diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 49de5c730..9b855916e 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -164,6 +164,25 @@ def _collect_all_custom_volumes(zones): return custom_volumes +def _collect_all_seedpoint_volumes(zones): + """Collect all SeedpointVolume instances from CustomZones.""" + seedpoint_volumes: list[SeedpointVolume] = [] + for zone in zones: + if isinstance(zone, CustomZones): + for volume in zone.entities.stored_entities: + if isinstance(volume, SeedpointVolume): + seedpoint_volumes.append(volume) + return seedpoint_volumes + + +def _validate_seedpoint_volume_usage(seedpoint_volumes, param_info: ParamsValidationInfo): + """Validate SeedpointVolume usage against mesher capabilities.""" + if seedpoint_volumes and not (param_info.use_snappy or param_info.is_beta_mesher): + raise ValueError( + "`SeedpointVolume` is supported only when using snappyHexMeshing or the beta mesher." + ) + + def _validate_custom_volume_rotation_association(custom_volumes, rotation_entity_names, param_info): """Validate that Cylinder/AxisymmetricBody/Sphere in CustomVolume.bounding_entities are associated with a RotationVolume or RotationSphere.""" @@ -369,6 +388,16 @@ def _check_volume_zones_have_unique_names(cls, v): return v + @contextual_model_validator(mode="after") + def _check_seedpoint_volume_usage(self, param_info: ParamsValidationInfo): + """Validate SeedpointVolume usage in legacy meshing schema.""" + if self.volume_zones is None: + return self + _validate_seedpoint_volume_usage( + _collect_all_seedpoint_volumes(self.volume_zones), param_info + ) + return self + @contextual_model_validator(mode="after") def _check_no_reused_volume_entities(self) -> Self: """ @@ -725,10 +754,12 @@ def _check_snappy_zones(self) -> Self: "snappyHexMeshing requires at least one `SeedpointVolume` when not using `AutomatedFarfield`." ) - else: - if total_seedpoint_volumes: - raise ValueError("`SeedpointVolume` is applicable only with snappyHexMeshing.") + return self + @contextual_model_validator(mode="after") + def _check_seedpoint_volume_usage(self, param_info: ParamsValidationInfo): + """Validate SeedpointVolume usage in modular meshing schema.""" + _validate_seedpoint_volume_usage(_collect_all_seedpoint_volumes(self.zones), param_info) return self @contextual_model_validator(mode="after") diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index ab5dbacf0..4a3cb2069 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1041,7 +1041,7 @@ def __getitem__(self, key: str): @final class SeedpointVolume(_VolumeEntityBase): """ - Represents a separate zone in the mesh, defined by a point inside it. + Represents a separate zone in the mesh, defined by one or more interior seed points. To be used only with snappyHexMesh. """ @@ -1050,8 +1050,10 @@ class SeedpointVolume(_VolumeEntityBase): "SeedpointVolume", frozen=True ) type: Literal["SeedpointVolume"] = pd.Field("SeedpointVolume", frozen=True) - point_in_mesh: LengthType.Point = pd.Field( - description="Seedpoint for a main fluid zone in snappyHexMesh." + point_in_mesh: List[LengthType.Point] = pd.Field( + min_length=1, + description="Seed point(s) for this custom volume zone. Accepts either one [x, y, z] point or a " + + "list of points [[x, y, z], ...]. Use with Snappy requires exactly one point per zone.", ) axes: Optional[OrthogonalAxes] = pd.Field( None, description="Principal axes definition when using with PorousMedium" @@ -1060,6 +1062,20 @@ class SeedpointVolume(_VolumeEntityBase): center: Optional[LengthType.Point] = pd.Field(None, description="") # Rotation support private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) + @pd.field_validator("point_in_mesh", mode="before") + @classmethod + def _normalize_point_in_mesh(cls, value): + """ + Normalize point_in_mesh input to list-of-points. + """ + try: + # Reuse LengthType.Point parsing/validation for single-point inputs. + single_point = pd.TypeAdapter(LengthType.Point).validate_python(value) + return [single_point] + except Exception: # pylint: disable=broad-exception-caught + # If this is not a single point, defer to List[LengthType.Point] validation. + return value + def _per_entity_type_validation(self, param_info: ParamsValidationInfo): """Validate that SeedpointVolume is listed in meshing->volume_zones.""" if self.name not in param_info.to_be_generated_custom_volumes: diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index f9dc82258..837141889 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -572,10 +572,18 @@ def snappy_mesher_json(input_params: SimulationParams): # points in mesh if all_seedpoint_zones and translated["cadIsFluid"]: - translated["locationInMesh"] = { - zone.name: [point.value.item() for point in zone.point_in_mesh] - for zone in all_seedpoint_zones - } + location_in_mesh = {} + for zone in all_seedpoint_zones: + if len(zone.point_in_mesh) != 1: + raise Flow360TranslationError( + f"SeedpointVolume '{zone.name}' must provide exactly one point for " + + "snappyHexMeshing locationInMesh.", + zone.point_in_mesh, + ["meshing", "zones"], + ) + location_in_mesh[zone.name] = [point.value.item() for point in zone.point_in_mesh[0]] + + translated["locationInMesh"] = location_in_mesh return translated @@ -692,6 +700,27 @@ def _get_volume_zones(volume_zones_list: list[dict]): return volume_zones_translated +def _get_gai_seed_points(input_params: SimulationParams) -> list[list[float]]: + """Collect all SeedpointVolume points for GAI defaults.seed_points.""" + volume_zones = getattr(input_params.meshing, "volume_zones", None) + if volume_zones is None: + volume_zones = getattr(input_params.meshing, "zones", None) + if volume_zones is None: + return [] + + seed_points: list[list[float]] = [] + for zone in volume_zones: + if not isinstance(zone, CustomZones): + continue + for entity in zone.entities.stored_entities: + if not isinstance(entity, SeedpointVolume): + continue + seed_points.extend( + [[coord.value.item() for coord in point] for point in entity.point_in_mesh] + ) + return seed_points + + def _filter_mirror_status(data): """Process mirror_status to ensure idempotency while preserving mirroring relationships. @@ -803,6 +832,7 @@ def _get_gai_setting_whitelist(input_params: SimulationParams) -> dict: "remove_hidden_geometry": None, "min_passage_size": None, "planar_face_tolerance": None, + "seed_points": None, } # Conditionally add sliding_interface_tolerance only when rotation zones are present @@ -967,6 +997,10 @@ def filter_simulation_json(input_params: SimulationParams, mesh_units): json_data=json_data, input_params=input_params, mesh_unit=mesh_units ) + seed_points = _get_gai_seed_points(input_params) + if seed_points: + json_data.setdefault("meshing", {}).setdefault("defaults", {})["seed_points"] = seed_points + # Generate whitelist based on simulation context whitelist = _get_gai_setting_whitelist(input_params) diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index 66a893c7f..0364b424b 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -312,8 +312,9 @@ def _get_custom_volumes(volume_zones: list): custom_volumes.append( { "name": custom_volume.name, - "pointInMesh": [ - coord.value.item() for coord in custom_volume.point_in_mesh + "seedPoints": [ + [coord.value.item() for coord in seed_point] + for seed_point in custom_volume.point_in_mesh ], } ) @@ -338,26 +339,6 @@ def translate_mesh_slice_fields( return mesh_slice_fields -# def _get_seedpoint_zones(volume_zones: list): -# """ -# Get translated seedpoint volumes from volume zones. -# To be later filled with data from snappyHexMesh. -# """ -# seedpoint_zones = [] -# for zone in volume_zones: -# if isinstance(zone, SeedpointVolume): -# seedpoint_zones.append( -# { -# "name": zone.name, -# "pointInMesh": [coord.value.item() for coord in zone.point_in_mesh], -# } -# ) -# if seedpoint_zones: -# # Sort custom volumes by name -# seedpoint_zones.sort(key=lambda x: x["name"]) -# return seedpoint_zones - - def translate_mesh_slice_output( output_params: list, output_class: MeshSliceOutput, diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index 9dafe82bc..691660652 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -992,6 +992,63 @@ def test_require_mesh_zones(): ) +def test_seedpoint_volume_requires_snappy_or_beta_mesher(): + message = "`SeedpointVolume` is supported only when using snappyHexMeshing or the beta mesher." + + non_beta_context = ParamsValidationInfo({}, []) + non_beta_context.is_beta_mesher = False + non_beta_context.use_snappy = False + non_beta_context.to_be_generated_custom_volumes = {"fluid"} + + beta_context = ParamsValidationInfo({}, []) + beta_context.is_beta_mesher = True + beta_context.use_snappy = False + beta_context.to_be_generated_custom_volumes = {"fluid"} + + with ValidationContext(VOLUME_MESH, non_beta_context): + with SI_unit_system, pytest.raises(pd.ValidationError, match=re.escape(message)): + MeshingParams( + volume_zones=[ + UserDefinedFarfield(), + CustomZones( + name="custom_zones", + entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], + ), + ] + ) + + with ValidationContext(VOLUME_MESH, non_beta_context): + with SI_unit_system, pytest.raises(pd.ValidationError, match=re.escape(message)): + ModularMeshingWorkflow( + zones=[ + CustomZones( + name="custom_zones", + entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], + ) + ] + ) + + with ValidationContext(VOLUME_MESH, beta_context): + with SI_unit_system: + MeshingParams( + volume_zones=[ + UserDefinedFarfield(), + CustomZones( + name="custom_zones", + entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], + ), + ] + ) + ModularMeshingWorkflow( + zones=[ + CustomZones( + name="custom_zones", + entities=[SeedpointVolume(name="fluid", point_in_mesh=(0, 0, 0) * u.mm)], + ) + ] + ) + + def test_bad_refinements(): message = "Default maximum spacing (5.0 mm) is lower than refinement minimum spacing (6.0 mm) and maximum spacing is not provided for BodyRefinement." with pytest.raises( diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index 5ff822124..719859e3b 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -47,6 +47,7 @@ CustomZones, RotationVolume, UniformRefinement, + UserDefinedFarfield, WheelBelts, WindTunnelFarfield, ) @@ -72,6 +73,7 @@ SI_unit_system, imperial_unit_system, ) +from flow360.exceptions import Flow360TranslationError from tests.simulation.conftest import AssetBase @@ -1161,6 +1163,37 @@ def test_snappy_no_refinements(get_snappy_geometry, snappy_refinements_no_region ) +def test_snappy_seedpoint_zone_rejects_multiple_points(get_snappy_geometry): + test_geometry = TempGeometry("tester.stl", True) + with SI_unit_system: + params = SimulationParams( + private_attribute_asset_cache=AssetCache( + project_entity_info=test_geometry._get_entity_info(), project_length_unit=1 * u.mm + ), + meshing=ModularMeshingWorkflow( + surface_meshing=snappy.SurfaceMeshingParams( + defaults=snappy.SurfaceMeshingDefaults( + min_spacing=3 * u.mm, max_spacing=4 * u.mm, gap_resolution=1 * u.mm + ), + octree_spacing=OctreeSpacing(base_spacing=3 * u.mm), + ), + zones=[ + CustomZones( + entities=[ + SeedpointVolume( + name="fluid", point_in_mesh=[[0, 0, 0], [1, 0, 0]] * u.mm + ) + ] + ) + ], + ), + ) + + # Contract: snappy `locationInMesh` supports exactly one point per SeedpointVolume. + with pytest.raises(Flow360TranslationError, match="must provide exactly one point"): + get_surface_meshing_json(params, mesh_unit=get_snappy_geometry.mesh_unit) + + def test_gai_surface_mesher_refinements(): geometry = Geometry.from_local_storage( geometry_id="geo-e5c01a98-2180-449e-b255-d60162854a83", @@ -1248,6 +1281,49 @@ def test_gai_surface_mesher_refinements(): ) +def test_gai_seedpoint_zones_emit_seedpoints(): + """GAI filtered JSON should emit meshing.defaults.seed_points from SeedpointVolume.""" + 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=[ + UserDefinedFarfield(), + CustomZones( + entities=[ + SeedpointVolume(name="fluid", point_in_mesh=[0, 0, 0] * u.m), + SeedpointVolume( + name="radiator", + point_in_mesh=[[1, 2, 3], [4, 5, 6]] * u.m, + ), + ] + ), + ], + ), + private_attribute_asset_cache=AssetCache.model_validate( + param_dict["private_attribute_asset_cache"] + ), + ) + + translated = get_surface_meshing_json(params, 1 * u.m) + assert translated["meshing"]["defaults"]["seed_points"] == [ + [0, 0, 0], + [1, 2, 3], + [4, 5, 6], + ] + + def test_gai_translator_hashing_ignores_id(): """Test that hash calculation ignores private_attribute_id fields.""" diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index ec874cd83..5f805674e 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -650,7 +650,7 @@ def test_user_defined_farfield(get_test_param, get_surface_mesh): "gapTreatmentStrength": 0.0, }, "faces": {}, - "zones": [{"name": "farfield", "pointInMesh": [0, 0, 0]}], + "zones": [{"name": "farfield", "seedPoints": [[0, 0, 0]]}], } assert sorted(translated.items()) == sorted(reference_standard.items()) assert sorted(translated_modular.items()) == sorted(reference_snappy_modular.items()) @@ -661,6 +661,7 @@ def test_seedpoint_zones(get_test_param_w_seedpoints, get_surface_mesh): get_test_param_w_seedpoints, get_surface_mesh.mesh_unit ) + # Contract: volume custom zones use list-of-points selector key `seedPoints`. reference = { "refinementFactor": 1.45, "farfield": {"type": "user-defined"}, @@ -677,11 +678,11 @@ def test_seedpoint_zones(get_test_param_w_seedpoints, get_surface_mesh): "zones": [ { "name": "fluid", - "pointInMesh": [0, 0, 0], + "seedPoints": [[0, 0, 0]], }, { "name": "radiator", - "pointInMesh": [1, 1, 1], + "seedPoints": [[1, 1, 1]], }, ], } @@ -689,6 +690,47 @@ def test_seedpoint_zones(get_test_param_w_seedpoints, get_surface_mesh): assert sorted(translated_modular.items()) == sorted(reference.items()) +def test_seedpoint_zone_multiple_points_emits_seedpoints(get_surface_mesh): + with SI_unit_system: + params = SimulationParams( + meshing=ModularMeshingWorkflow( + surface_meshing=snappy.SurfaceMeshingParams( + defaults=snappy.SurfaceMeshingDefaults( + min_spacing=1, max_spacing=2, gap_resolution=1 + ) + ), + volume_meshing=VolumeMeshingParams( + defaults=VolumeMeshingDefaults( + boundary_layer_first_layer_thickness=1.35e-06 * u.m, + boundary_layer_growth_rate=1 + 0.04, + ), + refinement_factor=1.45, + refinements=[], + ), + zones=[ + CustomZones( + entities=[ + SeedpointVolume( + name="fluid", + point_in_mesh=[[0, 0, 0], [1, 1, 1]] * u.m, + ) + ] + ) + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(params, get_surface_mesh.mesh_unit) + # Contract: a single SeedpointVolume may carry multiple points in volume JSON. + assert translated["zones"] == [ + { + "name": "fluid", + "seedPoints": [[0, 0, 0], [1, 1, 1]], + } + ] + + def test_param_to_json_legacy_mesher(get_test_param, get_surface_mesh): # Build params using legacy mesher (non-beta) params = get_test_param(beta_mesher=False)