diff --git a/flow360/component/simulation/entity_info.py b/flow360/component/simulation/entity_info.py index ba689be26..0373a7d9f 100644 --- a/flow360/component/simulation/entity_info.py +++ b/flow360/component/simulation/entity_info.py @@ -367,15 +367,16 @@ def get_registry(self, internal_registry, **_) -> EntityRegistry: "face", face_group_tag, registry=internal_registry ) - if self.edge_group_tag is None: - edge_group_tag = self._get_default_grouping_tag("edge") - log.info(f"Using `{edge_group_tag}` as default grouping for edges.") - else: - edge_group_tag = self.edge_group_tag + if len(self.edge_ids) > 0: + if self.edge_group_tag is None: + edge_group_tag = self._get_default_grouping_tag("edge") + log.info(f"Using `{edge_group_tag}` as default grouping for edges.") + else: + edge_group_tag = self.edge_group_tag - internal_registry = self._group_entity_by_tag( - "edge", edge_group_tag, registry=internal_registry - ) + internal_registry = self._group_entity_by_tag( + "edge", edge_group_tag, registry=internal_registry + ) if self.body_attribute_names: # Post-25.5 geometry asset. For Pre 25.5 we just skip body grouping. diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index c2dc0fe96..c7a8ee899 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -30,6 +30,9 @@ ) from flow360.component.simulation.validation.validation_utils import EntityUsageMap +from flow360.component.simulation.entity_info import Surface +from flow360.component.simulation.framework.entity_base import EntityList + RefinementTypes = Annotated[ Union[ SurfaceEdgeRefinement, @@ -178,6 +181,150 @@ def invalid_geometry_accuracy(cls, value): return value +class MeshQuality(Flow360BaseModel): + """ + Mesh quality settings for the surface mesh generation. + """ + + max_non_ortho: float = pd.Field( + 85, + description="Maximum non-orthogonality allowed in the mesh.", + ) + + max_boundary_skewness: float = pd.Field( + 20, + description="Maximum boundary skewness allowed in the mesh.", + ) + + max_internal_skewness: float = pd.Field( + 50, + description="Maximum internal skewness allowed in the mesh.", + ) + + max_concave: float = pd.Field( + 50, + description="Maximum concavity allowed in the mesh.", + ) + + min_vol: float = pd.Field( + -1e+30, + description="Minimum volume allowed in the mesh.", + ) + + min_tet_quality: float = pd.Field( + -1e+30, + description="Minimum tetrahedral quality allowed in the mesh.", + ) + + min_area: float = pd.Field( + -1, + description="Minimum area allowed in the mesh.", + ) + + min_twist: float = pd.Field( + -2, + description="Minimum twist allowed in the mesh.", + ) + + min_determinant: float = pd.Field( + 0, + description="Minimum determinant allowed in the mesh.", + ) + + min_vol_ratio: float = pd.Field( + 0, + description="Minimum volume ratio allowed in the mesh.", + ) + + min_face_weight: float = pd.Field( + 0, + description="Minimum face weight allowed in the mesh.", + ) + + min_triangle_twist: float = pd.Field( + -1, + description="Minimum triangle twist allowed in the mesh.", + ) + + n_smooth_scale: int = pd.Field( + 4, + description="Number of smoothing scale iterations.", + ) + + error_reduction: float = pd.Field( + 0.75, + description="Error reduction factor for mesh smoothing.", + ) + + min_vol_collapse_ratio: float = pd.Field( + 0, + description="Minimum volume collapse ratio allowed in the mesh.", + ) + +class SnapControls(Flow360BaseModel): + """ + Snap controls for the surface mesh generation. + """ + + tolerance: Optional[pd.PositiveFloat] = pd.Field( + 2, + description="Tolerance for the surface mesh generation.", + ) + + n_feature_snap_iter: int = pd.Field( + 15, + description="Number of feature snap iterations.", + ) + multi_region_feature_snap: bool = pd.Field( + True, + description="Whether to use multi-region feature snap.", + ) + +class GeometrySettings(Flow360BaseModel): + """ + Geometry settings for the surface mesh generation. + """ + + entities: EntityList[Surface] = pd.Field( + default=[], + description="List of entities to be wrapped.", + ) + + spec: dict = pd.Field( + default={}, + description="Specification for the geometry settings. Example: " + '{"spacing": {"min": 5, "max": 50}, ' + '"edges": {"edgeSpacing": 2, "includedAngle": 140, "minElem": 3, "minLen": 10}, ' + '"gap": 0.001, "regions": [], "gapSpacingReduction": null, "includedAngle": 140}', + ) + + + +class WrappingSettings(Flow360BaseModel): + geometry: Optional[List[GeometrySettings]] = pd.Field( + default=None, + description="List of settings geometry entities to be wrapped.", + ) + + mesh_quality: MeshQuality = pd.Field( + MeshQuality(), + description="Mesh quality settings for the surface mesh generation.", + ) + + snap_controls: SnapControls = pd.Field( + SnapControls(), + description="Snap controls for the surface mesh generation.", + ) + location_in_mesh: Optional[LengthType.Point] = pd.Field( + None, + description="Point in the mesh that will be used to determine side for wrapping.", + ) + cad_is_fluid: Optional[bool] = pd.Field( + False, + description="Whether the CAD represents a fluid or solid.", + ) + + class MeshingParams(Flow360BaseModel): """ Meshing parameters for volume and/or surface mesher. This contains all the meshing related settings. @@ -243,6 +390,10 @@ class MeshingParams(Flow360BaseModel): default=None, description="Creation of new volume zones." ) + wrapping_settings: Optional[WrappingSettings] = pd.Field( + default=None, description="Wrapping settings for the surface mesh generation." + ) + @pd.field_validator("volume_zones", mode="after") @classmethod def _check_volume_zones_has_farfied(cls, v): diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index 626ab8d55..bb6f9e9fd 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -134,4 +134,29 @@ def get_surface_meshing_json(input_params: SimulationParams, mesh_units): for face_id in surface.private_attribute_sub_components: translated["boundaries"][face_id] = {"boundaryName": surface.name} + + + ##:: >> Step 7: Get wrapping settings (for snappy) + if input_params.meshing.wrapping_settings is not None: + translated["meshingMethod"] = "wrapping" + translated["geometry"] = { + "bodies": [] + } + for geometry_settings in input_params.meshing.wrapping_settings.geometry: + for entity in geometry_settings.entities.stored_entities: + translated["geometry"]["bodies"].append({ + "bodyName": entity.name, + **geometry_settings.spec + }) + translated["mesherSettings"] = { + "snappyHexMesh": { + "snapControls": input_params.meshing.wrapping_settings.snap_controls.model_dump(by_alias=True), + }, + "meshQuality": input_params.meshing.wrapping_settings.mesh_quality.model_dump(by_alias=True), + } + location_in_mesh = input_params.meshing.wrapping_settings.location_in_mesh + if location_in_mesh is not None: + translated["locationInMesh"] = location_in_mesh.value.tolist() + translated["cadIsFluid"] = input_params.meshing.wrapping_settings.cad_is_fluid + return translated diff --git a/tests/simulation/translator/ref/surface_meshing/surface_mesh_snappy_config.json b/tests/simulation/translator/ref/surface_meshing/surface_mesh_snappy_config.json new file mode 100644 index 000000000..ee8e7eb1b --- /dev/null +++ b/tests/simulation/translator/ref/surface_meshing/surface_mesh_snappy_config.json @@ -0,0 +1,275 @@ +{ + "meshingMethod": "wrapping", + "curvatureResolutionAngle": 12.00000000000000, + "growthRate": 1.2, + "faces": { + "body01_face001": { + "maxEdgeLength": 1.0 + }, + "body01_face002": { + "maxEdgeLength": 1.0 + }, + "body01_face003": { + "maxEdgeLength": 1.0 + }, + "body01_face004": { + "maxEdgeLength": 1.0 + }, + "body01_face005": { + "maxEdgeLength": 1.0 + }, + "body01_face006": { + "maxEdgeLength": 1.0 + }, + "body01_face007": { + "maxEdgeLength": 1.0 + }, + "body01_face008": { + "maxEdgeLength": 1.0 + }, + "body01_face009": { + "maxEdgeLength": 1.0 + }, + "body01_face010": { + "maxEdgeLength": 1.0 + } + }, + "boundaries": { + "body01_face001": { + "boundaryName": "Wing" + }, + "body01_face002": { + "boundaryName": "Wing" + }, + "body01_face003": { + "boundaryName": "Wing" + }, + "body01_face004": { + "boundaryName": "Wing" + }, + "body01_face005": { + "boundaryName": "Fuselage" + }, + "body01_face006": { + "boundaryName": "Fuselage" + }, + "body01_face007": { + "boundaryName": "Fuselage" + }, + "body01_face008": { + "boundaryName": "Stab" + }, + "body01_face009": { + "boundaryName": "Stab" + }, + "body01_face010": { + "boundaryName": "Fin" + } + }, + "geometry": { + "bodies": [ + { + "bodyName": "body01_face001", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face002", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face003", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face004", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face005", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face006", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face007", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face008", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face009", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + }, + { + "bodyName": "body01_face010", + "spacing": { + "min": 5, + "max": 50 + }, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": null, + "includedAngle": 140 + } + ] + }, + "locationInMesh": [ + 3000, + 0, + 800 + ], + "cadIsFluid": true, + "mesherSettings": { + "snappyHexMesh": { + "snapControls": { + "tolerance": 2, + "nFeatureSnapIter": 15, + "multiRegionFeatureSnap": true + } + }, + "meshQuality": { + "maxNonOrtho": 85, + "maxBoundarySkewness": 20, + "maxInternalSkewness": 50, + "maxConcave": 50, + "minVol": -1e+30, + "minTetQuality": -1e+30, + "minArea": -1, + "minTwist": -2, + "minDeterminant": 0, + "minVolRatio": 0, + "minFaceWeight": 0, + "minTriangleTwist": -1, + "nSmoothScale": 4, + "errorReduction": 0.75, + "minVolCollapseRatio": 0 + } + } +} \ No newline at end of file diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index 799311428..27f0b9c40 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -18,6 +18,10 @@ from flow360.component.simulation.meshing_param.params import ( MeshingDefaults, MeshingParams, + WrappingSettings, + GeometrySettings, + MeshQuality, + SnapControls, ) from flow360.component.simulation.primitives import Edge, Surface from flow360.component.simulation.simulation_params import SimulationParams @@ -425,6 +429,71 @@ def rotor_surface_mesh(): return param +@pytest.fixture() +def airplane_surface_mesh_with_wrapping(): + my_geometry = TempGeometry("geometry.egads") + from numpy import pi + + with SI_unit_system: + param = SimulationParams( + private_attribute_asset_cache=AssetCache( + project_entity_info=my_geometry._get_entity_info() + ), + meshing=MeshingParams( + defaults=MeshingDefaults( + surface_max_edge_length=100 * u.cm, + ), + wrapping_settings=WrappingSettings( + geometry=[ + GeometrySettings( + entities=[ + my_geometry["*"], + ], + spec={ + "spacing": {"min": 5, "max": 50}, + "edges": { + "edgeSpacing": 2, + "includedAngle": 140, + "minElem": 3, + "minLen": 10 + }, + "gap": 0.001, + "regions": [], + "gapSpacingReduction": None, + "includedAngle": 140 + } + ) + ], + mesh_quality=MeshQuality( + max_non_ortho=85, + max_boundary_skewness=20, + max_internal_skewness=50, + max_concave=50, + min_vol=-1e+30, + min_tet_quality=-1e+30, + min_area=-1, + min_twist=-2, + min_determinant=0, + min_vol_ratio=0, + min_face_weight=0, + min_triangle_twist=-1, + n_smooth_scale=4, + error_reduction=0.75, + min_vol_collapse_ratio=0 + ), + snap_controls=SnapControls( + tolerance=2, + n_feature_snap_iter=15, + multi_region_feature_snap=True + ), + location_in_mesh=(3000, 0, 800) * u.m, + cad_is_fluid=True + ) + ), + ) + return param + + def _translate_and_compare(param, mesh_unit, ref_json_file: str): translated = get_surface_meshing_json(param, mesh_unit=mesh_unit) with open( @@ -433,7 +502,24 @@ def _translate_and_compare(param, mesh_unit, ref_json_file: str): ) ) as fh: ref_dict = json.load(fh) - assert compare_values(ref_dict, translated) + + print("\n" + "="*80) + print("REFERENCE JSON:") + print("="*80) + print(json.dumps(ref_dict, indent=2)) + + print("\n" + "="*80) + print("GENERATED JSON:") + print("="*80) + print(json.dumps(translated, indent=2)) + + print("\n" + "="*80) + print("COMPARISON RESULT:") + print("="*80) + result = compare_values(ref_dict, translated) + print(f"Comparison result: {result}") + + assert result def test_om6wing_tutorial( @@ -475,3 +561,11 @@ def test_rotor_surface_mesh(get_rotor_geometry, rotor_surface_mesh): get_rotor_geometry.mesh_unit, "rotor.json", ) + + +def test_wrapping_settings(get_airplane_geometry, airplane_surface_mesh_with_wrapping): + _translate_and_compare( + airplane_surface_mesh_with_wrapping, + get_airplane_geometry.mesh_unit, + "surface_mesh_snappy_config.json", + )