diff --git a/flow360/component/simulation/meshing_param/meshing_specs.py b/flow360/component/simulation/meshing_param/meshing_specs.py index a8cd36ebd..aff89c47b 100644 --- a/flow360/component/simulation/meshing_param/meshing_specs.py +++ b/flow360/component/simulation/meshing_param/meshing_specs.py @@ -194,6 +194,14 @@ class MeshingDefaults(Flow360BaseModel): context=SURFACE_MESH, ) + target_surface_node_count: Optional[pd.PositiveInt] = ContextField( + None, + description="Target number of surface mesh nodes. When specified, the surface mesher " + "will rescale the meshing parameters to achieve approximately this number of nodes. " + "This option is only supported when using geometry AI and can not be overridden per face.", + context=SURFACE_MESH, + ) + curvature_resolution_angle: AngleType.Positive = ContextField( 12 * u.deg, description=( @@ -312,6 +320,7 @@ def invalid_geometry_accuracy(cls, value, param_info: ParamsValidationInfo): @contextual_field_validator( "surface_max_aspect_ratio", "surface_max_adaptation_iterations", + "target_surface_node_count", "resolve_face_boundaries", "preserve_thin_geometry", "sealing_size", diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index 955a5d47e..056b8657d 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -788,6 +788,7 @@ def _get_gai_setting_whitelist(input_params: SimulationParams) -> dict: "preserve_thin_geometry": None, "surface_max_aspect_ratio": None, "surface_max_adaptation_iterations": None, + "target_surface_node_count": None, "sealing_size": None, "remove_hidden_geometry": None, "min_passage_size": None, diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index 95f8b62f2..ba7a909ce 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -1734,3 +1734,115 @@ def test_gai_no_stationary_enclosed_entities(): for zone in volume_zones: if zone["type"] in ("RotationVolume", "RotationCylinder"): assert "stationary_enclosed_entities" not in zone + + +def test_gai_target_surface_node_count_set(): + """target_surface_node_count passes through the GAI whitelist when set.""" + param_dict = { + "private_attribute_asset_cache": { + "use_inhouse_mesher": True, + "use_geometry_AI": True, + "project_entity_info": {"type_name": "GeometryEntityInfo"}, + }, + } + + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + surface_max_edge_length=0.1, + geometry_accuracy=0.01, + target_surface_node_count=50000, + ), + volume_zones=[AutomatedFarfield()], + ), + private_attribute_asset_cache=AssetCache.model_validate( + param_dict["private_attribute_asset_cache"] + ), + ) + + translated = get_surface_meshing_json(params, 1 * u.m) + assert "meshing" in translated + assert "defaults" in translated["meshing"] + assert "target_surface_node_count" in translated["meshing"]["defaults"] + assert translated["meshing"]["defaults"]["target_surface_node_count"] == 50000 + + +def test_gai_target_surface_node_count_absent(): + """target_surface_node_count is absent from GAI JSON when not set.""" + param_dict = { + "private_attribute_asset_cache": { + "use_inhouse_mesher": True, + "use_geometry_AI": True, + "project_entity_info": {"type_name": "GeometryEntityInfo"}, + }, + } + + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + surface_max_edge_length=0.1, + geometry_accuracy=0.01, + ), + volume_zones=[AutomatedFarfield()], + ), + private_attribute_asset_cache=AssetCache.model_validate( + param_dict["private_attribute_asset_cache"] + ), + ) + + translated = get_surface_meshing_json(params, 1 * u.m) + assert "meshing" in translated + assert "defaults" in translated["meshing"] + assert "target_surface_node_count" not in translated["meshing"]["defaults"] + + +def test_legacy_target_surface_node_count_rejected(get_om6wing_geometry): + """target_surface_node_count is rejected for legacy (non-GAI) flows.""" + my_geometry = TempGeometry("om6wing.csm") + with SI_unit_system: + params = SimulationParams( + private_attribute_asset_cache=AssetCache( + project_entity_info=my_geometry._get_entity_info() + ), + meshing=MeshingParams( + defaults=MeshingDefaults( + surface_edge_growth_rate=1.2, + curvature_resolution_angle=12 * u.deg, + surface_max_edge_length=1 * u.m, + target_surface_node_count=5000, + edge_split_layers=0, + ), + ), + ) + + params, err, warnings = validate_params_with_context(params, "Geometry", "SurfaceMesh") + assert ( + err is not None + ), "Expected validation error for target_surface_node_count in non-GAI flow" + assert "target_surface_node_count" in str(err) + + +def test_legacy_target_surface_node_count_absent(get_om6wing_geometry): + """target_surface_node_count is absent from legacy translated JSON when not set.""" + my_geometry = TempGeometry("om6wing.csm") + with SI_unit_system: + params = SimulationParams( + private_attribute_asset_cache=AssetCache( + project_entity_info=my_geometry._get_entity_info() + ), + meshing=MeshingParams( + defaults=MeshingDefaults( + surface_edge_growth_rate=1.2, + curvature_resolution_angle=12 * u.deg, + surface_max_edge_length=1 * u.m, + edge_split_layers=0, + ), + ), + ) + + params, err, warnings = validate_params_with_context(params, "Geometry", "SurfaceMesh") + assert err is None, f"Validation error: {err}" + translated = get_surface_meshing_json(params, mesh_unit=get_om6wing_geometry.mesh_unit) + assert "target_surface_node_count" not in translated