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
30 changes: 30 additions & 0 deletions flow360/component/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ class Geometry(AssetBase):
_entity_info_class = GeometryEntityInfo
_cloud_resource_type_name = "Geometry"

# pylint: disable=redefined-builtin
def __init__(self, id: Union[str, None]):
super().__init__(id)
self.snappy_body_registry = None

@property
def face_group_tag(self):
"getter for face_group_tag"
Expand Down Expand Up @@ -263,6 +268,16 @@ def body_group_tag(self):
def body_group_tag(self, new_value: str):
raise SyntaxError("Cannot set body_group_tag, use group_bodies_by_tag() instead.")

@property
def snappy_bodies(self):
"""Getter for the snappy registry."""
if hasattr(self, "snappy_body_registry") is False or self.snappy_body_registry is None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since you changed the constructor to always populate self.snappy_body_registry as None, the first statement will overshadowed?

raise Flow360ValueError(
"The faces in geometry are not grouped for snappy."
"Please use `group_faces_for_snappy` function to group them first."
)
return self.snappy_body_registry

def get_dynamic_default_settings(self, simulation_dict: dict):
"""Get the default geometry settings from the simulation dict"""

Expand Down Expand Up @@ -412,10 +427,25 @@ def group_bodies_by_tag(self, tag_name: str) -> None:
"body", tag_name, self.internal_registry
)

def group_faces_for_snappy(self) -> None:
"""
Group faces according to body::region convention for snappyHexMesh.
"""
# pylint: disable=protected-access,no-member
self.internal_registry = self._entity_info._group_entity_by_tag(
"face", "faceId", self.internal_registry
)
# pylint: disable=protected-access
self.snappy_body_registry = self._entity_info._group_faces_by_snappy_format()

def reset_face_grouping(self) -> None:
"""Reset the face grouping"""
# pylint: disable=protected-access,no-member
self.internal_registry = self._entity_info._reset_grouping("face", self.internal_registry)
if hasattr(self, "snappy_body_registry") is True and self.snappy_body_registry:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here?

self.snappy_body_registry = self.snappy_body._reset_grouping(
"face", self.snappy_body_registry
)

def reset_edge_grouping(self) -> None:
"""Reset the edge grouping"""
Expand Down
68 changes: 62 additions & 6 deletions flow360/component/simulation/entity_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
import pydantic as pd

from flow360.component.simulation.framework.base_model import Flow360BaseModel
from flow360.component.simulation.framework.entity_registry import EntityRegistry
from flow360.component.simulation.framework.entity_registry import (
EntityRegistry,
SnappyBodyRegistry,
)
from flow360.component.simulation.outputs.output_entities import (
Point,
PointArray,
Expand All @@ -23,6 +26,7 @@
GeometryBodyGroup,
GhostCircularPlane,
GhostSphere,
SnappyBody,
Surface,
)
from flow360.component.simulation.unit_system import LengthType
Expand All @@ -40,6 +44,8 @@
pd.Field(discriminator="private_attribute_entity_type_name"),
]

GROUPED_SNAPPY = "Grouped with snappy name formatting."


class EntityInfoModel(Flow360BaseModel, metaclass=ABCMeta):
"""Base model for asset entity info JSON"""
Expand Down Expand Up @@ -142,7 +148,7 @@ class GeometryEntityInfo(EntityInfoModel):

def group_in_registry(
self,
entity_type_name: Literal["face", "edge", "body"],
entity_type_name: Literal["face", "edge", "body", "snappy_body"],
attribute_name: str,
registry: EntityRegistry,
) -> EntityRegistry:
Expand All @@ -155,15 +161,31 @@ def group_in_registry(
known_frozen_hashes = registry.fast_register(item, known_frozen_hashes)
return registry

def _get_snappy_bodies(self) -> List[SnappyBody]:

snappy_body_mapping = {}
for patch in self.grouped_faces[self.face_attribute_names.index("faceId")]:
name_components = patch.name.split("::")
body_name = name_components[0]
if body_name not in snappy_body_mapping:
snappy_body_mapping[body_name] = []
Copy link
Collaborator

Choose a reason for hiding this comment

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

you can use default dict for initialization.

if patch not in snappy_body_mapping[body_name]:
snappy_body_mapping[body_name].append(patch)

return [
SnappyBody(name=snappy_body, surfaces=body_entities)
for snappy_body, body_entities in snappy_body_mapping.items()
]

def _get_list_of_entities(
self,
attribute_name: Union[str, None] = None,
entity_type_name: Literal["face", "edge", "body"] = None,
) -> Union[List[Surface], List[Edge], List[GeometryBodyGroup]]:
entity_type_name: Literal["face", "edge", "body", "snappy_body"] = None,
) -> Union[List[Surface], List[Edge], List[GeometryBodyGroup], List[SnappyBody]]:
# Validations
if entity_type_name is None:
raise ValueError("Entity type name is required.")
if entity_type_name not in ["face", "edge", "body"]:
if entity_type_name not in ["face", "edge", "body", "snappy_body"]:
raise ValueError(
f"Invalid entity type name, expected 'body, 'face' or 'edge' but got {entity_type_name}."
)
Expand All @@ -175,10 +197,12 @@ def _get_list_of_entities(
entity_attribute_names = self.edge_attribute_names
entity_full_list = self.grouped_edges
specified_attribute_name = self.edge_group_tag
else:
elif entity_type_name == "body":
entity_attribute_names = self.body_attribute_names
entity_full_list = self.grouped_bodies
specified_attribute_name = self.body_group_tag
else:
return self._get_snappy_bodies()

# Use the supplied one if not None
if attribute_name is not None:
Expand All @@ -188,6 +212,8 @@ def _get_list_of_entities(
if specified_attribute_name in entity_attribute_names:
# pylint: disable=no-member, unsubscriptable-object
return entity_full_list[entity_attribute_names.index(specified_attribute_name)]
if specified_attribute_name == GROUPED_SNAPPY:
return self._get_snappy_bodies()

raise ValueError(
f"The given attribute_name `{attribute_name}` is not found"
Expand All @@ -199,6 +225,11 @@ def get_boundaries(self, attribute_name: str = None) -> list[Surface]:
Get the full list of boundaries.
If attribute_name is supplied then ignore stored face_group_tag and use supplied one.
"""
if self.face_group_tag == GROUPED_SNAPPY and attribute_name is None:
Copy link
Collaborator

@benflexcompute benflexcompute Oct 15, 2025

Choose a reason for hiding this comment

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

Why do we need to add this if the faces are already grouped with faceId? Wouldn't the previous method give correct list of boundaries?

all_boundaries = []
for body in self._get_list_of_entities(attribute_name, "snappy_body"):
all_boundaries += body.surfaces
return all_boundaries
return self._get_list_of_entities(attribute_name, "face")

def update_persistent_entities(self, *, asset_entity_registry: EntityRegistry) -> None:
Expand Down Expand Up @@ -348,11 +379,36 @@ def _group_entity_by_tag(

return registry

def _group_faces_by_snappy_format(self):
registry = SnappyBodyRegistry()

existing_face_tag = None
if self.face_group_tag is not None:
existing_face_tag = self.face_group_tag

if existing_face_tag:
if existing_face_tag != GROUPED_SNAPPY:
log.info(
f"Regrouping face entities using snappy name formatting (previous `{GROUPED_SNAPPY}`)."
)
registry = self._reset_grouping(entity_type_name="face", registry=registry)

registry = self.group_in_registry(
"snappy_body", attribute_name=GROUPED_SNAPPY, registry=registry
)

with model_attribute_unlock(self, "face_group_tag"):
self.face_group_tag = GROUPED_SNAPPY

return registry

@pd.validate_call
def _reset_grouping(
self, entity_type_name: Literal["face", "edge", "body"], registry: EntityRegistry
) -> EntityRegistry:
if entity_type_name == "face":
registry.clear(Surface)
registry.clear(SnappyBody)
with model_attribute_unlock(self, "face_group_tag"):
self.face_group_tag = None
elif entity_type_name == "edge":
Expand Down
1 change: 1 addition & 0 deletions flow360/component/simulation/framework/entity_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ def _valid_individual_input(cls, input_data):
"Expected entity instance."
)

# pylint: disable=too-many-branches
@pd.model_validator(mode="before")
@classmethod
def _format_input_to_list(cls, input_data: Union[dict, list]):
Expand Down
76 changes: 76 additions & 0 deletions flow360/component/simulation/framework/entity_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,37 @@
from flow360.component.simulation.framework.base_model import Flow360BaseModel
from flow360.component.simulation.framework.entity_base import EntityBase
from flow360.component.utils import _naming_pattern_handler
from flow360.exceptions import Flow360ValueError


class DoubleIndexableList(list):
Copy link
Collaborator

Choose a reason for hiding this comment

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

The addition of this DoubleIndexableList seems redundant? See my other comment.

"""
An extension of a list that allows accessing elements inside it through a string key.
"""

def __getitem__(self, key: Union[str, slice, int]):
if isinstance(key, str):
returned_items = []
for item in self:
try:
item_ret_value = item[key]
except KeyError:
item_ret_value = []
except Exception as e:
raise ValueError(
f"Trying to access something in {item} through string indexing, which is not allowed."
) from e
if isinstance(item_ret_value, list):
returned_items += item_ret_value
else:
returned_items.append(item_ret_value)
if not returned_items:
raise ValueError(
"No entity found in registry for parent entities: "
+ f"{', '.join([f'{entity.name}' for entity in self])} with given name/naming pattern: '{key}'."
)
return returned_items
return super().__getitem__(key)


class EntityRegistryBucket:
Expand Down Expand Up @@ -227,3 +258,48 @@ def find_by_asset_id(self, *, entity_id: str, entity_class: type[EntityBase]):
def is_empty(self):
"""Return True if the registry is empty, False otherwise."""
return not self.internal_registry


class SnappyBodyRegistry(EntityRegistry):
"""
Extension of Entityregistry to be used with SnappyBody, allows double indexing.
"""

def find_by_naming_pattern(
self, pattern: str, enforce_output_as_list: bool = True, error_when_no_match: bool = False
) -> list[EntityBase]:
"""
Finds all registered entities whose names match a given pattern.

Parameters:
pattern (str): A naming pattern, which can include '*' as a wildcard.

Returns:
List[EntityBase]: A list of entities whose names match the pattern.
"""
matched_entities = DoubleIndexableList()
regex = _naming_pattern_handler(pattern=pattern)
# pylint: disable=no-member
for entity_list in self.internal_registry.values():
matched_entities.extend(filter(lambda x: regex.match(x.name), entity_list))

if not matched_entities and error_when_no_match is True:
raise ValueError(
f"No entity found in registry with given name/naming pattern: '{pattern}'."
)
if enforce_output_as_list is False and len(matched_entities) == 1:
return matched_entities[0]

return matched_entities

def __getitem__(self, key):
"""
Get the entity by name.
`key` is the name of the entity or the naming pattern if wildcard is used.
"""
if isinstance(key, str) is False:
raise Flow360ValueError(f"Entity naming pattern: {key} is not a string.")

return self.find_by_naming_pattern(
key, enforce_output_as_list=False, error_when_no_match=True
)
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class SnappyBodyRefinement(SnappyEntityRefinement):
# pylint: disable=no-member
refinement_type: Literal["SnappyBodyRefinement"] = pd.Field("SnappyBodyRefinement", frozen=True)
gap_resolution: Optional[LengthType.NonNegative] = pd.Field(None)
entities: List[SnappyBody] = pd.Field(alias="bodies")
entities: EntityList[SnappyBody] = pd.Field(alias="bodies")


class SnappyRegionRefinement(SnappyEntityRefinement):
Expand Down Expand Up @@ -119,7 +119,7 @@ class SnappySurfaceEdgeRefinement(Flow360BaseModel):
min_elem: Optional[pd.NonNegativeInt] = pd.Field(None)
min_len: Optional[LengthType.NonNegative] = pd.Field(None)
included_angle: AngleType.Positive = pd.Field(150 * u.deg)
bodies: Optional[List[SnappyBody]] = pd.Field(None)
bodies: Optional[EntityList[SnappyBody]] = pd.Field(None)
regions: Optional[EntityList[Surface]] = pd.Field(None)
retain_on_smoothing: Optional[bool] = pd.Field(True)

Expand Down
24 changes: 22 additions & 2 deletions flow360/component/simulation/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
get_validation_info,
)
from flow360.component.types import Axis
from flow360.component.utils import _naming_pattern_handler

BOUNDARY_FULL_NAME_WHEN_NOT_FOUND = "This boundary does not exist!!!"

Expand Down Expand Up @@ -716,14 +717,33 @@ def __str__(self):
return ",".join(sorted([self.pair[0].name, self.pair[1].name]))


class SnappyBody(Flow360BaseModel):
class SnappyBody(EntityBase):
"""
Represents a group of faces forming a body for snappyHexMesh.
Bodies and their regions are defined in the ASCII STL file by using the solid -> endsolid"
keywords with a body::region naming scheme.
"""

body_name: str = pd.Field()
private_attribute_registry_bucket_name: Literal["SurfaceGroupedEntityType"] = pd.Field(
"SurfaceGroupedEntityType", frozen=True
)
private_attribute_entity_type_name: Literal["SnappyBody"] = pd.Field("SnappyBody", frozen=True)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Please populate private_attribute_id too here.

surfaces: List[Surface] = pd.Field()

def __getitem__(self, key: str):
Copy link
Collaborator

@benflexcompute benflexcompute Oct 15, 2025

Choose a reason for hiding this comment

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

Why do we still need to get Surface entities from SnappyBody?
To select a surface with pattern patch* under SnappyBodies with pattern tunne* we can just do:

my_geo["tunne*::patch*"]

Why do user want to instead do:

my_geo.snappy_body["tunne*"]["patch*"]

which is longer?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Had a discussion with @piotrkluba and this is just a syntax candy for snappy user.

if len(self.surfaces) == 1 and ("::" not in self.surfaces[0].name):
regex = _naming_pattern_handler(pattern=key)
else:
regex = _naming_pattern_handler(pattern=f"{self.name}::{key}")

matched_surfaces = [entity for entity in self.surfaces if regex.match(entity.name)]
if not matched_surfaces:
print(key)
raise KeyError(
f"No entity found in registry for parent entity: {self.name} with given name/naming pattern: '{key}'."
)
return matched_surfaces


@final
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def apply_SnappyBodyRefinement(
"""
Translate SnappyBodyRefinement to bodies.
"""
applicable_bodies = [entity.body_name for entity in refinement.entities]
applicable_bodies = [entity.name for entity in refinement.entities.stored_entities]
for body in translated["geometry"]["bodies"]:
if body["bodyName"] in applicable_bodies:
if refinement.gap_resolution is not None:
Expand Down Expand Up @@ -163,7 +163,9 @@ def apply_SnappySurfaceEdgeRefinement(
refinement.spacing, spacing_system
).value.item()
applicable_bodies = (
[entity.body_name for entity in refinement.bodies] if refinement.bodies is not None else []
[entity.name for entity in refinement.bodies.stored_entities]
if refinement.bodies is not None
else []
)
applicable_regions = get_applicable_regions_dict(refinement_regions=refinement.regions)
for body in translated["geometry"]["bodies"]:
Expand Down
Loading