Coverage for src / beamme / core / mesh.py: 95%
359 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-23 08:08 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-23 08:08 +0000
1# The MIT License (MIT)
2#
3# Copyright (c) 2018-2026 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_nodal_coordinates as _get_nodal_coordinates
68from beamme.utils.nodes import get_nodal_quaternions as _get_nodal_quaternions
69from beamme.utils.nodes import get_nodes_by_function as _get_nodes_by_function
72class Mesh:
73 """A class that contains a full mesh, i.e. Nodes, Elements, Boundary
74 Conditions, Sets, Couplings, Materials and Functions."""
76 def __init__(self):
77 """Initialize all empty containers."""
79 self.nodes = []
80 self.elements = []
81 self.materials = []
82 self.functions = []
83 self.geometry_sets = _GeometrySetContainer()
84 self.boundary_conditions = _BoundaryConditionContainer()
86 @staticmethod
87 def get_base_mesh_item_type(item):
88 """Return the base mesh type of the given item.
90 Amongst other things, we need this function so we can check if
91 all items of a list are of the same "base" type.
93 Args:
94 item: The object we want to get the base type from.
95 """
97 for cls in (
98 Mesh,
99 _Function,
100 _BoundaryConditionBase,
101 _Material,
102 _Node,
103 _Element,
104 _GeometrySetBase,
105 _GeometryName,
106 ):
107 if isinstance(item, cls):
108 return cls
109 return type(item)
111 def add(self, *args, **kwargs):
112 """Add an item to this mesh, depending on its type.
114 If an list is given each list element is added with this
115 function. If multiple arguments are given, each one is
116 individually added with this function. Keyword arguments are
117 passed through to the adding function.
118 """
120 match len(args):
121 case 0:
122 raise ValueError("At least one argument is required!")
123 case 1:
124 add_item = args[0]
125 base_type = self.get_base_mesh_item_type(add_item)
127 base_type_to_method_map = {
128 Mesh: self.add_mesh,
129 _Function: self.add_function,
130 _BoundaryConditionBase: self.add_bc,
131 _Material: self.add_material,
132 _Node: self.add_node,
133 _Element: self.add_element,
134 _GeometrySetBase: self.add_geometry_set,
135 _GeometryName: self.add_geometry_name,
136 list: self.add_list,
137 }
138 if base_type in base_type_to_method_map:
139 base_type_to_method_map[base_type](add_item, **kwargs)
140 else:
141 raise TypeError(
142 f'No Mesh.add case implemented for type: "{type(add_item)}" with base type "{base_type}"!'
143 )
144 case _:
145 for item in args:
146 self.add(item, **kwargs)
148 def add_mesh(self, mesh):
149 """Add the content of another mesh to this mesh."""
151 # Add each item from mesh to self.
152 self.add(mesh.nodes)
153 self.add(mesh.elements)
154 self.add(mesh.materials)
155 self.add(mesh.functions)
156 self.geometry_sets.extend(mesh.geometry_sets)
157 self.boundary_conditions.extend(mesh.boundary_conditions)
159 def add_bc(self, bc):
160 """Add a boundary condition to this mesh."""
161 bc_key = bc.bc_type
162 geom_key = bc.geometry_set.geometry_type
163 bc.geometry_set.check_replaced_nodes()
164 self.boundary_conditions.append((bc_key, geom_key), bc)
166 def add_function(self, function):
167 """Add a function to this mesh item.
169 Check that the function is only added once.
170 """
171 if function not in self.functions:
172 self.functions.append(function)
174 def add_material(self, material):
175 """Add a material to this mesh item.
177 Check that the material is only added once.
178 """
179 if material not in self.materials:
180 self.materials.append(material)
182 def add_node(self, node):
183 """Add a node to this mesh."""
184 if node in self.nodes:
185 raise ValueError("The node is already in this mesh!")
186 self.nodes.append(node)
188 def add_element(self, element):
189 """Add an element to this mesh."""
190 if element in self.elements:
191 raise ValueError("The element is already in this mesh!")
192 self.elements.append(element)
194 def add_geometry_set(self, geometry_set):
195 """Add a geometry set to this mesh."""
196 geometry_set.check_replaced_nodes()
197 self.geometry_sets.append(geometry_set.geometry_type, geometry_set)
199 def add_geometry_name(self, geometry_name):
200 """Add a set of geometry sets to this mesh.
202 Sort by the keys here to create a deterministic ordering,
203 especially for testing purposes
204 """
205 keys = list(geometry_name.keys())
206 keys.sort()
207 for key in keys:
208 self.add(geometry_name[key])
210 def add_list(self, add_list: _List, **kwargs) -> None:
211 """Add a list of items to this mesh.
213 Args:
214 add_list:
215 List to be added to the mesh. This method checks that all
216 base types of the list items are the same.
218 In the special case of a node or element list, we add the whole list
219 at once. This avoids a check for duplicate entries for every
220 addition, which scales very badly. By doing it this way we only
221 check the final list for duplicate entries which is much more
222 performant.
224 For all other types of items, we add each element individually
225 via the Mesh.add method.
226 """
228 types = {self.get_base_mesh_item_type(item) for item in add_list}
229 if len(types) > 1:
230 raise TypeError(
231 f"You can only add lists with the same type of element. Got {types}"
232 )
233 elif len(types) == 1:
234 list_type = types.pop()
236 def extend_internal_list(self_list: _List, new_list: _List) -> None:
237 """Extend an internal list with the new list.
239 It is checked that the final list does not have
240 duplicate entries.
241 """
242 self_list.extend(new_list)
243 if not len(set(self_list)) == len(self_list):
244 raise ValueError(
245 "The added list contains entries already existing in the Mesh"
246 )
248 if list_type == _Node:
249 extend_internal_list(self.nodes, add_list)
250 elif list_type == _Element:
251 extend_internal_list(self.elements, add_list)
252 else:
253 for item in add_list:
254 self.add(item, **kwargs)
256 def replace_node(self, old_node, new_node):
257 """Replace the first node with the second node."""
259 # Check that the new node is in mesh.
260 if new_node not in self.nodes:
261 raise ValueError("The new node is not in the mesh!")
263 for i, node in enumerate(self.nodes):
264 if node == old_node:
265 del self.nodes[i]
266 break
267 else:
268 raise ValueError("The node that should be replaced is not in the mesh")
270 def get_unique_geometry_sets(
271 self,
272 *,
273 coupling_sets: bool = True,
274 link_to_nodes: str = "no_link",
275 geometry_set_start_indices: _Optional[_Dict] = None,
276 ) -> _GeometrySetContainer:
277 """Return a geometry set container that contains geometry sets
278 explicitly added to the mesh, as well as sets for boundary conditions.
280 The i_global values are set in the returned geometry sets.
282 Args:
283 coupling_sets:
284 If this is true, also sets for couplings will be added.
285 link_to_nodes:
286 "no_link":
287 No link between the geometry set and the nodes is set
288 "explicitly_contained_nodes":
289 A link will be set for all nodes that are explicitly part of the geometry set
290 "all_nodes":
291 A link will be set for all nodes that are part of the geometry set, i.e., also
292 nodes connected to elements of an element set. This is mainly used for vtk
293 output so we can color the nodes which are part of element sets.
294 geometry_set_start_indices:
295 Dictionary where the keys are geometry type and the value is the starting
296 index for those geometry sets. This can be used to define an offset in the
297 i_global numbering of the geometry sets. The offsets default to 0.
298 """
300 is_link_nodes = not link_to_nodes == "no_link"
301 if is_link_nodes:
302 # First clear all links in existing nodes.
303 for node in self.nodes:
304 node.node_sets_link = []
306 # Make a copy of the sets in this mesh.
307 mesh_sets = self.geometry_sets.copy()
309 # Add sets from boundary conditions.
310 for (bc_key, geom_key), bc_list in self.boundary_conditions.items():
311 for bc in bc_list:
312 # Check if sets from couplings should be added.
313 is_coupling = bc_key in (
314 _bme.bc.point_coupling,
315 bc_key == _bme.bc.point_coupling_penalty,
316 )
317 if (is_coupling and coupling_sets) or (not is_coupling):
318 # Only add set if it is not already in the container.
319 # For example if multiple Neumann boundary conditions
320 # are applied on the same node set.
321 if bc.geometry_set not in mesh_sets[geom_key]:
322 mesh_sets[geom_key].append(bc.geometry_set)
324 for key in mesh_sets.keys():
325 i_global_offset = 0
326 if geometry_set_start_indices is not None:
327 if key in geometry_set_start_indices:
328 i_global_offset = geometry_set_start_indices[key]
329 else:
330 raise KeyError("Could not find {key} in geometry_set_start_indices")
331 for i, geometry_set in enumerate(mesh_sets[key]):
332 # Add global indices to the geometry set.
333 geometry_set.i_global = i + i_global_offset
334 if is_link_nodes:
335 geometry_set.link_to_nodes(link_to_nodes=link_to_nodes)
337 return mesh_sets
339 def set_node_links(self):
340 """Create a link of all elements to the nodes connected to them.
342 Also add a link to this mesh.
343 """
344 for element in self.elements:
345 for node in element.nodes:
346 node.element_link.append(element)
347 for node in self.nodes:
348 node.mesh = self
350 def translate(self, vector):
351 """Translate all beam nodes of this mesh.
353 Args
354 ----
355 vector: _np.array, list
356 3D vector that will be added to all nodes.
357 """
358 for node in self.nodes:
359 node.coordinates += vector
361 def rotate(
362 self,
363 rotation: _Rotation | _NDArray[_quaternion.quaternion],
364 origin=None,
365 only_rotate_triads: bool = False,
366 ) -> None:
367 """Rotate all beam nodes of the mesh with rotation.
369 Args:
370 rotation: The rotation(s) that will be applied to the nodes. If
371 this is an array, it has to hold a quaternion for each node.
372 origin (3D vector): If this is given, the mesh is rotated about
373 this point. Defaults to (0, 0, 0).
374 only_rotate_triads: If this is true, the nodal positions are not
375 changed.
376 """
378 # Get array with all quaternions for the nodes.
379 rot1 = _get_nodal_quaternions(self.nodes)
381 # Apply the rotation to the rotation of all nodes.
382 rot_new = _add_rotations(rotation, rot1)
384 if not only_rotate_triads:
385 # Get array with all positions for the nodes.
386 pos = _get_nodal_coordinates(self.nodes)
387 pos_new = _rotate_coordinates(pos, rotation, origin=origin)
389 for i, node in enumerate(self.nodes):
390 if isinstance(node, _NodeCosserat):
391 node.rotation.q = rot_new[i, :]
392 if not only_rotate_triads:
393 node.coordinates = pos_new[i, :]
395 def reflect(self, normal_vector, origin=None, flip_beams: bool = False) -> None:
396 """Reflect all nodes of the mesh with respect to a plane defined by its
397 normal_vector. Per default the plane goes through the origin, if not a
398 point on the plane can be given with the parameter origin.
400 For the reflection we assume that e1' and e2' are mirrored with respect
401 to the original frame and e3' is in the opposite direction than the
402 mirrored e3.
404 With the defined mirroring strategy, the quaternion to be applied on
405 the existing rotations can be calculated the following way:
406 q[0] = e3 * n
407 q[1,2,3] = e3 x n
408 This constructs a rotation with the rotation axis on the plane, and
409 normal to the vector e3. The rotation angle is twice the angle of e3
410 to n.
412 Args:
413 normal_vector (3D vector): The normal vector of the reflection plane.
414 origin (3D vector): The reflection plane goes through this point.
415 Defaults to (0, 0, 0).
416 flip_beams: When True, the beams are flipped, so that the direction
417 along the beam is reversed.
418 """
420 # Normalize the normal vector.
421 normal_vector = _np.asarray(normal_vector) / _np.linalg.norm(normal_vector)
423 # Get array with all quaternions and positions for the nodes.
424 pos = _get_nodal_coordinates(self.nodes)
425 rot1 = _get_nodal_quaternions(self.nodes)
427 # Check if origin has to be added.
428 if origin is not None:
429 pos -= origin
431 # Get the reflection matrix A.
432 A = _np.eye(3) - 2.0 * _np.outer(normal_vector, normal_vector)
434 # Calculate the new positions.
435 pos_new = _np.dot(pos, A)
437 # Move back from the origin.
438 if origin is not None:
439 pos_new += origin
441 # First get all e3 vectors of the nodes.
442 e3 = _np.zeros_like(pos)
443 e3[:, 0] = 2 * (rot1[:, 0] * rot1[:, 2] + rot1[:, 1] * rot1[:, 3])
444 e3[:, 1] = 2 * (-1 * rot1[:, 0] * rot1[:, 1] + rot1[:, 2] * rot1[:, 3])
445 e3[:, 2] = rot1[:, 0] ** 2 - rot1[:, 1] ** 2 - rot1[:, 2] ** 2 + rot1[:, 3] ** 2
447 # Get the dot and cross product of e3 and the normal vector.
448 rot2 = _np.zeros_like(rot1)
449 rot2[:, 0] = _np.dot(e3, normal_vector)
450 rot2[:, 1:] = _np.cross(e3, normal_vector)
452 # Add to the existing rotations.
453 rot_new = _add_rotations(rot2, rot1)
455 if flip_beams:
456 # To achieve the flip, the triads are rotated with the angle pi
457 # around the e2 axis.
458 rot_flip = _Rotation([0, 1, 0], _np.pi)
459 rot_new = _add_rotations(rot_new, rot_flip)
461 # For solid elements we need to adapt the connectivity to avoid negative Jacobians.
462 # For beam elements this is optional.
463 for element in self.elements:
464 if isinstance(element, _Beam):
465 if flip_beams:
466 element.flip()
467 else:
468 element.flip()
470 # Set the new positions and rotations.
471 for i, node in enumerate(self.nodes):
472 node.coordinates = pos_new[i, :]
473 if isinstance(node, _NodeCosserat):
474 node.rotation.q = rot_new[i, :]
476 def wrap_around_cylinder(
477 self, radius: float | None = None, advanced_warning: bool = True
478 ) -> None:
479 """Wrap the geometry around a cylinder. The y-z plane gets morphed into
480 the z-axis of symmetry. If all nodes are on the same y-z plane, the
481 radius of the created cylinder is the x coordinate of that plane. If
482 the nodes are not on the same y-z plane, the radius has to be given
483 explicitly.
485 Args:
486 radius: If this value is given AND not all nodes are on the same y-z
487 plane, then use this radius for the calculation of phi for all
488 nodes. This might still lead to distorted elements!
489 advanced_warning: If each element should be checked if it is either parallel
490 to the y-z or x-z plane. This is computationally expensive, but in most
491 cases (up to 100,000 elements) this check can be left activated.
492 """
494 pos = _get_nodal_coordinates(self.nodes)
495 quaternions = _np.zeros([len(self.nodes), 4])
497 # The x coordinate is the radius, the y coordinate the arc length.
498 points_x = pos[:, 0].copy()
500 # Check if all points are on the same y-z plane.
501 if _np.abs(_np.min(points_x) - _np.max(points_x)) > _bme.eps_pos:
502 # The points are not all on the y-z plane, get the reference
503 # radius.
504 if radius is not None:
505 if advanced_warning:
506 # Here we check, if each element lays on a plane parallel
507 # to the y-z plane, or parallel to the x-z plane.
508 #
509 # To be exactly sure, we could check the rotations here,
510 # i.e. if they are also in plane.
511 element_warning = []
512 for i_element, element in enumerate(self.elements):
513 element_coordinates = _np.zeros([len(element.nodes), 3])
514 for i_node, node in enumerate(element.nodes):
515 element_coordinates[i_node, :] = node.coordinates
516 is_yz = (
517 _np.max(
518 _np.abs(
519 element_coordinates[:, 0]
520 - element_coordinates[0, 0]
521 )
522 )
523 < _bme.eps_pos
524 )
525 is_xz = (
526 _np.max(
527 _np.abs(
528 element_coordinates[:, 1]
529 - element_coordinates[0, 1]
530 )
531 )
532 < _bme.eps_pos
533 )
534 if not (is_yz or is_xz):
535 element_warning.append(i_element)
536 if len(element_warning) != 0:
537 _warnings.warn(
538 "There are elements which are not "
539 "parallel to the y-z or x-y plane. This will lead "
540 "to distorted elements!"
541 )
542 else:
543 _warnings.warn(
544 "The nodes are not on the same y-z plane. "
545 "This may lead to distorted elements!"
546 )
547 else:
548 raise ValueError(
549 "The nodes that should be wrapped around a "
550 "cylinder are not on the same y-z plane. This will give "
551 "unexpected results. Give a reference radius!"
552 )
553 radius_phi = radius
554 radius_points = points_x
555 elif radius is None or _np.abs(points_x[0] - radius) < _bme.eps_pos:
556 radius_points = radius_phi = points_x[0]
557 else:
558 raise ValueError(
559 (
560 "The points are all on the same y-z plane with "
561 "the x-coordinate {} but the given radius {} is different. "
562 "This does not make sense."
563 ).format(points_x[0], radius)
564 )
566 # Get the angle for all nodes.
567 phi = pos[:, 1] / radius_phi
569 # The rotation is about the z-axis.
570 quaternions[:, 0] = _np.cos(0.5 * phi)
571 quaternions[:, 3] = _np.sin(0.5 * phi)
573 # Set the new positions in the global array.
574 pos[:, 0] = radius_points * _np.cos(phi)
575 pos[:, 1] = radius_points * _np.sin(phi)
577 # Rotate the mesh
578 self.rotate(quaternions, only_rotate_triads=True)
580 # Set the new position for the nodes.
581 for i, node in enumerate(self.nodes):
582 node.coordinates = pos[i, :]
584 def couple_nodes(
585 self,
586 *,
587 nodes=None,
588 reuse_matching_nodes=False,
589 coupling_type=_bme.bc.point_coupling,
590 coupling_dof_type=_bme.coupling_dof.fix,
591 ):
592 """Search through nodes and connect all nodes with the same
593 coordinates.
595 Args:
596 ----
597 nodes: [Node]
598 List of nodes to couple. If None is given, all nodes of the mesh
599 are coupled (except middle nodes).
600 reuse_matching_nodes: bool
601 If two nodes have the same position and rotation, the nodes are
602 reduced to one node in the mesh. Be aware, that this might lead to
603 issues if not all DOFs of the nodes should be coupled.
604 coupling_type: bme.bc
605 Type of point coupling.
606 coupling_dof_type: str, bme.coupling_dof
607 str: The string that will be used in the input file.
608 bme.coupling_dof.fix: Fix all positional and rotational DOFs of the
609 nodes together.
610 bme.coupling_dof.joint: Fix all positional DOFs of the nodes
611 together.
612 """
614 # Check that a coupling BC is given.
615 if coupling_type not in (
616 _bme.bc.point_coupling,
617 _bme.bc.point_coupling_penalty,
618 ):
619 raise ValueError(
620 "Only coupling conditions can be applied in 'couple_nodes'!"
621 )
623 # Get the nodes that should be checked for coupling. Middle nodes are
624 # not checked, as coupling can only be applied to the boundary nodes.
625 if nodes is None:
626 node_list = self.nodes
627 else:
628 node_list = nodes
629 node_list = _filter_nodes(node_list, middle_nodes=False)
630 partner_nodes = _find_close_nodes(node_list)
631 if len(partner_nodes) == 0:
632 # If no partner nodes were found, end this function.
633 return
635 if reuse_matching_nodes:
636 # Check if there are nodes with the same rotation. If there are the
637 # nodes are reused, and no coupling is inserted.
639 # Set the links to all nodes in the mesh. In this case we have to use
640 # "all_nodes" since we also have to replace nodes that are in existing
641 # GeometrySetNodes.
642 self.unlink_nodes()
643 self.get_unique_geometry_sets(link_to_nodes="explicitly_contained_nodes")
644 self.set_node_links()
646 # Go through partner nodes.
647 for node_list in partner_nodes:
648 # Get array with rotation vectors.
649 rotation_vectors = _np.zeros([len(node_list), 3])
650 for i, node in enumerate(node_list):
651 if isinstance(node, _NodeCosserat):
652 rotation_vectors[i, :] = node.rotation.get_rotation_vector()
653 else:
654 # For the case of nodes that belong to solid elements,
655 # we define the following default value:
656 rotation_vectors[i, :] = [4 * _np.pi, 0, 0]
658 # Use find close points function to find nodes with the
659 # same rotation.
660 partners, n_partners = _find_close_points(
661 rotation_vectors, tol=_bme.eps_quaternion
662 )
664 # Check if nodes with the same rotations were found.
665 if n_partners == 0:
666 self.add(
667 _coupling_factory(node_list, coupling_type, coupling_dof_type)
668 )
669 else:
670 # There are nodes that need to be combined.
671 combining_nodes = []
672 coupling_nodes = []
673 found_partner_id = [None for _i in range(n_partners)]
675 # Add the nodes that need to be combined and add the nodes
676 # that will be coupled.
677 for i, partner in enumerate(partners):
678 if partner == -1:
679 # This node does not have a partner with the same
680 # rotation.
681 coupling_nodes.append(node_list[i])
683 elif found_partner_id[partner] is not None:
684 # This node has already a processed partner, add
685 # this one to the combining nodes.
686 combining_nodes[found_partner_id[partner]].append(
687 node_list[i]
688 )
690 else:
691 # This is the first node of a partner set that was
692 # found. This one will remain, the other ones will
693 # be replaced with this one.
694 new_index = len(combining_nodes)
695 found_partner_id[partner] = new_index
696 combining_nodes.append([node_list[i]])
697 coupling_nodes.append(node_list[i])
699 # Add the coupling nodes.
700 if len(coupling_nodes) > 1:
701 self.add(
702 _coupling_factory(
703 coupling_nodes, coupling_type, coupling_dof_type
704 )
705 )
707 # Replace the identical nodes.
708 for combine_list in combining_nodes:
709 master_node = combine_list[0]
710 for node in combine_list[1:]:
711 node.replace_with(master_node)
713 else:
714 # Connect close nodes with a coupling.
715 for node_list in partner_nodes:
716 self.add(_coupling_factory(node_list, coupling_type, coupling_dof_type))
718 def unlink_nodes(self):
719 """Delete the linked arrays and global indices in all nodes."""
720 for node in self.nodes:
721 node.unlink()
723 def get_nodes_by_function(self, *args, **kwargs):
724 """Return all nodes for which the function evaluates to true."""
725 return _get_nodes_by_function(self.nodes, *args, **kwargs)
727 def check_overlapping_elements(self, raise_error=True):
728 """Check if there are overlapping elements in the mesh.
730 This is done by checking if all middle nodes of beam elements
731 have unique coordinates in the mesh.
732 """
734 # Number of middle nodes.
735 middle_nodes = [node for node in self.nodes if node.is_middle_node]
737 # Only check if there are middle nodes.
738 if len(middle_nodes) == 0:
739 return
741 # Get array with middle nodes.
742 coordinates = _np.zeros([len(middle_nodes), 3])
743 for i, node in enumerate(middle_nodes):
744 coordinates[i, :] = node.coordinates
746 # Check if there are double entries in the coordinates.
747 has_partner, partner = _find_close_points(coordinates)
748 partner_indices = _point_partners_to_partner_indices(has_partner, partner)
749 if partner > 0:
750 if raise_error:
751 raise ValueError(
752 "There are multiple middle nodes with the "
753 "same coordinates. Per default this raises an error! "
754 "This check can be turned of with "
755 "bme.check_overlapping_elements=False"
756 )
757 else:
758 _warnings.warn(
759 "There are multiple middle nodes with the same coordinates!"
760 )
762 # Add the partner index to the middle nodes.
763 for i_partner, partners in enumerate(partner_indices):
764 for i_node in partners:
765 middle_nodes[i_node].element_partner_index = i_partner
767 def get_vtk_representation(
768 self, *, overlapping_elements=True, coupling_sets=False, **kwargs
769 ):
770 """Return a vtk representation of the beams and solid in this mesh.
772 Args
773 ----
774 overlapping_elements: bool
775 I elements should be checked for overlapping. If they overlap, the
776 output will mark them.
777 coupling_sets: bool
778 If coupling sets should also be displayed.
779 """
781 # Object to store VKT data (and write it to file)
782 vtk_writer_beam = _VTKWriter()
783 vtk_writer_solid = _VTKWriter()
785 # Get the set numbers of the mesh
786 mesh_sets = self.get_unique_geometry_sets(
787 coupling_sets=coupling_sets, link_to_nodes="all_nodes"
788 )
790 # Set the global value for digits in the VTK output.
791 # Get highest number of node_sets.
792 max_sets = max(len(geometry_list) for geometry_list in mesh_sets.values())
794 # Set the bme value.
795 digits = len(str(max_sets))
796 _bme.vtk_node_set_format = "{:0" + str(digits) + "}"
798 if overlapping_elements:
799 # Check for overlapping elements.
800 self.check_overlapping_elements(raise_error=False)
802 # Get representation of elements.
803 for element in self.elements:
804 element.get_vtk(vtk_writer_beam, vtk_writer_solid, **kwargs)
806 # Finish and return the writers
807 vtk_writer_beam.complete_data()
808 vtk_writer_solid.complete_data()
809 return vtk_writer_beam, vtk_writer_solid
811 def write_vtk(
812 self, output_name="beamme", output_directory="", binary=True, **kwargs
813 ):
814 """Write the contents of this mesh to VTK files.
816 Args
817 ----
818 output_name: str
819 Base name of the output file. There will be a {}_beam.vtu and
820 {}_solid.vtu file.
821 output_directory: path
822 Directory where the output files will be written.
823 binary: bool
824 If the data should be written encoded in binary or in human readable text
826 **kwargs
827 For all of them look into:
828 Mesh().get_vtk_representation
829 Beam().get_vtk
830 VolumeElement().get_vtk
831 ----
832 beam_centerline_visualization_segments: int
833 Number of segments to be used for visualization of beam centerline between successive
834 nodes. Default is 1, which means a straight line is drawn between the beam nodes. For
835 Values greater than 1, a Hermite interpolation of the centerline is assumed for
836 visualization purposes.
837 """
839 vtk_writer_beam, vtk_writer_solid = self.get_vtk_representation(**kwargs)
841 # Write to file, only if there is at least one point in the writer.
842 if vtk_writer_beam.points.GetNumberOfPoints() > 0:
843 filepath = _os.path.join(output_directory, output_name + "_beam.vtu")
844 vtk_writer_beam.write_vtk(filepath, binary=binary)
845 if vtk_writer_solid.points.GetNumberOfPoints() > 0:
846 filepath = _os.path.join(output_directory, output_name + "_solid.vtu")
847 vtk_writer_solid.write_vtk(filepath, binary=binary)
849 def display_pyvista(
850 self,
851 *,
852 beam_nodes=True,
853 beam_tube=True,
854 beam_cross_section_directors=True,
855 beam_radius_for_display=None,
856 resolution=20,
857 parallel_projection=False,
858 **kwargs,
859 ):
860 """Display the mesh in pyvista.
862 If this is called in a GitHub testing run, nothing will be shown, instead
863 the _pv.plotter object will be returned.
865 Args
866 ----
867 beam_nodes: bool
868 If the beam nodes should be displayed. The start and end nodes of each
869 beam will be shown in green, possible middle nodes inside the element
870 are shown in cyan.
871 beam_tube: bool
872 If the beam should be rendered as a tube
873 beam_cross_section_directors: bool
874 If the cross section directors should be displayed (at each node)
875 beam_radius_for_display: float
876 If not all beams have an explicitly given radius (in the material
877 definition) this value will be used to approximate the beams radius
878 for visualization
879 resolution: int
880 Indicates how many triangulations will be performed to visualize arrows,
881 tubes and spheres.
882 parallel_projection: bool
883 Flag to change camera view to parallel projection.
885 **kwargs
886 For all of them look into:
887 Mesh().get_vtk_representation
888 Beam().get_vtk
889 VolumeElement().get_vtk
890 ----
891 beam_centerline_visualization_segments: int
892 Number of segments to be used for visualization of beam centerline between successive
893 nodes. Default is 1, which means a straight line is drawn between the beam nodes. For
894 Values greater than 1, a Hermite interpolation of the centerline is assumed for
895 visualization purposes.
896 """
898 plotter = _pv.Plotter()
899 plotter.renderer.add_axes()
901 if parallel_projection:
902 plotter.enable_parallel_projection()
904 vtk_writer_beam, vtk_writer_solid = self.get_vtk_representation(**kwargs)
906 if vtk_writer_beam.points.GetNumberOfPoints() > 0:
907 beam_grid = _pv.UnstructuredGrid(vtk_writer_beam.grid)
909 # Check if all beams have a given cross-section radius, if not set the given input
910 # value
911 all_beams_have_cross_section_radius = (
912 min(beam_grid.cell_data["cross_section_radius"]) > 0
913 )
914 if not all_beams_have_cross_section_radius:
915 if beam_radius_for_display is None:
916 raise ValueError(
917 "Not all beams have a radius, you need to set "
918 "beam_radius_for_display to allow a display of the beams."
919 )
920 beam_grid.cell_data["cross_section_radius"] = beam_radius_for_display
922 # Grid with beam polyline
923 beam_grid = beam_grid.cell_data_to_point_data()
925 # Poly data for nodes
926 finite_element_nodes = beam_grid.cast_to_poly_points().threshold(
927 scalars="node_value", value=(0.4, 1.1)
928 )
930 # Plot the nodes
931 node_radius_scaling_factor = 1.5
932 if beam_nodes:
933 sphere = _pv.Sphere(
934 radius=1.0,
935 theta_resolution=resolution,
936 phi_resolution=resolution,
937 )
938 nodes_glyph = finite_element_nodes.glyph(
939 geom=sphere,
940 scale="cross_section_radius",
941 factor=node_radius_scaling_factor,
942 orient=False,
943 )
944 plotter.add_mesh(
945 nodes_glyph.threshold(scalars="node_value", value=(0.9, 1.1)),
946 color="green",
947 )
948 middle_nodes = nodes_glyph.threshold(
949 scalars="node_value", value=(0.4, 0.6)
950 )
951 if len(middle_nodes.points) > 0:
952 plotter.add_mesh(middle_nodes, color="cyan")
954 # Plot the beams
955 beam_color = [0.5, 0.5, 0.5]
956 if beam_tube:
957 surface = beam_grid.extract_surface()
958 if all_beams_have_cross_section_radius:
959 tube = surface.tube(
960 scalars="cross_section_radius",
961 absolute=True,
962 n_sides=resolution,
963 )
964 else:
965 tube = surface.tube(
966 radius=beam_radius_for_display, n_sides=resolution
967 )
968 plotter.add_mesh(tube, color=beam_color)
969 else:
970 plotter.add_mesh(beam_grid, color=beam_color, line_width=4)
972 # Plot the directors of the beam cross-section
973 director_radius_scaling_factor = 3.5
974 if beam_cross_section_directors:
975 arrow = _pv.Arrow(
976 tip_resolution=resolution, shaft_resolution=resolution
977 )
978 directors = [
979 finite_element_nodes.glyph(
980 geom=arrow,
981 orient=f"base_vector_{i + 1}",
982 scale="cross_section_radius",
983 factor=director_radius_scaling_factor,
984 )
985 for i in range(3)
986 ]
987 colors = ["white", "blue", "red"]
988 for i, arrow in enumerate(directors):
989 plotter.add_mesh(arrow, color=colors[i])
991 if vtk_writer_solid.points.GetNumberOfPoints() > 0:
992 solid_grid = _pv.UnstructuredGrid(vtk_writer_solid.grid).clean()
993 plotter.add_mesh(solid_grid, color="white", show_edges=True, opacity=0.5)
995 if not _is_testing():
996 plotter.show()
997 else:
998 return plotter
1000 def copy(self) -> "Mesh":
1001 """Return a deep copy of this mesh.
1003 The internal mesh data (nodes, elements, boundary conditions, and
1004 geometry sets) are deep-copied. Materials and functions are not
1005 deep-copied.
1007 **Important:** Some mesh creation functions return geometry set
1008 containers (e.g., node or element sets) that hold a reference to the
1009 nodes or elements of the mesh they were created with. When using
1010 ``mesh.copy()``, these externally returned sets remain linked to the
1011 original mesh and are therefore not transferred to the copied mesh.
1013 To copy both the mesh and the corresponding geometry sets correctly,
1014 deep-copy them together.
1016 Returns:
1017 A deep copy of the mesh.
1018 """
1019 return _copy.deepcopy(self)