99from typing import Optional
1010from typing import Set
1111from typing import Union
12+ from typing import Tuple
1213
1314from compas .datastructures import Graph
1415from compas .geometry import Box
1819from compas .geometry import Transformation
1920from compas .geometry import bounding_box
2021from compas .geometry import centroid_points
22+ from compas .geometry import centroid_points_weighted
2123from pint import UnitRegistry
2224
2325import compas_fea2
@@ -107,6 +109,8 @@ def __init__(self, description: Optional[str] = None, author: Optional[str] = No
107109 self ._groups : Set [_Group ] = set ()
108110 self ._problems : Set [Problem ] = set ()
109111
112+ self ._constants : dict = {"g" : None }
113+
110114 @property
111115 def __data__ (self ):
112116 return {
@@ -121,6 +125,7 @@ def __data__(self):
121125 "sections" : [section .__data__ for section in self .sections ],
122126 "problems" : [problem .__data__ for problem in self .problems ],
123127 "path" : str (self .path ) if self .path else None ,
128+ "constants" : self ._constants ,
124129 }
125130
126131 @classmethod
@@ -161,6 +166,7 @@ def __from_data__(cls, data):
161166 problem_classes = {cls .__name__ : cls for cls in Problem .__subclasses__ ()}
162167 model ._problems = {problem_classes [problem_data ["class" ]].__from_data__ (problem_data ) for problem_data in data .get ("problems" , [])}
163168 model ._path = Path (data .get ("path" )) if data .get ("path" ) else None
169+ model ._constants = data .get ("constants" )
164170 return model
165171
166172 @classmethod
@@ -217,6 +223,18 @@ def constraints(self) -> Set[_Constraint]:
217223 def connectors (self ) -> Set [Connector ]:
218224 return self ._connectors
219225
226+ @property
227+ def constants (self ) -> dict :
228+ return self ._constants
229+
230+ @property
231+ def g (self ) -> float :
232+ return self .constants ["g" ]
233+
234+ @g .setter
235+ def g (self , value ):
236+ self ._constants ["g" ] = value
237+
220238 @property
221239 def materials_dict (self ) -> dict [Union [_Part , "Model" ], list [_Material ]]:
222240 materials = {part : part .materials for part in filter (lambda p : not isinstance (p , RigidPart ), self .parts )}
@@ -301,11 +319,24 @@ def bounding_box(self) -> Optional[Box]:
301319 return Box .from_bounding_box (bb )
302320
303321 @property
304- def center (self ) -> Point :
322+ def bb_center (self ) -> Point :
305323 if self .bounding_box :
306- return centroid_points (self .bounding_box .points )
324+ return Point ( * centroid_points (self .bounding_box .points ) )
307325 else :
308- return centroid_points (self .points )
326+ raise AttributeError ("The model has no bounding box" )
327+
328+ @property
329+ def center (self ) -> Point :
330+ return Point (* centroid_points (self .points ))
331+
332+ @property
333+ def centroid (self ) -> Point :
334+ weights = []
335+ points = []
336+ for part in self .parts :
337+ points .append (part .centroid )
338+ weights .append (part .weight )
339+ return Point (* centroid_points_weighted (points = points , weights = weights ))
309340
310341 @property
311342 def bottom_plane (self ) -> Plane :
@@ -855,7 +886,12 @@ def find_node_by_name(self, name: str) -> Node:
855886
856887 @get_docstring (_Part )
857888 @part_method
858- def find_closest_nodes_to_node (self , node : Node , distance : float , number_of_nodes : int = 1 , plane : Optional [Plane ] = None ) -> NodesGroup :
889+ def find_closest_nodes_to_node (self , node : Node , number_of_nodes : int = 1 , plane : Optional [Plane ] = None ) -> NodesGroup :
890+ pass
891+
892+ @get_docstring (_Part )
893+ @part_method
894+ def find_closest_nodes_to_point (self , point : Point , number_of_nodes : int = 1 , plane : Optional [Plane ] = None ) -> NodesGroup :
859895 pass
860896
861897 @get_docstring (_Part )
@@ -1433,6 +1469,42 @@ def add_interfaces(self, interfaces):
14331469 """
14341470 return [self .add_interface (interface ) for interface in interfaces ]
14351471
1472+ def extract_interfaces (self , max_origin_distance = 0.01 , tol = 1e-6 ) -> list [Tuple [Plane , Plane ]]:
1473+ """Extract interfaces from the model.
1474+
1475+ Returns
1476+ -------
1477+ list[:class:`compas_fea2.model.Interface`]
1478+ List of interfaces extracted from the model.
1479+
1480+ """
1481+ from compas .geometry import distance_point_plane_signed
1482+ import itertools
1483+ from typing import List
1484+
1485+ # --- Nested helper function to check coplanarity ---
1486+ def _are_two_planes_coplanar (pln1 : Plane , pln2 : Plane , tol : float ) -> bool :
1487+ """Checks if two COMPAS planes are geometrically the same."""
1488+ normals_collinear = abs (abs (pln1 .normal .dot (pln2 .normal )) - 1.0 ) < tol
1489+ if not normals_collinear :
1490+ return False
1491+ point_on_plane = abs (distance_point_plane_signed (pln1 .point , pln2 )) < tol
1492+ return point_on_plane
1493+
1494+ list_of_plane_lists = [p .extract_clustered_planes () for p in self .parts ]
1495+ # 1. Flatten the list of lists into a single list of all planes.
1496+ all_planes_raw : List [Plane ] = list (itertools .chain .from_iterable (list_of_plane_lists ))
1497+
1498+ # 2. Get unique plane instances from the raw list.
1499+ coplanar_pairs : List [Tuple [Plane , Plane ]] = []
1500+ for p1 , p2 in itertools .combinations (all_planes_raw , 2 ):
1501+ if _are_two_planes_coplanar (p1 , p2 , tol ):
1502+ # If planes are coplanar, then check the distance between their origin points
1503+ if max_origin_distance is None or p1 .point .distance_to_point (p2 .point ) < max_origin_distance :
1504+ coplanar_pairs .append ((p1 , p2 ))
1505+
1506+ return coplanar_pairs
1507+
14361508 # ==============================================================================
14371509 # Summary
14381510 # ==============================================================================
0 commit comments