Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions flow360/component/simulation/meshing_param/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation regression: unconditional check replaced with context-dependent one

Medium Severity

The old _check_snappy_zones else branch unconditionally rejected SeedpointVolume with non-snappy meshers (plain @pd.model_validator). The replacement _check_seedpoint_volume_usage is a @contextual_model_validator that only runs when a ParamsValidationInfo context is provided. When a ModularMeshingWorkflow is constructed without a validation context (e.g., in notebooks or scripts), SeedpointVolume entries paired with a non-snappy, non-beta mesher will silently pass validation instead of raising an error.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3413d3c. Configure here.

return self
Comment on lines +759 to 763
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the updated SeedpointVolume capability checks, consider also updating the existing snappy-specific error message in _check_snappy_zones that says snappy zones are "not CustomZones"—the actual conflict is between SeedpointVolume and CustomVolume entities inside CustomZones, so the message is currently misleading.

Copilot uses AI. Check for mistakes.

@contextual_model_validator(mode="after")
Expand Down
22 changes: 19 additions & 3 deletions flow360/component/simulation/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SeedpointVolume docstring says "To be used only with snappyHexMesh", but the validators now explicitly allow SeedpointVolume with the beta mesher as well. Please update the docstring to match the supported meshers (snappyHexMeshing or beta mesher) to avoid misleading API users.

Suggested change
To be used only with snappyHexMesh.
To be used with snappyHexMeshing or the beta mesher.

Copilot uses AI. Check for mistakes.
"""

Expand All @@ -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"
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
}
Comment on lines 832 to 836
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _get_gai_setting_whitelist, the new defaults whitelist entry seed_points is added, but the function still directly reads input_params.meshing.volume_zones earlier to detect rotation zones. That will raise AttributeError if Geometry AI is enabled while using ModularMeshingWorkflow (which uses zones instead). Consider switching that rotation-zone detection to use getattr(..., "volume_zones", None) with a fallback to zones (similar to _get_gai_seed_points) so GAI filtering works for both schemas.

Copilot uses AI. Check for mistakes.

# Conditionally add sliding_interface_tolerance only when rotation zones are present
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
}
)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
76 changes: 76 additions & 0 deletions tests/simulation/translator/test_surface_meshing_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
CustomZones,
RotationVolume,
UniformRefinement,
UserDefinedFarfield,
WheelBelts,
WindTunnelFarfield,
)
Expand All @@ -72,6 +73,7 @@
SI_unit_system,
imperial_unit_system,
)
from flow360.exceptions import Flow360TranslationError
from tests.simulation.conftest import AssetBase


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down
Loading
Loading