Coverage for src/beamme/core/mesh.py: 95%
362 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-11 12:17 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-11 12:17 +0000
1# The MIT License (MIT)
2#
3# Copyright (c) 2018-2025 BeamMe Authors
4#
5# Permission is hereby granted, free of charge, to any person obtaining a copy
6# of this software and associated documentation files (the "Software"), to deal
7# in the Software without restriction, including without limitation the rights
8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9# copies of the Software, and to permit persons to whom the Software is
10# furnished to do so, subject to the following conditions:
11#
12# The above copyright notice and this permission notice shall be included in
13# all copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21# THE SOFTWARE.
22"""This module defines the Mesh class, which holds the content (nodes,
23elements, sets, ...) for a meshed geometry."""
25import copy as _copy
26import os as _os
27import warnings as _warnings
28from typing import Dict as _Dict
29from typing import List as _List
30from typing import Optional as _Optional
32import numpy as _np
33import pyvista as _pv
34import quaternion as _quaternion
35from numpy.typing import NDArray as _NDArray
37from beamme.core.boundary_condition import (
38 BoundaryConditionBase as _BoundaryConditionBase,
39)
40from beamme.core.boundary_condition import (
41 BoundaryConditionContainer as _BoundaryConditionContainer,
42)
43from beamme.core.conf import bme as _bme
44from beamme.core.coupling import coupling_factory as _coupling_factory
45from beamme.core.element import Element as _Element
46from beamme.core.element_beam import Beam as _Beam
47from beamme.core.function import Function as _Function
48from beamme.core.geometry_set import GeometryName as _GeometryName
49from beamme.core.geometry_set import GeometrySetBase as _GeometrySetBase
50from beamme.core.geometry_set import GeometrySetContainer as _GeometrySetContainer
51from beamme.core.material import Material as _Material
52from beamme.core.node import Node as _Node
53from beamme.core.node import NodeCosserat as _NodeCosserat
54from beamme.core.rotation import Rotation as _Rotation
55from beamme.core.rotation import add_rotations as _add_rotations
56from beamme.core.rotation import rotate_coordinates as _rotate_coordinates
57from beamme.core.vtk_writer import VTKWriter as _VTKWriter
58from beamme.geometric_search.find_close_points import (
59 find_close_points as _find_close_points,
60)
61from beamme.geometric_search.find_close_points import (
62 point_partners_to_partner_indices as _point_partners_to_partner_indices,
63)
64from beamme.utils.environment import is_testing as _is_testing
65from beamme.utils.nodes import filter_nodes as _filter_nodes
66from beamme.utils.nodes import find_close_nodes as _find_close_nodes
67from beamme.utils.nodes import get_min_max_nodes as _get_min_max_nodes
68from beamme.utils.nodes import get_nodal_coordinates as _get_nodal_coordinates
69from beamme.utils.nodes import get_nodal_quaternions as _get_nodal_quaternions
70from beamme.utils.nodes import get_nodes_by_function as _get_nodes_by_function
73class Mesh:
74 """A class that contains a full mesh, i.e. Nodes, Elements, Boundary
75 Conditions, Sets, Couplings, Materials and Functions."""
77 def __init__(self):
78 """Initialize all empty containers."""
80 self.nodes = []
81 self.elements = []
82 self.materials = []
83 self.functions = []
84 self.geometry_sets = _GeometrySetContainer()
85 self.boundary_conditions = _BoundaryConditionContainer()
87 @staticmethod
88 def get_base_mesh_item_type(item):
89 """Return the base mesh type of the given item.
91 Amongst other things, we need this function so we can check if
92 all items of a list are of the same "base" type.
94 Args:
95 item: The object we want to get the base type from.
96 """
98 for cls in (
99 Mesh,
100 _Function,
101 _BoundaryConditionBase,
102 _Material,
103 _Node,
104 _Element,
105 _GeometrySetBase,
106 _GeometryName,
107 ):
108 if isinstance(item, cls):
109 return cls
110 return type(item)
112 def add(self, *args, **kwargs):
113 """Add an item to this mesh, depending on its type.
115 If an list is given each list element is added with this
116 function. If multiple arguments are given, each one is
117 individually added with this function. Keyword arguments are
118 passed through to the adding function.
119 """
121 match len(args):
122 case 0:
123 raise ValueError("At least one argument is required!")
124 case 1:
125 add_item = args[0]
126 base_type = self.get_base_mesh_item_type(add_item)
128 base_type_to_method_map = {
129 Mesh: self.add_mesh,
130 _Function: self.add_function,
131 _BoundaryConditionBase: self.add_bc,
132 _Material: self.add_material,
133 _Node: self.add_node,
134 _Element: self.add_element,
135 _GeometrySetBase: self.add_geometry_set,
136 _GeometryName: self.add_geometry_name,
137 list: self.add_list,
138 }
139 if base_type in base_type_to_method_map:
140 base_type_to_method_map[base_type](add_item, **kwargs)
141 else:
142 raise TypeError(
143 f'No Mesh.add case implemented for type: "{type(add_item)}" with base type "{base_type}"!'
144 )
145 case _:
146 for item in args:
147 self.add(item, **kwargs)
149 def add_mesh(self, mesh):
150 """Add the content of another mesh to this mesh."""
152 # Add each item from mesh to self.
153 self.add(mesh.nodes)
154 self.add(mesh.elements)
155 self.add(mesh.materials)
156 self.add(mesh.functions)
157 self.geometry_sets.extend(mesh.geometry_sets)
158 self.boundary_conditions.extend(mesh.boundary_conditions)
160 def add_bc(self, bc):
161 """Add a boundary condition to this mesh."""
162 bc_key = bc.bc_type
163 geom_key = bc.geometry_set.geometry_type
164 bc.geometry_set.check_replaced_nodes()
165 self.boundary_conditions.append((bc_key, geom_key), bc)
167 def add_function(self, function):
168 """Add a function to this mesh item.
170 Check that the function is only added once.
171 """
172 if function not in self.functions:
173 self.functions.append(function)
175 def add_material(self, material):
176 """Add a material to this mesh item.
178 Check that the material is only added once.
179 """
180 if material not in self.materials:
181 self.materials.append(material)
183 def add_node(self, node):
184 """Add a node to this mesh."""
185 if node in self.nodes:
186 raise ValueError("The node is already in this mesh!")
187 self.nodes.append(node)
189 def add_element(self, element):
190 """Add an element to this mesh."""
191 if element in self.elements:
192 raise ValueError("The element is already in this mesh!")
193 self.elements.append(element)
195 def add_geometry_set(self, geometry_set):
196 """Add a geometry set to this mesh."""
197 geometry_set.check_replaced_nodes()
198 self.geometry_sets.append(geometry_set.geometry_type, geometry_set)
200 def add_geometry_name(self, geometry_name):
201 """Add a set of geometry sets to this mesh.
203 Sort by the keys here to create a deterministic ordering,
204 especially for testing purposes
205 """
206 keys = list(geometry_name.keys())
207 keys.sort()
208 for key in keys:
209 self.add(geometry_name[key])
211 def add_list(self, add_list: _List, **kwargs) -> None:
212 """Add a list of items to this mesh.
214 Args:
215 add_list:
216 List to be added to the mesh. This method checks that all
217 base types of the list items are the same.
219 In the special case of a node or element list, we add the whole list
220 at once. This avoids a check for duplicate entries for every
221 addition, which scales very badly. By doing it this way we only
222 check the final list for duplicate entries which is much more
223 performant.
225 For all other types of items, we add each element individually
226 via the Mesh.add method.
227 """
229 types = {self.get_base_mesh_item_type(item) for item in add_list}
230 if len(types) > 1:
231 raise TypeError(
232 f"You can only add lists with the same type of element. Got {types}"
233 )
234 elif len(types) == 1:
235 list_type = types.pop()
237 def extend_internal_list(self_list: _List, new_list: _List) -> None:
238 """Extend an internal list with the new list.
240 It is checked that the final list does not have
241 duplicate entries.
242 """
243 self_list.extend(new_list)
244 if not len(set(self_list)) == len(self_list):
245 raise ValueError(
246 "The added list contains entries already existing in the Mesh"
247 )
249 if list_type == _Node:
250 extend_internal_list(self.nodes, add_list)
251 elif list_type == _Element:
252 extend_internal_list(self.elements, add_list)
253 else:
254 for item in add_list:
255 self.add(item, **kwargs)
257 def replace_node(self, old_node, new_node):
258 """Replace the first node with the second node."""
260 # Check that the new node is in mesh.
261 if new_node not in self.nodes:
262 raise ValueError("The new node is not in the mesh!")
264 for i, node in enumerate(self.nodes):
265 if node == old_node:
266 del self.nodes[i]
267 break
268 else:
269 raise ValueError("The node that should be replaced is not in the mesh")
271 def get_unique_geometry_sets(
272 self,
273 *,
274 coupling_sets: bool = True,
275 link_to_nodes: str = "no_link",
276 geometry_set_start_indices: _Optional[_Dict] = None,
277 ):
278 """Return a geometry set container that contains geometry sets
279 explicitly added to the mesh, as well as sets for boundary conditions.
281 The i_global values are set in the returned geometry sets.
283 Args:
284 coupling_sets:
285 If this is true, also sets for couplings will be added.
286 link_to_nodes:
287 "no_link":
288 No link between the geometry set and the nodes is set
289 "explicitly_contained_nodes":
290 A link will be set for all nodes that are explicitly part of the geometry set
291 "all_nodes":
292 A link will be set for all nodes that are part of the geometry set, i.e., also
293 nodes connected to elements of an element set. This is mainly used for vtk
294 output so we can color the nodes which are part of element sets.
295 geometry_set_start_indices:
296 Dictionary where the keys are geometry type and the value is the starting
297 index for those geometry sets. This can be used to define an offset in the
298 i_global numbering of the geometry sets. The offsets default to 0.
299 """
301 is_link_nodes = not link_to_nodes == "no_link"
302 if is_link_nodes:
303 # First clear all links in existing nodes.
304 for node in self.nodes:
305 node.node_sets_link = []
307 # Make a copy of the sets in this mesh.
308 mesh_sets = self.geometry_sets.copy()
310 # Add sets from boundary conditions.
311 for (bc_key, geom_key), bc_list in self.boundary_conditions.items():
312 for bc in bc_list:
313 # Check if sets from couplings should be added.
314 is_coupling = bc_key in (
315 _bme.bc.point_coupling,
316 bc_key == _bme.bc.point_coupling_penalty,
317 )
318 if (is_coupling and coupling_sets) or (not is_coupling):
319 # Only add set if it is not already in the container.
320 # For example if multiple Neumann boundary conditions
321 # are applied on the same node set.
322 if bc.geometry_set not in mesh_sets[geom_key]:
323 mesh_sets[geom_key].append(bc.geometry_set)
325 for key in mesh_sets.keys():
326 i_global_offset = 0
327 if geometry_set_start_indices is not None:
328 if key in geometry_set_start_indices:
329 i_global_offset = geometry_set_start_indices[key]
330 else:
331 raise KeyError("Could not find {key} in geometry_set_start_indices")
332 for i, geometry_set in enumerate(mesh_sets[key]):
333 # Add global indices to the geometry set.
334 geometry_set.i_global = i + i_global_offset
335 if is_link_nodes:
336 geometry_set.link_to_nodes(link_to_nodes=link_to_nodes)
338 return mesh_sets
340 def set_node_links(self):
341 """Create a link of all elements to the nodes connected to them.
343 Also add a link to this mesh.
344 """
345 for element in self.elements:
346 for node in element.nodes:
347 node.element_link.append(element)
348 for node in self.nodes:
349 node.mesh = self
351 def translate(self, vector):
352 """Translate all beam nodes of this mesh.
354 Args
355 ----
356 vector: _np.array, list
357 3D vector that will be added to all nodes.
358 """
359 for node in self.nodes:
360 node.coordinates += vector
362 def rotate(
363 self,
364 rotation: _Rotation | _NDArray[_quaternion.quaternion],
365 origin=None,
366 only_rotate_triads: bool = False,
367 ) -> None:
368 """Rotate all beam nodes of the mesh with rotation.
370 Args:
371 rotation: The rotation(s) that will be applied to the nodes. If
372 this is an array, it has to hold a quaternion for each node.
373 origin (3D vector): If this is given, the mesh is rotated about
374 this point. Defaults to (0, 0, 0).
375 only_rotate_triads: If this is true, the nodal positions are not
376 changed.
377 """
379 # Get array with all quaternions for the nodes.
380 rot1 = _get_nodal_quaternions(self.nodes)
382 # Apply the rotation to the rotation of all nodes.
383 rot_new = _add_rotations(rotation, rot1)
385 if not only_rotate_triads:
386 # Get array with all positions for the nodes.
387 pos = _get_nodal_coordinates(self.nodes)
388 pos_new = _rotate_coordinates(pos, rotation, origin=origin)
390 for i, node in enumerate(self.nodes):
391 if isinstance(node, _NodeCosserat):
392 node.rotation.q = rot_new[i, :]
393 if not only_rotate_triads:
394 node.coordinates = pos_new[i, :]
396 def reflect(self, normal_vector, origin=None, flip_beams: bool = False) -> None:
397 """Reflect all nodes of the mesh with respect to a plane defined by its
398 normal_vector. Per default the plane goes through the origin, if not a
399 point on the plane can be given with the parameter origin.
401 For the reflection we assume that e1' and e2' are mirrored with respect
402 to the original frame and e3' is in the opposite direction than the
403 mirrored e3.
405 With the defined mirroring strategy, the quaternion to be applied on
406 the existing rotations can be calculated the following way:
407 q[0] = e3 * n
408 q[1,2,3] = e3 x n
409 This constructs a rotation with the rotation axis on the plane, and
410 normal to the vector e3. The rotation angle is twice the angle of e3
411 to n.
413 Args:
414 normal_vector (3D vector): The normal vector of the reflection plane.
415 origin (3D vector): The reflection plane goes through this point.
416 Defaults to (0, 0, 0).
417 flip_beams: When True, the beams are flipped, so that the direction
418 along the beam is reversed.
419 """
421 # Normalize the normal vector.
422 normal_vector = _np.asarray(normal_vector) / _np.linalg.norm(normal_vector)
424 # Get array with all quaternions and positions for the nodes.
425 pos = _get_nodal_coordinates(self.nodes)
426 rot1 = _get_nodal_quaternions(self.nodes)
428 # Check if origin has to be added.
429 if origin is not None:
430 pos -= origin
432 # Get the reflection matrix A.
433 A = _np.eye(3) - 2.0 * _np.outer(normal_vector, normal_vector)
435 # Calculate the new positions.
436 pos_new = _np.dot(pos, A)
438 # Move back from the origin.
439 if origin is not None:
440 pos_new += origin
442 # First get all e3 vectors of the nodes.
443 e3 = _np.zeros_like(pos)
444 e3[:, 0] = 2 * (rot1[:, 0] * rot1[:, 2] + rot1[:, 1] * rot1[:, 3])
445 e3[:, 1] = 2 * (-1 * rot1[:, 0] * rot1[:, 1] + rot1[:, 2] * rot1[:, 3])
446 e3[:, 2] = rot1[:, 0] ** 2 - rot1[:, 1] ** 2 - rot1[:, 2] ** 2 + rot1[:, 3] ** 2
448 # Get the dot and cross product of e3 and the normal vector.
449 rot2 = _np.zeros_like(rot1)
450 rot2[:, 0] = _np.dot(e3, normal_vector)
451 rot2[:, 1:] = _np.cross(e3, normal_vector)
453 # Add to the existing rotations.
454 rot_new = _add_rotations(rot2, rot1)
456 if flip_beams:
457 # To achieve the flip, the triads are rotated with the angle pi
458 # around the e2 axis.
459 rot_flip = _Rotation([0, 1, 0], _np.pi)
460 rot_new = _add_rotations(rot_new, rot_flip)
462 # For solid elements we need to adapt the connectivity to avoid negative Jacobians.
463 # For beam elements this is optional.
464 for element in self.elements:
465 if isinstance(element, _Beam):
466 if flip_beams:
467 element.flip()
468 else:
469 element.flip()
471 # Set the new positions and rotations.
472 for i, node in enumerate(self.nodes):
473 node.coordinates = pos_new[i, :]
474 if isinstance(node, _NodeCosserat):
475 node.rotation.q = rot_new[i, :]
477 def wrap_around_cylinder(
478 self, radius: float | None = None, advanced_warning: bool = True
479 ) -> None:
480 """Wrap the geometry around a cylinder. The y-z plane gets morphed into
481 the z-axis of symmetry. If all nodes are on the same y-z plane, the
482 radius of the created cylinder is the x coordinate of that plane. If
483 the nodes are not on the same y-z plane, the radius has to be given
484 explicitly.
486 Args:
487 radius: If this value is given AND not all nodes are on the same y-z
488 plane, then use this radius for the calculation of phi for all
489 nodes. This might still lead to distorted elements!
490 advanced_warning: If each element should be checked if it is either parallel
491 to the y-z or x-z plane. This is computationally expensive, but in most
492 cases (up to 100,000 elements) this check can be left activated.
493 """
495 pos = _get_nodal_coordinates(self.nodes)
496 quaternions = _np.zeros([len(self.nodes), 4])
498 # The x coordinate is the radius, the y coordinate the arc length.
499 points_x = pos[:, 0].copy()
501 # Check if all points are on the same y-z plane.
502 if _np.abs(_np.min(points_x) - _np.max(points_x)) > _bme.eps_pos:
503 # The points are not all on the y-z plane, get the reference
504 # radius.
505 if radius is not None:
506 if advanced_warning:
507 # Here we check, if each element lays on a plane parallel
508 # to the y-z plane, or parallel to the x-z plane.
509 #
510 # To be exactly sure, we could check the rotations here,
511 # i.e. if they are also in plane.
512 element_warning = []
513 for i_element, element in enumerate(self.elements):
514 element_coordinates = _np.zeros([len(element.nodes), 3])
515 for i_node, node in enumerate(element.nodes):
516 element_coordinates[i_node, :] = node.coordinates
517 is_yz = (
518 _np.max(
519 _np.abs(
520 element_coordinates[:, 0]
521 - element_coordinates[0, 0]
522 )
523 )
524 < _bme.eps_pos
525 )
526 is_xz = (
527 _np.max(
528 _np.abs(
529 element_coordinates[:, 1]
530 - element_coordinates[0, 1]
531 )
532 )
533 < _bme.eps_pos
534 )
535 if not (is_yz or is_xz):
536 element_warning.append(i_element)
537 if len(element_warning) != 0:
538 _warnings.warn(
539 "There are elements which are not "
540 "parallel to the y-z or x-y plane. This will lead "
541 "to distorted elements!"
542 )
543 else:
544 _warnings.warn(
545 "The nodes are not on the same y-z plane. "
546 "This may lead to distorted elements!"
547 )
548 else:
549 raise ValueError(
550 "The nodes that should be wrapped around a "
551 "cylinder are not on the same y-z plane. This will give "
552 "unexpected results. Give a reference radius!"
553 )
554 radius_phi = radius
555 radius_points = points_x
556 elif radius is None or _np.abs(points_x[0] - radius) < _bme.eps_pos:
557 radius_points = radius_phi = points_x[0]
558 else:
559 raise ValueError(
560 (
561 "The points are all on the same y-z plane with "
562 "the x-coordinate {} but the given radius {} is different. "
563 "This does not make sense."
564 ).format(points_x[0], radius)
565 )
567 # Get the angle for all nodes.
568 phi = pos[:, 1] / radius_phi
570 # The rotation is about the z-axis.
571 quaternions[:, 0] = _np.cos(0.5 * phi)
572 quaternions[:, 3] = _np.sin(0.5 * phi)
574 # Set the new positions in the global array.
575 pos[:, 0] = radius_points * _np.cos(phi)
576 pos[:, 1] = radius_points * _np.sin(phi)
578 # Rotate the mesh
579 self.rotate(quaternions, only_rotate_triads=True)
581 # Set the new position for the nodes.
582 for i, node in enumerate(self.nodes):
583 node.coordinates = pos[i, :]
585 def couple_nodes(
586 self,
587 *,
588 nodes=None,
589 reuse_matching_nodes=False,
590 coupling_type=_bme.bc.point_coupling,
591 coupling_dof_type=_bme.coupling_dof.fix,
592 ):
593 """Search through nodes and connect all nodes with the same
594 coordinates.
596 Args:
597 ----
598 nodes: [Node]
599 List of nodes to couple. If None is given, all nodes of the mesh
600 are coupled (except middle nodes).
601 reuse_matching_nodes: bool
602 If two nodes have the same position and rotation, the nodes are
603 reduced to one node in the mesh. Be aware, that this might lead to
604 issues if not all DOFs of the nodes should be coupled.
605 coupling_type: bme.bc
606 Type of point coupling.
607 coupling_dof_type: str, bme.coupling_dof
608 str: The string that will be used in the input file.
609 bme.coupling_dof.fix: Fix all positional and rotational DOFs of the
610 nodes together.
611 bme.coupling_dof.joint: Fix all positional DOFs of the nodes
612 together.
613 """
615 # Check that a coupling BC is given.
616 if coupling_type not in (
617 _bme.bc.point_coupling,
618 _bme.bc.point_coupling_penalty,
619 ):
620 raise ValueError(
621 "Only coupling conditions can be applied in 'couple_nodes'!"
622 )
624 # Get the nodes that should be checked for coupling. Middle nodes are
625 # not checked, as coupling can only be applied to the boundary nodes.
626 if nodes is None:
627 node_list = self.nodes
628 else:
629 node_list = nodes
630 node_list = _filter_nodes(node_list, middle_nodes=False)
631 partner_nodes = _find_close_nodes(node_list)
632 if len(partner_nodes) == 0:
633 # If no partner nodes were found, end this function.
634 return
636 if reuse_matching_nodes:
637 # Check if there are nodes with the same rotation. If there are the
638 # nodes are reused, and no coupling is inserted.
640 # Set the links to all nodes in the mesh. In this case we have to use
641 # "all_nodes" since we also have to replace nodes that are in existing
642 # GeometrySetNodes.
643 self.unlink_nodes()
644 self.get_unique_geometry_sets(link_to_nodes="explicitly_contained_nodes")
645 self.set_node_links()
647 # Go through partner nodes.
648 for node_list in partner_nodes:
649 # Get array with rotation vectors.
650 rotation_vectors = _np.zeros([len(node_list), 3])
651 for i, node in enumerate(node_list):
652 if isinstance(node, _NodeCosserat):
653 rotation_vectors[i, :] = node.rotation.get_rotation_vector()
654 else:
655 # For the case of nodes that belong to solid elements,
656 # we define the following default value:
657 rotation_vectors[i, :] = [4 * _np.pi, 0, 0]
659 # Use find close points function to find nodes with the
660 # same rotation.
661 partners, n_partners = _find_close_points(
662 rotation_vectors, tol=_bme.eps_quaternion
663 )
665 # Check if nodes with the same rotations were found.
666 if n_partners == 0:
667 self.add(
668 _coupling_factory(node_list, coupling_type, coupling_dof_type)
669 )
670 else:
671 # There are nodes that need to be combined.
672 combining_nodes = []
673 coupling_nodes = []
674 found_partner_id = [None for _i in range(n_partners)]
676 # Add the nodes that need to be combined and add the nodes
677 # that will be coupled.
678 for i, partner in enumerate(partners):
679 if partner == -1:
680 # This node does not have a partner with the same
681 # rotation.
682 coupling_nodes.append(node_list[i])
684 elif found_partner_id[partner] is not None:
685 # This node has already a processed partner, add
686 # this one to the combining nodes.
687 combining_nodes[found_partner_id[partner]].append(
688 node_list[i]
689 )
691 else:
692 # This is the first node of a partner set that was
693 # found. This one will remain, the other ones will
694 # be replaced with this one.
695 new_index = len(combining_nodes)
696 found_partner_id[partner] = new_index
697 combining_nodes.append([node_list[i]])
698 coupling_nodes.append(node_list[i])
700 # Add the coupling nodes.
701 if len(coupling_nodes) > 1:
702 self.add(
703 _coupling_factory(
704 coupling_nodes, coupling_type, coupling_dof_type
705 )
706 )
708 # Replace the identical nodes.
709 for combine_list in combining_nodes:
710 master_node = combine_list[0]
711 for node in combine_list[1:]:
712 node.replace_with(master_node)
714 else:
715 # Connect close nodes with a coupling.
716 for node_list in partner_nodes:
717 self.add(_coupling_factory(node_list, coupling_type, coupling_dof_type))
719 def unlink_nodes(self):
720 """Delete the linked arrays and global indices in all nodes."""
721 for node in self.nodes:
722 node.unlink()
724 def get_nodes_by_function(self, *args, **kwargs):
725 """Return all nodes for which the function evaluates to true."""
726 return _get_nodes_by_function(self.nodes, *args, **kwargs)
728 def get_min_max_nodes(self, *args, **kwargs):
729 """Return a geometry set with the max and min nodes in all
730 directions."""
731 return _get_min_max_nodes(self.nodes, *args, **kwargs)
733 def check_overlapping_elements(self, raise_error=True):
734 """Check if there are overlapping elements in the mesh.
736 This is done by checking if all middle nodes of beam elements
737 have unique coordinates in the mesh.
738 """
740 # Number of middle nodes.
741 middle_nodes = [node for node in self.nodes if node.is_middle_node]
743 # Only check if there are middle nodes.
744 if len(middle_nodes) == 0:
745 return
747 # Get array with middle nodes.
748 coordinates = _np.zeros([len(middle_nodes), 3])
749 for i, node in enumerate(middle_nodes):
750 coordinates[i, :] = node.coordinates
752 # Check if there are double entries in the coordinates.
753 has_partner, partner = _find_close_points(coordinates)
754 partner_indices = _point_partners_to_partner_indices(has_partner, partner)
755 if partner > 0:
756 if raise_error:
757 raise ValueError(
758 "There are multiple middle nodes with the "
759 "same coordinates. Per default this raises an error! "
760 "This check can be turned of with "
761 "bme.check_overlapping_elements=False"
762 )
763 else:
764 _warnings.warn(
765 "There are multiple middle nodes with the same coordinates!"
766 )
768 # Add the partner index to the middle nodes.
769 for i_partner, partners in enumerate(partner_indices):
770 for i_node in partners:
771 middle_nodes[i_node].element_partner_index = i_partner
773 def get_vtk_representation(
774 self, *, overlapping_elements=True, coupling_sets=False, **kwargs
775 ):
776 """Return a vtk representation of the beams and solid in this mesh.
778 Args
779 ----
780 overlapping_elements: bool
781 I elements should be checked for overlapping. If they overlap, the
782 output will mark them.
783 coupling_sets: bool
784 If coupling sets should also be displayed.
785 """
787 # Object to store VKT data (and write it to file)
788 vtk_writer_beam = _VTKWriter()
789 vtk_writer_solid = _VTKWriter()
791 # Get the set numbers of the mesh
792 mesh_sets = self.get_unique_geometry_sets(
793 coupling_sets=coupling_sets, link_to_nodes="all_nodes"
794 )
796 # Set the global value for digits in the VTK output.
797 # Get highest number of node_sets.
798 max_sets = max(len(geometry_list) for geometry_list in mesh_sets.values())
800 # Set the bme value.
801 digits = len(str(max_sets))
802 _bme.vtk_node_set_format = "{:0" + str(digits) + "}"
804 if overlapping_elements:
805 # Check for overlapping elements.
806 self.check_overlapping_elements(raise_error=False)
808 # Get representation of elements.
809 for element in self.elements:
810 element.get_vtk(vtk_writer_beam, vtk_writer_solid, **kwargs)
812 # Finish and return the writers
813 vtk_writer_beam.complete_data()
814 vtk_writer_solid.complete_data()
815 return vtk_writer_beam, vtk_writer_solid
817 def write_vtk(
818 self, output_name="beamme", output_directory="", binary=True, **kwargs
819 ):
820 """Write the contents of this mesh to VTK files.
822 Args
823 ----
824 output_name: str
825 Base name of the output file. There will be a {}_beam.vtu and
826 {}_solid.vtu file.
827 output_directory: path
828 Directory where the output files will be written.
829 binary: bool
830 If the data should be written encoded in binary or in human readable text
832 **kwargs
833 For all of them look into:
834 Mesh().get_vtk_representation
835 Beam().get_vtk
836 VolumeElement().get_vtk
837 ----
838 beam_centerline_visualization_segments: int
839 Number of segments to be used for visualization of beam centerline between successive
840 nodes. Default is 1, which means a straight line is drawn between the beam nodes. For
841 Values greater than 1, a Hermite interpolation of the centerline is assumed for
842 visualization purposes.
843 """
845 vtk_writer_beam, vtk_writer_solid = self.get_vtk_representation(**kwargs)
847 # Write to file, only if there is at least one point in the writer.
848 if vtk_writer_beam.points.GetNumberOfPoints() > 0:
849 filepath = _os.path.join(output_directory, output_name + "_beam.vtu")
850 vtk_writer_beam.write_vtk(filepath, binary=binary)
851 if vtk_writer_solid.points.GetNumberOfPoints() > 0:
852 filepath = _os.path.join(output_directory, output_name + "_solid.vtu")
853 vtk_writer_solid.write_vtk(filepath, binary=binary)
855 def display_pyvista(
856 self,
857 *,
858 beam_nodes=True,
859 beam_tube=True,
860 beam_cross_section_directors=True,
861 beam_radius_for_display=None,
862 resolution=20,
863 parallel_projection=False,
864 **kwargs,
865 ):
866 """Display the mesh in pyvista.
868 If this is called in a GitHub testing run, nothing will be shown, instead
869 the _pv.plotter object will be returned.
871 Args
872 ----
873 beam_nodes: bool
874 If the beam nodes should be displayed. The start and end nodes of each
875 beam will be shown in green, possible middle nodes inside the element
876 are shown in cyan.
877 beam_tube: bool
878 If the beam should be rendered as a tube
879 beam_cross_section_directors: bool
880 If the cross section directors should be displayed (at each node)
881 beam_radius_for_display: float
882 If not all beams have an explicitly given radius (in the material
883 definition) this value will be used to approximate the beams radius
884 for visualization
885 resolution: int
886 Indicates how many triangulations will be performed to visualize arrows,
887 tubes and spheres.
888 parallel_projection: bool
889 Flag to change camera view to parallel projection.
891 **kwargs
892 For all of them look into:
893 Mesh().get_vtk_representation
894 Beam().get_vtk
895 VolumeElement().get_vtk
896 ----
897 beam_centerline_visualization_segments: int
898 Number of segments to be used for visualization of beam centerline between successive
899 nodes. Default is 1, which means a straight line is drawn between the beam nodes. For
900 Values greater than 1, a Hermite interpolation of the centerline is assumed for
901 visualization purposes.
902 """
904 plotter = _pv.Plotter()
905 plotter.renderer.add_axes()
907 if parallel_projection:
908 plotter.enable_parallel_projection()
910 vtk_writer_beam, vtk_writer_solid = self.get_vtk_representation(**kwargs)
912 if vtk_writer_beam.points.GetNumberOfPoints() > 0:
913 beam_grid = _pv.UnstructuredGrid(vtk_writer_beam.grid)
915 # Check if all beams have a given cross-section radius, if not set the given input
916 # value
917 all_beams_have_cross_section_radius = (
918 min(beam_grid.cell_data["cross_section_radius"]) > 0
919 )
920 if not all_beams_have_cross_section_radius:
921 if beam_radius_for_display is None:
922 raise ValueError(
923 "Not all beams have a radius, you need to set "
924 "beam_radius_for_display to allow a display of the beams."
925 )
926 beam_grid.cell_data["cross_section_radius"] = beam_radius_for_display
928 # Grid with beam polyline
929 beam_grid = beam_grid.cell_data_to_point_data()
931 # Poly data for nodes
932 finite_element_nodes = beam_grid.cast_to_poly_points().threshold(
933 scalars="node_value", value=(0.4, 1.1)
934 )
936 # Plot the nodes
937 node_radius_scaling_factor = 1.5
938 if beam_nodes:
939 sphere = _pv.Sphere(
940 radius=1.0,
941 theta_resolution=resolution,
942 phi_resolution=resolution,
943 )
944 nodes_glyph = finite_element_nodes.glyph(
945 geom=sphere,
946 scale="cross_section_radius",
947 factor=node_radius_scaling_factor,
948 orient=False,
949 )
950 plotter.add_mesh(
951 nodes_glyph.threshold(scalars="node_value", value=(0.9, 1.1)),
952 color="green",
953 )
954 middle_nodes = nodes_glyph.threshold(
955 scalars="node_value", value=(0.4, 0.6)
956 )
957 if len(middle_nodes.points) > 0:
958 plotter.add_mesh(middle_nodes, color="cyan")
960 # Plot the beams
961 beam_color = [0.5, 0.5, 0.5]
962 if beam_tube:
963 surface = beam_grid.extract_surface()
964 if all_beams_have_cross_section_radius:
965 tube = surface.tube(
966 scalars="cross_section_radius",
967 absolute=True,
968 n_sides=resolution,
969 )
970 else:
971 tube = surface.tube(
972 radius=beam_radius_for_display, n_sides=resolution
973 )
974 plotter.add_mesh(tube, color=beam_color)
975 else:
976 plotter.add_mesh(beam_grid, color=beam_color, line_width=4)
978 # Plot the directors of the beam cross-section
979 director_radius_scaling_factor = 3.5
980 if beam_cross_section_directors:
981 arrow = _pv.Arrow(
982 tip_resolution=resolution, shaft_resolution=resolution
983 )
984 directors = [
985 finite_element_nodes.glyph(
986 geom=arrow,
987 orient=f"base_vector_{i + 1}",
988 scale="cross_section_radius",
989 factor=director_radius_scaling_factor,
990 )
991 for i in range(3)
992 ]
993 colors = ["white", "blue", "red"]
994 for i, arrow in enumerate(directors):
995 plotter.add_mesh(arrow, color=colors[i])
997 if vtk_writer_solid.points.GetNumberOfPoints() > 0:
998 solid_grid = _pv.UnstructuredGrid(vtk_writer_solid.grid).clean()
999 plotter.add_mesh(solid_grid, color="white", show_edges=True, opacity=0.5)
1001 if not _is_testing():
1002 plotter.show()
1003 else:
1004 return plotter
1006 def copy(self):
1007 """Return a deep copy of this mesh.
1009 The functions and materials will not be deep copied.
1010 """
1011 return _copy.deepcopy(self)