From 339dde03c02b4100fe08de7a22c3e7fa0e3dd96a Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Fri, 17 Oct 2025 17:09:55 -0400 Subject: [PATCH 01/15] wip: Geometry management on tree --- flow360/component/__init__.py | 24 +++ flow360/component/geometry.py | 110 +++++++++- flow360/component/geometry_tree.py | 334 +++++++++++++++++++++++++++++ 3 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 flow360/component/geometry_tree.py diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index e69de29bb..a1c2bd21c 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -0,0 +1,24 @@ +"""Flow360 Component Module""" + +from flow360.component.geometry_tree import ( + AttributeQuery, + FilterExpression, + GeometryTree, + Name, + NodeType, + TreeNode, + Type, +) + +__all__ = [ + "AttributeQuery", + "FilterExpression", + "GeometryTree", + "Name", + "NodeType", + "TreeNode", + "Type", +] + + + diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 06b59af41..754ff1f60 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -7,7 +7,7 @@ import os import threading from enum import Enum -from typing import Any, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union import pydantic as pd @@ -18,6 +18,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi +from flow360.component.geometry_tree import FilterExpression, GeometryTree from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( AssetMetaBaseModelV2, @@ -236,6 +237,10 @@ class Geometry(AssetBase): _entity_info_class = GeometryEntityInfo _cloud_resource_type_name = "Geometry" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._geometry_tree: Optional[GeometryTree] = None + @property def face_group_tag(self): "getter for face_group_tag" @@ -568,3 +573,106 @@ def __getitem__(self, key: str): def __setitem__(self, key: str, value: Any): raise NotImplementedError("Assigning/setting entities is not supported.") + + # ========== Tree-based face grouping methods ========== + + def load_geometry_tree(self, tree_json_path: str) -> None: + """ + Load Geometry hierarchy tree from JSON file + + Parameters + ---------- + tree_json_path : str + Path to the tree JSON file generated from Geometry hierarchy extraction + + Examples + -------- + >>> geometry = Geometry.from_cloud("geom_id") + >>> geometry.load_geometry_tree("tree.json") + """ + self._geometry_tree = GeometryTree(tree_json_path) + log.info(f"Loaded Geometry tree with {len(self._geometry_tree.all_faces)} faces") + + def create_face_group(self, name: str, filter: FilterExpression) -> List[str]: + """ + Create a face group based on Geometry tree filtering + + This method filters nodes in the Geometry hierarchy tree and groups all faces + under matching nodes. If any faces already belong to another group, they + will be reassigned to the new group. + + Parameters + ---------- + name : str + Name of the face group + filter : FilterExpression + Filter expression to match nodes in the tree. Use Type, Name operators + to build filter expressions. + + Returns + ------- + List[str] + List of face UUIDs in the created group + + Examples + -------- + >>> from flow360.component.geometry_tree import Type, Name, NodeType + >>> geometry.create_face_group( + ... name="wing", + ... filter=(Type == NodeType.FRMFeature) & Name.contains("wing") + ... ) + """ + if self._geometry_tree is None: + raise Flow360ValueError( + "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" + ) + + face_nodes = self._geometry_tree.create_face_group(name, filter) + face_uuids = [face.uuid for face in face_nodes if face.uuid] + + log.info(f"Created face group '{name}' with {len(face_uuids)} faces") + return face_uuids + + @property + def face_groups(self) -> Dict[str, int]: + """ + Get dictionary of face groups and their face counts + + Returns + ------- + Dict[str, int] + Dictionary mapping group names to number of faces in each group + + Examples + -------- + >>> geometry.face_groups + {'wing': 45, 'fuselage': 32, 'tail': 18} + """ + if self._geometry_tree is None: + return {} + return { + name: len(faces) for name, faces in self._geometry_tree.face_groups.items() + } + + def print_face_grouping_stats(self) -> None: + """ + Print statistics about face grouping + + Examples + -------- + >>> geometry.print_face_grouping_stats() + === Face Grouping Statistics === + Total faces: 95 + Faces in groups: 95 + + Face groups (3): + - wing: 45 faces + - fuselage: 32 faces + - tail: 18 faces + ================================= + """ + if self._geometry_tree is None: + raise Flow360ValueError( + "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" + ) + self._geometry_tree.print_grouping_stats() diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py new file mode 100644 index 000000000..ccfa44f51 --- /dev/null +++ b/flow360/component/geometry_tree.py @@ -0,0 +1,334 @@ +""" +Tree-based geometry grouping functionality for Geometry models +""" + +from __future__ import annotations + +import json +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Set, Union + + +class NodeType(Enum): + """Geometry tree node types""" + + ModelFile = "ModelFile" + ProductOccurrence = "ProductOccurrence" + PartDefinition = "PartDefinition" + FRMFeatureBasedEntity = "FRMFeatureBasedEntity" + FRMFeatureParameter = "FRMFeatureParameter" + FRMFeature = "FRMFeature" + FRMFeatureLinkedItem = "FRMFeatureLinkedItem" + RiBrepModel = "RiBrepModel" + RiSet = "RiSet" + TopoConnex = "TopoConnex" + TopoShell = "TopoShell" + TopoFace = "TopoFace" + + +class TreeNode: + """Represents a node in the Geometry hierarchy tree""" + + def __init__( + self, + node_type: str, + name: str = "", + attributes: Dict[str, str] = None, + color: str = "", + uuid: str = "", + children: List[TreeNode] = None, + ): + self.type = node_type + self.name = name + self.attributes = attributes or {} + self.color = color + self.uuid = uuid + self.children = children or [] + self.parent: Optional[TreeNode] = None + + # Set parent for children + for child in self.children: + child.parent = self + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> TreeNode: + """Create TreeNode from dictionary""" + children = [cls.from_dict(child) for child in data.get("children", [])] + node = cls( + node_type=data.get("type", ""), + name=data.get("name", ""), + attributes=data.get("attributes", {}), + color=data.get("color", ""), + uuid=data.get("UUID", ""), + children=children, + ) + return node + + def get_path(self) -> List[TreeNode]: + """Get the path from root to this node""" + path = [] + current = self + while current is not None: + path.insert(0, current) + current = current.parent + return path + + def get_all_faces(self) -> List[TreeNode]: + """Get all TopoFace nodes under this node""" + faces = [] + if self.type == "TopoFace": + faces.append(self) + for child in self.children: + faces.extend(child.get_all_faces()) + return faces + + def find_nodes(self, filter_func: Callable[[TreeNode], bool]) -> List[TreeNode]: + """Find all nodes matching the filter function""" + matches = [] + if filter_func(self): + matches.append(self) + for child in self.children: + matches.extend(child.find_nodes(filter_func)) + return matches + + def __repr__(self): + return f"TreeNode(type={self.type}, name={self.name})" + + +class FilterExpression: + """Base class for filter expressions""" + + def __call__(self, node: TreeNode) -> bool: + raise NotImplementedError + + def __and__(self, other: FilterExpression) -> AndExpression: + return AndExpression(self, other) + + def __or__(self, other: FilterExpression) -> OrExpression: + return OrExpression(self, other) + + def __invert__(self) -> NotExpression: + return NotExpression(self) + + +class AndExpression(FilterExpression): + """Logical AND of two filter expressions""" + + def __init__(self, left: FilterExpression, right: FilterExpression): + self.left = left + self.right = right + + def __call__(self, node: TreeNode) -> bool: + return self.left(node) and self.right(node) + + +class OrExpression(FilterExpression): + """Logical OR of two filter expressions""" + + def __init__(self, left: FilterExpression, right: FilterExpression): + self.left = left + self.right = right + + def __call__(self, node: TreeNode) -> bool: + return self.left(node) or self.right(node) + + +class NotExpression(FilterExpression): + """Logical NOT of a filter expression""" + + def __init__(self, expr: FilterExpression): + self.expr = expr + + def __call__(self, node: TreeNode) -> bool: + return not self.expr(node) + + +class TypeQuery: + """Query builder for node type""" + + def __eq__(self, node_type: Union[NodeType, str]) -> FilterExpression: + if isinstance(node_type, NodeType): + type_str = node_type.value + else: + type_str = node_type + + class TypeFilter(FilterExpression): + def __call__(self, node: TreeNode) -> bool: + return node.type == type_str + + return TypeFilter() + + +class NameQuery: + """Query builder for node name""" + + def contains(self, substring: str) -> FilterExpression: + class NameContainsFilter(FilterExpression): + def __call__(self, node: TreeNode) -> bool: + return substring.lower() in node.name.lower() + + return NameContainsFilter() + + def __eq__(self, name: str) -> FilterExpression: + class NameEqualsFilter(FilterExpression): + def __call__(self, node: TreeNode) -> bool: + return node.name == name + + return NameEqualsFilter() + + def startswith(self, prefix: str) -> FilterExpression: + class NameStartsWithFilter(FilterExpression): + def __call__(self, node: TreeNode) -> bool: + return node.name.startswith(prefix) + + return NameStartsWithFilter() + + def endswith(self, suffix: str) -> FilterExpression: + class NameEndsWithFilter(FilterExpression): + def __call__(self, node: TreeNode) -> bool: + return node.name.endswith(suffix) + + return NameEndsWithFilter() + + +class AttributeQuery: + """Query builder for node attributes""" + + def __init__(self, attr_key: str): + self.attr_key = attr_key + + def __eq__(self, value: str) -> FilterExpression: + class AttributeEqualsFilter(FilterExpression): + def __init__(self, key: str, val: str): + self.key = key + self.val = val + + def __call__(self, node: TreeNode) -> bool: + return node.attributes.get(self.key) == self.val + + return AttributeEqualsFilter(self.attr_key, value) + + def contains(self, substring: str) -> FilterExpression: + class AttributeContainsFilter(FilterExpression): + def __init__(self, key: str, substr: str): + self.key = key + self.substr = substr + + def __call__(self, node: TreeNode) -> bool: + attr_value = node.attributes.get(self.key, "") + return self.substr in attr_value + + return AttributeContainsFilter(self.attr_key, substring) + + +# Singleton query objects +Type = TypeQuery() +Name = NameQuery() + + +class GeometryTree: + """Manages Geometry hierarchy tree and face grouping""" + + def __init__(self, tree_json_path: str): + """ + Initialize geometry tree from JSON file + + Parameters + ---------- + tree_json_path : str + Path to the tree JSON file + """ + with open(tree_json_path, "r", encoding="utf-8") as f: + tree_data = json.load(f) + + self.root = TreeNode.from_dict(tree_data) + self.all_faces = self.root.get_all_faces() + self.face_to_group: Dict[str, str] = {} # face_uuid -> group_name + self.face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces + + def create_face_group( + self, name: str, filter_expr: FilterExpression + ) -> List[TreeNode]: + """ + Create a face group by filtering nodes and collecting their faces. + If a face already belongs to another group, it will be reassigned to the new group. + + Parameters + ---------- + name : str + Name of the face group + filter_expr : FilterExpression + Filter expression to match nodes + + Returns + ------- + List[TreeNode] + List of faces in the created group + """ + # Find matching nodes + matching_nodes = self.root.find_nodes(lambda node: filter_expr(node)) + + # Collect faces from matching nodes + group_faces = [] + new_face_uuids = set() + + for node in matching_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + group_faces.append(face) + new_face_uuids.add(face.uuid) + + # Remove these faces from their previous groups + for group_name, faces in list(self.face_groups.items()): + if group_name != name: + self.face_groups[group_name] = [ + f for f in faces if f.uuid not in new_face_uuids + ] + + # Update face-to-group mapping + for uuid in new_face_uuids: + self.face_to_group[uuid] = name + + # Store the group + self.face_groups[name] = group_faces + + return group_faces + + def print_grouping_stats(self) -> None: + """Print statistics about face grouping""" + total_faces = len(self.all_faces) + + # Count faces currently in groups + faces_in_groups = sum(len(faces) for faces in self.face_groups.values()) + + print(f"\n=== Face Grouping Statistics ===") + print(f"Total faces: {total_faces}") + print(f"Faces in groups: {faces_in_groups}") + print(f"\nFace groups ({len(self.face_groups)}):") + for group_name, faces in self.face_groups.items(): + print(f" - {group_name}: {len(faces)} faces") + print("=" * 33) + + def get_face_group_names(self) -> List[str]: + """Get list of all face group names""" + return list(self.face_groups.keys()) + + def get_faces_in_group(self, group_name: str) -> List[TreeNode]: + """ + Get faces in a specific group + + Parameters + ---------- + group_name : str + Name of the face group + + Returns + ------- + List[TreeNode] + List of faces in the group + """ + return self.face_groups.get(group_name, []) + + + From f494f760805fc9aab72144aa8392e3ed43d0c5eb Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Mon, 20 Oct 2025 13:27:53 -0400 Subject: [PATCH 02/15] temp: lift restriction on entity_info in Geometry --- flow360/component/geometry.py | 28 ------------------- .../component/simulation/web/asset_base.py | 5 ++-- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 754ff1f60..dcf5371ec 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -594,34 +594,6 @@ def load_geometry_tree(self, tree_json_path: str) -> None: log.info(f"Loaded Geometry tree with {len(self._geometry_tree.all_faces)} faces") def create_face_group(self, name: str, filter: FilterExpression) -> List[str]: - """ - Create a face group based on Geometry tree filtering - - This method filters nodes in the Geometry hierarchy tree and groups all faces - under matching nodes. If any faces already belong to another group, they - will be reassigned to the new group. - - Parameters - ---------- - name : str - Name of the face group - filter : FilterExpression - Filter expression to match nodes in the tree. Use Type, Name operators - to build filter expressions. - - Returns - ------- - List[str] - List of face UUIDs in the created group - - Examples - -------- - >>> from flow360.component.geometry_tree import Type, Name, NodeType - >>> geometry.create_face_group( - ... name="wing", - ... filter=(Type == NodeType.FRMFeature) & Name.contains("wing") - ... ) - """ if self._geometry_tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" diff --git a/flow360/component/simulation/web/asset_base.py b/flow360/component/simulation/web/asset_base.py index 14fd2d8fb..48c2f9fa4 100644 --- a/flow360/component/simulation/web/asset_base.py +++ b/flow360/component/simulation/web/asset_base.py @@ -316,8 +316,9 @@ def _from_local_storage( with open(os.path.join(local_storage_path, "simulation.json"), encoding="utf-8") as f: params_dict = json.load(f) - asset_obj = cls._from_supplied_entity_info(params_dict, cls(asset_id)) - asset_obj.get_dynamic_default_settings(params_dict) + asset_obj = cls(asset_id) +# asset_obj = cls._from_supplied_entity_info(params_dict, cls(asset_id)) +# asset_obj.get_dynamic_default_settings(params_dict) # pylint: disable=protected-access if not hasattr(asset_obj, "_webapi"): From 89260a44ce5079abb48b5742592f55ec7eb02ca9 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Mon, 20 Oct 2025 14:45:50 -0400 Subject: [PATCH 03/15] create FaceGroupManager --- flow360/component/__init__.py | 2 + flow360/component/face_group_manager.py | 122 ++++++++++++++++++++++++ flow360/component/geometry.py | 47 +++++++-- flow360/component/geometry_tree.py | 81 ++-------------- 4 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 flow360/component/face_group_manager.py diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index a1c2bd21c..ff5dbfa32 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -1,5 +1,6 @@ """Flow360 Component Module""" +from flow360.component.face_group_manager import FaceGroupManager from flow360.component.geometry_tree import ( AttributeQuery, FilterExpression, @@ -12,6 +13,7 @@ __all__ = [ "AttributeQuery", + "FaceGroupManager", "FilterExpression", "GeometryTree", "Name", diff --git a/flow360/component/face_group_manager.py b/flow360/component/face_group_manager.py new file mode 100644 index 000000000..b7994d7bb --- /dev/null +++ b/flow360/component/face_group_manager.py @@ -0,0 +1,122 @@ +""" +Face grouping manager for geometry models +""" + +from __future__ import annotations + +from typing import Dict, List + +from flow360.component.geometry_tree import FilterExpression, GeometryTree, TreeNode + + +class FaceGroupManager: + """Manages face grouping logic""" + + def __init__(self, tree: GeometryTree): + """ + Initialize face group manager + + Parameters + ---------- + tree : GeometryTree + The geometry tree to manage groupings for + """ + self.tree = tree + self.face_to_group: Dict[str, str] = {} # face_uuid -> group_name + self.face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces + + def create_face_group( + self, name: str, filter_expr: FilterExpression + ) -> List[TreeNode]: + """ + Create a face group by filtering nodes and collecting their faces. + If a face already belongs to another group, it will be reassigned to the new group. + + Parameters + ---------- + name : str + Name of the face group + filter_expr : FilterExpression + Filter expression to match nodes + + Returns + ------- + List[TreeNode] + List of faces in the created group + """ + # Find matching nodes using the tree + matching_nodes = self.tree.find_nodes(filter_expr) + + # Collect faces from matching nodes + group_faces = [] + new_face_uuids = set() + + for node in matching_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + group_faces.append(face) + new_face_uuids.add(face.uuid) + + # Remove these faces from their previous groups + for group_name, faces in list(self.face_groups.items()): + if group_name != name: + self.face_groups[group_name] = [ + f for f in faces if f.uuid not in new_face_uuids + ] + + # Update face-to-group mapping + for uuid in new_face_uuids: + self.face_to_group[uuid] = name + + # Store the group + self.face_groups[name] = group_faces + + return group_faces + + def get_face_groups(self) -> Dict[str, int]: + """ + Get dictionary of face groups and their face counts + + Returns + ------- + Dict[str, int] + Dictionary mapping group names to number of faces + """ + return {name: len(faces) for name, faces in self.face_groups.items()} + + def print_grouping_stats(self) -> None: + """Print statistics about face grouping""" + total_faces = len(self.tree.all_faces) + + # Count faces currently in groups + faces_in_groups = sum(len(faces) for faces in self.face_groups.values()) + + print(f"\n=== Face Grouping Statistics ===") + print(f"Total faces: {total_faces}") + print(f"Faces in groups: {faces_in_groups}") + print(f"\nFace groups ({len(self.face_groups)}):") + for group_name, faces in self.face_groups.items(): + print(f" - {group_name}: {len(faces)} faces") + print("=" * 33) + + def get_face_group_names(self) -> List[str]: + """Get list of all face group names""" + return list(self.face_groups.keys()) + + def get_faces_in_group(self, group_name: str) -> List[TreeNode]: + """ + Get faces in a specific group + + Parameters + ---------- + group_name : str + Name of the face group + + Returns + ------- + List[TreeNode] + List of faces in the group + """ + return self.face_groups.get(group_name, []) + diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index dcf5371ec..6aae1d5d8 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -18,6 +18,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi +from flow360.component.face_group_manager import FaceGroupManager from flow360.component.geometry_tree import FilterExpression, GeometryTree from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( @@ -240,6 +241,7 @@ class Geometry(AssetBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._geometry_tree: Optional[GeometryTree] = None + self._face_group_manager: Optional[FaceGroupManager] = None @property def face_group_tag(self): @@ -591,15 +593,46 @@ def load_geometry_tree(self, tree_json_path: str) -> None: >>> geometry.load_geometry_tree("tree.json") """ self._geometry_tree = GeometryTree(tree_json_path) + self._face_group_manager = FaceGroupManager(self._geometry_tree) + ## create default face gruoping by body + log.info(f"Loaded Geometry tree with {len(self._geometry_tree.all_faces)} faces") def create_face_group(self, name: str, filter: FilterExpression) -> List[str]: - if self._geometry_tree is None: + """ + Create a face group based on Geometry tree filtering + + This method filters nodes in the Geometry hierarchy tree and groups all faces + under matching nodes. If any faces already belong to another group, they + will be reassigned to the new group. + + Parameters + ---------- + name : str + Name of the face group + filter : FilterExpression + Filter expression to match nodes in the tree. Use Type, Name operators + to build filter expressions. + + Returns + ------- + List[str] + List of face UUIDs in the created group + + Examples + -------- + >>> from flow360.component.geometry_tree import Type, Name, NodeType + >>> geometry.create_face_group( + ... name="wing", + ... filter=(Type == NodeType.FRMFeature) & Name.contains("wing") + ... ) + """ + if self._face_group_manager is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - face_nodes = self._geometry_tree.create_face_group(name, filter) + face_nodes = self._face_group_manager.create_face_group(name, filter) face_uuids = [face.uuid for face in face_nodes if face.uuid] log.info(f"Created face group '{name}' with {len(face_uuids)} faces") @@ -620,11 +653,9 @@ def face_groups(self) -> Dict[str, int]: >>> geometry.face_groups {'wing': 45, 'fuselage': 32, 'tail': 18} """ - if self._geometry_tree is None: + if self._face_group_manager is None: return {} - return { - name: len(faces) for name, faces in self._geometry_tree.face_groups.items() - } + return self._face_group_manager.get_face_groups() def print_face_grouping_stats(self) -> None: """ @@ -643,8 +674,8 @@ def print_face_grouping_stats(self) -> None: - tail: 18 faces ================================= """ - if self._geometry_tree is None: + if self._face_group_manager is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - self._geometry_tree.print_grouping_stats() + self._face_group_manager.print_grouping_stats() diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index ccfa44f51..25d4bf9ca 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -227,7 +227,7 @@ def __call__(self, node: TreeNode) -> bool: class GeometryTree: - """Manages Geometry hierarchy tree and face grouping""" + """Pure tree structure representing Geometry hierarchy""" def __init__(self, tree_json_path: str): """ @@ -243,92 +243,31 @@ def __init__(self, tree_json_path: str): self.root = TreeNode.from_dict(tree_data) self.all_faces = self.root.get_all_faces() - self.face_to_group: Dict[str, str] = {} # face_uuid -> group_name - self.face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces - def create_face_group( - self, name: str, filter_expr: FilterExpression - ) -> List[TreeNode]: + def find_nodes(self, filter_expr: FilterExpression) -> List[TreeNode]: """ - Create a face group by filtering nodes and collecting their faces. - If a face already belongs to another group, it will be reassigned to the new group. + Find all nodes matching the filter expression Parameters ---------- - name : str - Name of the face group filter_expr : FilterExpression Filter expression to match nodes Returns ------- List[TreeNode] - List of faces in the created group + List of matching nodes """ - # Find matching nodes - matching_nodes = self.root.find_nodes(lambda node: filter_expr(node)) - - # Collect faces from matching nodes - group_faces = [] - new_face_uuids = set() - - for node in matching_nodes: - faces = node.get_all_faces() - for face in faces: - if face.uuid: - group_faces.append(face) - new_face_uuids.add(face.uuid) - - # Remove these faces from their previous groups - for group_name, faces in list(self.face_groups.items()): - if group_name != name: - self.face_groups[group_name] = [ - f for f in faces if f.uuid not in new_face_uuids - ] - - # Update face-to-group mapping - for uuid in new_face_uuids: - self.face_to_group[uuid] = name - - # Store the group - self.face_groups[name] = group_faces - - return group_faces - - def print_grouping_stats(self) -> None: - """Print statistics about face grouping""" - total_faces = len(self.all_faces) - - # Count faces currently in groups - faces_in_groups = sum(len(faces) for faces in self.face_groups.values()) - - print(f"\n=== Face Grouping Statistics ===") - print(f"Total faces: {total_faces}") - print(f"Faces in groups: {faces_in_groups}") - print(f"\nFace groups ({len(self.face_groups)}):") - for group_name, faces in self.face_groups.items(): - print(f" - {group_name}: {len(faces)} faces") - print("=" * 33) - - def get_face_group_names(self) -> List[str]: - """Get list of all face group names""" - return list(self.face_groups.keys()) - - def get_faces_in_group(self, group_name: str) -> List[TreeNode]: - """ - Get faces in a specific group + return self.root.find_nodes(lambda node: filter_expr(node)) - Parameters - ---------- - group_name : str - Name of the face group + def get_all_faces(self) -> List[TreeNode]: + """ + Get all face nodes in the tree Returns ------- List[TreeNode] - List of faces in the group + List of all TopoFace nodes """ - return self.face_groups.get(group_name, []) - - + return self.all_faces From 473051320ede9edec58ca54e54ee530fb3580878 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Mon, 20 Oct 2025 15:02:17 -0400 Subject: [PATCH 04/15] remove face group manager --- flow360/component/__init__.py | 2 - flow360/component/face_group_manager.py | 122 ------------------------ flow360/component/geometry.py | 59 +++++++++--- 3 files changed, 46 insertions(+), 137 deletions(-) delete mode 100644 flow360/component/face_group_manager.py diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index ff5dbfa32..a1c2bd21c 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -1,6 +1,5 @@ """Flow360 Component Module""" -from flow360.component.face_group_manager import FaceGroupManager from flow360.component.geometry_tree import ( AttributeQuery, FilterExpression, @@ -13,7 +12,6 @@ __all__ = [ "AttributeQuery", - "FaceGroupManager", "FilterExpression", "GeometryTree", "Name", diff --git a/flow360/component/face_group_manager.py b/flow360/component/face_group_manager.py deleted file mode 100644 index b7994d7bb..000000000 --- a/flow360/component/face_group_manager.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -Face grouping manager for geometry models -""" - -from __future__ import annotations - -from typing import Dict, List - -from flow360.component.geometry_tree import FilterExpression, GeometryTree, TreeNode - - -class FaceGroupManager: - """Manages face grouping logic""" - - def __init__(self, tree: GeometryTree): - """ - Initialize face group manager - - Parameters - ---------- - tree : GeometryTree - The geometry tree to manage groupings for - """ - self.tree = tree - self.face_to_group: Dict[str, str] = {} # face_uuid -> group_name - self.face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces - - def create_face_group( - self, name: str, filter_expr: FilterExpression - ) -> List[TreeNode]: - """ - Create a face group by filtering nodes and collecting their faces. - If a face already belongs to another group, it will be reassigned to the new group. - - Parameters - ---------- - name : str - Name of the face group - filter_expr : FilterExpression - Filter expression to match nodes - - Returns - ------- - List[TreeNode] - List of faces in the created group - """ - # Find matching nodes using the tree - matching_nodes = self.tree.find_nodes(filter_expr) - - # Collect faces from matching nodes - group_faces = [] - new_face_uuids = set() - - for node in matching_nodes: - faces = node.get_all_faces() - for face in faces: - if face.uuid: - group_faces.append(face) - new_face_uuids.add(face.uuid) - - # Remove these faces from their previous groups - for group_name, faces in list(self.face_groups.items()): - if group_name != name: - self.face_groups[group_name] = [ - f for f in faces if f.uuid not in new_face_uuids - ] - - # Update face-to-group mapping - for uuid in new_face_uuids: - self.face_to_group[uuid] = name - - # Store the group - self.face_groups[name] = group_faces - - return group_faces - - def get_face_groups(self) -> Dict[str, int]: - """ - Get dictionary of face groups and their face counts - - Returns - ------- - Dict[str, int] - Dictionary mapping group names to number of faces - """ - return {name: len(faces) for name, faces in self.face_groups.items()} - - def print_grouping_stats(self) -> None: - """Print statistics about face grouping""" - total_faces = len(self.tree.all_faces) - - # Count faces currently in groups - faces_in_groups = sum(len(faces) for faces in self.face_groups.values()) - - print(f"\n=== Face Grouping Statistics ===") - print(f"Total faces: {total_faces}") - print(f"Faces in groups: {faces_in_groups}") - print(f"\nFace groups ({len(self.face_groups)}):") - for group_name, faces in self.face_groups.items(): - print(f" - {group_name}: {len(faces)} faces") - print("=" * 33) - - def get_face_group_names(self) -> List[str]: - """Get list of all face group names""" - return list(self.face_groups.keys()) - - def get_faces_in_group(self, group_name: str) -> List[TreeNode]: - """ - Get faces in a specific group - - Parameters - ---------- - group_name : str - Name of the face group - - Returns - ------- - List[TreeNode] - List of faces in the group - """ - return self.face_groups.get(group_name, []) - diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 6aae1d5d8..fa4d48e26 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -18,8 +18,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi -from flow360.component.face_group_manager import FaceGroupManager -from flow360.component.geometry_tree import FilterExpression, GeometryTree +from flow360.component.geometry_tree import FilterExpression, GeometryTree, TreeNode from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( AssetMetaBaseModelV2, @@ -241,7 +240,8 @@ class Geometry(AssetBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._geometry_tree: Optional[GeometryTree] = None - self._face_group_manager: Optional[FaceGroupManager] = None + self._face_to_group: Dict[str, str] = {} # face_uuid -> group_name + self._face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces @property def face_group_tag(self): @@ -593,7 +593,6 @@ def load_geometry_tree(self, tree_json_path: str) -> None: >>> geometry.load_geometry_tree("tree.json") """ self._geometry_tree = GeometryTree(tree_json_path) - self._face_group_manager = FaceGroupManager(self._geometry_tree) ## create default face gruoping by body log.info(f"Loaded Geometry tree with {len(self._geometry_tree.all_faces)} faces") @@ -627,14 +626,40 @@ def create_face_group(self, name: str, filter: FilterExpression) -> List[str]: ... filter=(Type == NodeType.FRMFeature) & Name.contains("wing") ... ) """ - if self._face_group_manager is None: + if self._geometry_tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - face_nodes = self._face_group_manager.create_face_group(name, filter) - face_uuids = [face.uuid for face in face_nodes if face.uuid] - + # Find matching nodes using the tree + matching_nodes = self._geometry_tree.find_nodes(filter) + + # Collect faces from matching nodes + group_faces = [] + new_face_uuids = set() + + for node in matching_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + group_faces.append(face) + new_face_uuids.add(face.uuid) + + # Remove these faces from their previous groups + for group_name, faces in list(self._face_groups.items()): + if group_name != name: + self._face_groups[group_name] = [ + f for f in faces if f.uuid not in new_face_uuids + ] + + # Update face-to-group mapping + for uuid in new_face_uuids: + self._face_to_group[uuid] = name + + # Store the group + self._face_groups[name] = group_faces + + face_uuids = [face.uuid for face in group_faces if face.uuid] log.info(f"Created face group '{name}' with {len(face_uuids)} faces") return face_uuids @@ -653,9 +678,7 @@ def face_groups(self) -> Dict[str, int]: >>> geometry.face_groups {'wing': 45, 'fuselage': 32, 'tail': 18} """ - if self._face_group_manager is None: - return {} - return self._face_group_manager.get_face_groups() + return {name: len(faces) for name, faces in self._face_groups.items()} def print_face_grouping_stats(self) -> None: """ @@ -674,8 +697,18 @@ def print_face_grouping_stats(self) -> None: - tail: 18 faces ================================= """ - if self._face_group_manager is None: + if self._geometry_tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - self._face_group_manager.print_grouping_stats() + + total_faces = len(self._geometry_tree.all_faces) + faces_in_groups = sum(len(faces) for faces in self._face_groups.values()) + + print(f"\n=== Face Grouping Statistics ===") + print(f"Total faces: {total_faces}") + print(f"Faces in groups: {faces_in_groups}") + print(f"\nFace groups ({len(self._face_groups)}):") + for group_name, faces in self._face_groups.items(): + print(f" - {group_name}: {len(faces)} faces") + print("=" * 33) From f79fb23c238fc2088a93736a424b48b5d82bebb5 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Mon, 20 Oct 2025 17:05:09 -0400 Subject: [PATCH 05/15] wip --- flow360/component/geometry_tree.py | 62 +++++++++++++----------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index 25d4bf9ca..085002c86 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -8,6 +8,7 @@ from enum import Enum from typing import Any, Callable, Dict, List, Optional, Set, Union +FLOW360_UUID_ATTRIBUTE_KEY = "Flow360UUID" class NodeType(Enum): """Geometry tree node types""" @@ -24,6 +25,7 @@ class NodeType(Enum): TopoConnex = "TopoConnex" TopoShell = "TopoShell" TopoFace = "TopoFace" + TopoFacePointer = "TopoFacePointer" class TreeNode: @@ -31,22 +33,21 @@ class TreeNode: def __init__( self, - node_type: str, + node_type: NodeType, name: str = "", - attributes: Dict[str, str] = None, color: str = "", - uuid: str = "", - children: List[TreeNode] = None, + attributes: Dict[str, str] = {}, + children: List[TreeNode] = [], ): self.type = node_type self.name = name - self.attributes = attributes or {} + self.attributes = attributes self.color = color - self.uuid = uuid - self.children = children or [] + self.children = children self.parent: Optional[TreeNode] = None - - # Set parent for children + self.uuid = None + if FLOW360_UUID_ATTRIBUTE_KEY in attributes: + self.uuid = attributes[FLOW360_UUID_ATTRIBUTE_KEY] for child in self.children: child.parent = self @@ -55,11 +56,10 @@ def from_dict(cls, data: Dict[str, Any]) -> TreeNode: """Create TreeNode from dictionary""" children = [cls.from_dict(child) for child in data.get("children", [])] node = cls( - node_type=data.get("type", ""), - name=data.get("name", ""), - attributes=data.get("attributes", {}), - color=data.get("color", ""), - uuid=data.get("UUID", ""), + node_type=NodeType[data.get("type")], + name=data.get("name"), + color=data.get("color"), + attributes=data.get("attributes"), children=children, ) return node @@ -73,15 +73,6 @@ def get_path(self) -> List[TreeNode]: current = current.parent return path - def get_all_faces(self) -> List[TreeNode]: - """Get all TopoFace nodes under this node""" - faces = [] - if self.type == "TopoFace": - faces.append(self) - for child in self.children: - faces.extend(child.get_all_faces()) - return faces - def find_nodes(self, filter_func: Callable[[TreeNode], bool]) -> List[TreeNode]: """Find all nodes matching the filter function""" matches = [] @@ -91,8 +82,16 @@ def find_nodes(self, filter_func: Callable[[TreeNode], bool]) -> List[TreeNode]: matches.extend(child.find_nodes(filter_func)) return matches + def get_uuid_to_face(self) -> Dict[str, TreeNode]: + uuid_to_face = {} + if self.type == NodeType.TopoFace: + uuid_to_face[self.uuid] = self + for child in self.children: + uuid_to_face.update(child.get_uuid_to_face()) + return uuid_to_face + def __repr__(self): - return f"TreeNode(type={self.type}, name={self.name})" + return f"TreeNode(type={self.type.value}, name={self.name})" class FilterExpression: @@ -241,8 +240,9 @@ def __init__(self, tree_json_path: str): with open(tree_json_path, "r", encoding="utf-8") as f: tree_data = json.load(f) - self.root = TreeNode.from_dict(tree_data) - self.all_faces = self.root.get_all_faces() + self.root: TreeNode = TreeNode.from_dict(tree_data) + self.uuid_to_face = self.root.get_uuid_to_face() + def find_nodes(self, filter_expr: FilterExpression) -> List[TreeNode]: """ @@ -261,13 +261,5 @@ def find_nodes(self, filter_expr: FilterExpression) -> List[TreeNode]: return self.root.find_nodes(lambda node: filter_expr(node)) def get_all_faces(self) -> List[TreeNode]: - """ - Get all face nodes in the tree - - Returns - ------- - List[TreeNode] - List of all TopoFace nodes - """ - return self.all_faces + return list(self.uuid_to_face.values()) From a867bfe8de3c1704577d496997b5b4ffb668ce84 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Tue, 28 Oct 2025 12:44:41 -0400 Subject: [PATCH 06/15] use selection instead of filter --- flow360/component/geometry.py | 53 +++++++++----- flow360/component/geometry_tree.py | 111 ++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 19 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index fa4d48e26..254744d40 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -578,6 +578,27 @@ def __setitem__(self, key: str, value: Any): # ========== Tree-based face grouping methods ========== + @property + def tree_root(self): + """ + Get the root node of the geometry tree + + Returns + ------- + TreeNode + Root node of the geometry hierarchy tree + + Raises + ------ + Flow360ValueError + If geometry tree has not been loaded yet + """ + if self._geometry_tree is None: + raise Flow360ValueError( + "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" + ) + return self._geometry_tree.root + def load_geometry_tree(self, tree_json_path: str) -> None: """ Load Geometry hierarchy tree from JSON file @@ -597,21 +618,21 @@ def load_geometry_tree(self, tree_json_path: str) -> None: log.info(f"Loaded Geometry tree with {len(self._geometry_tree.all_faces)} faces") - def create_face_group(self, name: str, filter: FilterExpression) -> List[str]: + def create_face_group(self, name: str, selection: List[TreeNode]) -> List[str]: """ - Create a face group based on Geometry tree filtering + Create a face group based on explicit selection of tree nodes - This method filters nodes in the Geometry hierarchy tree and groups all faces - under matching nodes. If any faces already belong to another group, they - will be reassigned to the new group. + This method groups all faces under the selected nodes in the Geometry hierarchy tree. + If any faces already belong to another group, they will be reassigned to the new group. Parameters ---------- name : str Name of the face group - filter : FilterExpression - Filter expression to match nodes in the tree. Use Type, Name operators - to build filter expressions. + selection : List[TreeNode] + List of tree nodes to include in the group. All faces under these nodes + (recursively) will be added to the group. Typically obtained from + tree_root.search() method. Returns ------- @@ -620,25 +641,21 @@ def create_face_group(self, name: str, filter: FilterExpression) -> List[str]: Examples -------- - >>> from flow360.component.geometry_tree import Type, Name, NodeType - >>> geometry.create_face_group( - ... name="wing", - ... filter=(Type == NodeType.FRMFeature) & Name.contains("wing") - ... ) + >>> from flow360.component.geometry_tree import NodeType + >>> # Search for nodes and create face group from selection + >>> wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*") + >>> geometry.create_face_group(name="wing", selection=wing_nodes) """ if self._geometry_tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - # Find matching nodes using the tree - matching_nodes = self._geometry_tree.find_nodes(filter) - - # Collect faces from matching nodes + # Collect faces from selected nodes group_faces = [] new_face_uuids = set() - for node in matching_nodes: + for node in selection: faces = node.get_all_faces() for face in faces: if face.uuid: diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index 085002c86..657f8db60 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -25,7 +25,7 @@ class NodeType(Enum): TopoConnex = "TopoConnex" TopoShell = "TopoShell" TopoFace = "TopoFace" - TopoFacePointer = "TopoFacePointer" + TopoFacePointer = "TopoFacePointer" # References to TopoFace nodes class TreeNode: @@ -90,6 +90,103 @@ def get_uuid_to_face(self) -> Dict[str, TreeNode]: uuid_to_face.update(child.get_uuid_to_face()) return uuid_to_face + def get_all_faces(self) -> List[TreeNode]: + """ + Recursively collect all TopoFace and TopoFacePointer nodes in the subtree + + TopoFacePointer nodes are references to actual TopoFace nodes and are collected + alongside TopoFace nodes. Both have Flow360UUID attributes that can be used + for face grouping. + + Returns + ------- + List[TreeNode] + List of all TopoFace and TopoFacePointer nodes under this node + """ + faces = [] + if self.type == NodeType.TopoFace or self.type == NodeType.TopoFacePointer: + faces.append(self) + for child in self.children: + faces.extend(child.get_all_faces()) + return faces + + def search( + self, + type: Optional[Union[NodeType, str]] = None, + name: Optional[str] = None, + color: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None + ) -> List[TreeNode]: + """ + Search for nodes in the subtree matching the given criteria. + + Supports wildcard matching for name using '*' character. + All criteria are ANDed together. + + Parameters + ---------- + type : Optional[Union[NodeType, str]] + Node type to match (e.g., NodeType.FRMFeature) + name : Optional[str] + Name pattern to match. Supports wildcards: + - "*wing*" matches any name containing "wing" + - "wing*" matches any name starting with "wing" + - "*wing" matches any name ending with "wing" + - "wing" matches exact name "wing" + color : Optional[str] + Color string to match + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match + + Returns + ------- + List[TreeNode] + List of matching nodes in the subtree + + Examples + -------- + >>> # Search for all FRMFeature nodes with "wing" in the name + >>> nodes = root.search(type=NodeType.FRMFeature, name="*wing*") + >>> + >>> # Search for nodes with specific attribute + >>> nodes = root.search(attributes={"Flow360UUID": "abc123"}) + """ + import fnmatch + + matches = [] + + # Check if this node matches all criteria + match = True + + if type is not None: + target_type = type.value if isinstance(type, NodeType) else type + if self.type.value != target_type: + match = False + + if match and name is not None: + # Use fnmatch for wildcard matching (case-insensitive) + if not fnmatch.fnmatch(self.name.lower(), name.lower()): + match = False + + if match and color is not None: + if self.color != color: + match = False + + if match and attributes is not None: + for key, value in attributes.items(): + if self.attributes.get(key) != value: + match = False + break + + if match: + matches.append(self) + + # Recursively search children + for child in self.children: + matches.extend(child.search(type=type, name=name, color=color, attributes=attributes)) + + return matches + def __repr__(self): return f"TreeNode(type={self.type.value}, name={self.name})" @@ -262,4 +359,16 @@ def find_nodes(self, filter_expr: FilterExpression) -> List[TreeNode]: def get_all_faces(self) -> List[TreeNode]: return list(self.uuid_to_face.values()) + + @property + def all_faces(self) -> List[TreeNode]: + """ + Get all face nodes in the tree + + Returns + ------- + List[TreeNode] + List of all TopoFace nodes in the tree + """ + return self.get_all_faces() From 63f159c1ab6cdf56688bac6966521e246757a4f4 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Wed, 29 Oct 2025 11:43:49 -0400 Subject: [PATCH 07/15] remove old design code --- flow360/component/__init__.py | 8 - flow360/component/geometry.py | 21 ++- flow360/component/geometry_tree.py | 253 +++++++---------------------- 3 files changed, 72 insertions(+), 210 deletions(-) diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index a1c2bd21c..bf88b1c55 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -1,23 +1,15 @@ """Flow360 Component Module""" from flow360.component.geometry_tree import ( - AttributeQuery, - FilterExpression, GeometryTree, - Name, NodeType, TreeNode, - Type, ) __all__ = [ - "AttributeQuery", - "FilterExpression", "GeometryTree", - "Name", "NodeType", "TreeNode", - "Type", ] diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 254744d40..1df5c0e28 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -18,7 +18,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi -from flow360.component.geometry_tree import FilterExpression, GeometryTree, TreeNode +from flow360.component.geometry_tree import GeometryTree, TreeNode from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( AssetMetaBaseModelV2, @@ -239,7 +239,7 @@ class Geometry(AssetBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._geometry_tree: Optional[GeometryTree] = None + self._tree: Optional[GeometryTree] = None self._face_to_group: Dict[str, str] = {} # face_uuid -> group_name self._face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces @@ -593,11 +593,11 @@ def tree_root(self): Flow360ValueError If geometry tree has not been loaded yet """ - if self._geometry_tree is None: + if self._tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - return self._geometry_tree.root + return self._tree.root def load_geometry_tree(self, tree_json_path: str) -> None: """ @@ -613,10 +613,13 @@ def load_geometry_tree(self, tree_json_path: str) -> None: >>> geometry = Geometry.from_cloud("geom_id") >>> geometry.load_geometry_tree("tree.json") """ - self._geometry_tree = GeometryTree(tree_json_path) + self._tree = GeometryTree(tree_json_path) ## create default face gruoping by body - log.info(f"Loaded Geometry tree with {len(self._geometry_tree.all_faces)} faces") + log.info(f"Loaded Geometry tree with {len(self._tree.all_faces)} faces") + + # def group_faces_by_body(self) -> None: + def create_face_group(self, name: str, selection: List[TreeNode]) -> List[str]: """ @@ -646,7 +649,7 @@ def create_face_group(self, name: str, selection: List[TreeNode]) -> List[str]: >>> wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*") >>> geometry.create_face_group(name="wing", selection=wing_nodes) """ - if self._geometry_tree is None: + if self._tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) @@ -714,12 +717,12 @@ def print_face_grouping_stats(self) -> None: - tail: 18 faces ================================= """ - if self._geometry_tree is None: + if self._tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) - total_faces = len(self._geometry_tree.all_faces) + total_faces = len(self._tree.all_faces) faces_in_groups = sum(len(faces) for faces in self._face_groups.values()) print(f"\n=== Face Grouping Statistics ===") diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index 657f8db60..e1e63c7a8 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -10,6 +10,7 @@ FLOW360_UUID_ATTRIBUTE_KEY = "Flow360UUID" + class NodeType(Enum): """Geometry tree node types""" @@ -35,14 +36,16 @@ def __init__( self, node_type: NodeType, name: str = "", - color: str = "", + colorRGB: str = "", + material: str = "", attributes: Dict[str, str] = {}, children: List[TreeNode] = [], ): self.type = node_type self.name = name - self.attributes = attributes - self.color = color + self.attributes = attributes + self.colorRGB = colorRGB + self.material = material self.children = children self.parent: Optional[TreeNode] = None self.uuid = None @@ -53,13 +56,19 @@ def __init__( @classmethod def from_dict(cls, data: Dict[str, Any]) -> TreeNode: - """Create TreeNode from dictionary""" + """ + Create TreeNode from dictionary + + Supports both old format (color) and new format (colorRGB) for backward compatibility + """ children = [cls.from_dict(child) for child in data.get("children", [])] + node = cls( node_type=NodeType[data.get("type")], - name=data.get("name"), - color=data.get("color"), - attributes=data.get("attributes"), + name=data.get("name", ""), + colorRGB=data.get("colorRGB"), + material=data.get("material", ""), + attributes=data.get("attributes", {}), children=children, ) return node @@ -73,15 +82,6 @@ def get_path(self) -> List[TreeNode]: current = current.parent return path - def find_nodes(self, filter_func: Callable[[TreeNode], bool]) -> List[TreeNode]: - """Find all nodes matching the filter function""" - matches = [] - if filter_func(self): - matches.append(self) - for child in self.children: - matches.extend(child.find_nodes(filter_func)) - return matches - def get_uuid_to_face(self) -> Dict[str, TreeNode]: uuid_to_face = {} if self.type == NodeType.TopoFace: @@ -93,11 +93,11 @@ def get_uuid_to_face(self) -> Dict[str, TreeNode]: def get_all_faces(self) -> List[TreeNode]: """ Recursively collect all TopoFace and TopoFacePointer nodes in the subtree - + TopoFacePointer nodes are references to actual TopoFace nodes and are collected alongside TopoFace nodes. Both have Flow360UUID attributes that can be used for face grouping. - + Returns ------- List[TreeNode] @@ -114,15 +114,16 @@ def search( self, type: Optional[Union[NodeType, str]] = None, name: Optional[str] = None, - color: Optional[str] = None, - attributes: Optional[Dict[str, str]] = None + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, ) -> List[TreeNode]: """ Search for nodes in the subtree matching the given criteria. - + Supports wildcard matching for name using '*' character. All criteria are ANDed together. - + Parameters ---------- type : Optional[Union[NodeType, str]] @@ -133,195 +134,82 @@ def search( - "wing*" matches any name starting with "wing" - "*wing" matches any name ending with "wing" - "wing" matches exact name "wing" - color : Optional[str] - Color string to match + colorRGB : Optional[str] + RGB color string to match (e.g., "255,0,0" for red) + material : Optional[str] + Material name to match. Supports wildcard matching like name parameter. attributes : Optional[Dict[str, str]] Dictionary of attribute key-value pairs to match - + Returns ------- List[TreeNode] List of matching nodes in the subtree - + Examples -------- >>> # Search for all FRMFeature nodes with "wing" in the name >>> nodes = root.search(type=NodeType.FRMFeature, name="*wing*") - >>> + >>> >>> # Search for nodes with specific attribute >>> nodes = root.search(attributes={"Flow360UUID": "abc123"}) + >>> + >>> # Search for nodes with specific material + >>> nodes = root.search(material="aluminum") """ import fnmatch - + matches = [] - + # Check if this node matches all criteria match = True - + if type is not None: target_type = type.value if isinstance(type, NodeType) else type if self.type.value != target_type: match = False - + if match and name is not None: # Use fnmatch for wildcard matching (case-insensitive) if not fnmatch.fnmatch(self.name.lower(), name.lower()): match = False - - if match and color is not None: - if self.color != color: + + if match and colorRGB is not None: + if self.colorRGB != colorRGB: match = False - + + if match and material is not None: + # Support wildcard matching for material + if not fnmatch.fnmatch(self.material.lower(), material.lower()): + match = False + if match and attributes is not None: for key, value in attributes.items(): if self.attributes.get(key) != value: match = False break - + if match: matches.append(self) - + # Recursively search children for child in self.children: - matches.extend(child.search(type=type, name=name, color=color, attributes=attributes)) - + matches.extend( + child.search( + type=type, + name=name, + colorRGB=colorRGB, + material=material, + attributes=attributes, + ) + ) + return matches def __repr__(self): return f"TreeNode(type={self.type.value}, name={self.name})" -class FilterExpression: - """Base class for filter expressions""" - - def __call__(self, node: TreeNode) -> bool: - raise NotImplementedError - - def __and__(self, other: FilterExpression) -> AndExpression: - return AndExpression(self, other) - - def __or__(self, other: FilterExpression) -> OrExpression: - return OrExpression(self, other) - - def __invert__(self) -> NotExpression: - return NotExpression(self) - - -class AndExpression(FilterExpression): - """Logical AND of two filter expressions""" - - def __init__(self, left: FilterExpression, right: FilterExpression): - self.left = left - self.right = right - - def __call__(self, node: TreeNode) -> bool: - return self.left(node) and self.right(node) - - -class OrExpression(FilterExpression): - """Logical OR of two filter expressions""" - - def __init__(self, left: FilterExpression, right: FilterExpression): - self.left = left - self.right = right - - def __call__(self, node: TreeNode) -> bool: - return self.left(node) or self.right(node) - - -class NotExpression(FilterExpression): - """Logical NOT of a filter expression""" - - def __init__(self, expr: FilterExpression): - self.expr = expr - - def __call__(self, node: TreeNode) -> bool: - return not self.expr(node) - - -class TypeQuery: - """Query builder for node type""" - - def __eq__(self, node_type: Union[NodeType, str]) -> FilterExpression: - if isinstance(node_type, NodeType): - type_str = node_type.value - else: - type_str = node_type - - class TypeFilter(FilterExpression): - def __call__(self, node: TreeNode) -> bool: - return node.type == type_str - - return TypeFilter() - - -class NameQuery: - """Query builder for node name""" - - def contains(self, substring: str) -> FilterExpression: - class NameContainsFilter(FilterExpression): - def __call__(self, node: TreeNode) -> bool: - return substring.lower() in node.name.lower() - - return NameContainsFilter() - - def __eq__(self, name: str) -> FilterExpression: - class NameEqualsFilter(FilterExpression): - def __call__(self, node: TreeNode) -> bool: - return node.name == name - - return NameEqualsFilter() - - def startswith(self, prefix: str) -> FilterExpression: - class NameStartsWithFilter(FilterExpression): - def __call__(self, node: TreeNode) -> bool: - return node.name.startswith(prefix) - - return NameStartsWithFilter() - - def endswith(self, suffix: str) -> FilterExpression: - class NameEndsWithFilter(FilterExpression): - def __call__(self, node: TreeNode) -> bool: - return node.name.endswith(suffix) - - return NameEndsWithFilter() - - -class AttributeQuery: - """Query builder for node attributes""" - - def __init__(self, attr_key: str): - self.attr_key = attr_key - - def __eq__(self, value: str) -> FilterExpression: - class AttributeEqualsFilter(FilterExpression): - def __init__(self, key: str, val: str): - self.key = key - self.val = val - - def __call__(self, node: TreeNode) -> bool: - return node.attributes.get(self.key) == self.val - - return AttributeEqualsFilter(self.attr_key, value) - - def contains(self, substring: str) -> FilterExpression: - class AttributeContainsFilter(FilterExpression): - def __init__(self, key: str, substr: str): - self.key = key - self.substr = substr - - def __call__(self, node: TreeNode) -> bool: - attr_value = node.attributes.get(self.key, "") - return self.substr in attr_value - - return AttributeContainsFilter(self.attr_key, substring) - - -# Singleton query objects -Type = TypeQuery() -Name = NameQuery() - - class GeometryTree: """Pure tree structure representing Geometry hierarchy""" @@ -340,35 +228,14 @@ def __init__(self, tree_json_path: str): self.root: TreeNode = TreeNode.from_dict(tree_data) self.uuid_to_face = self.root.get_uuid_to_face() - - def find_nodes(self, filter_expr: FilterExpression) -> List[TreeNode]: - """ - Find all nodes matching the filter expression - - Parameters - ---------- - filter_expr : FilterExpression - Filter expression to match nodes - - Returns - ------- - List[TreeNode] - List of matching nodes - """ - return self.root.find_nodes(lambda node: filter_expr(node)) - - def get_all_faces(self) -> List[TreeNode]: - return list(self.uuid_to_face.values()) - @property def all_faces(self) -> List[TreeNode]: """ Get all face nodes in the tree - + Returns ------- List[TreeNode] List of all TopoFace nodes in the tree """ - return self.get_all_faces() - + return list(self.uuid_to_face.values()) From 5c83ee9d48d1b9d43b348b08d2f308d8f177cf1d Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Wed, 29 Oct 2025 15:24:28 -0400 Subject: [PATCH 08/15] added children() and NodeCollection class --- flow360/component/__init__.py | 4 + flow360/component/geometry.py | 62 ++++- flow360/component/geometry_tree.py | 354 ++++++++++++++++++++++++----- 3 files changed, 357 insertions(+), 63 deletions(-) diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index bf88b1c55..e023e4848 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -2,14 +2,18 @@ from flow360.component.geometry_tree import ( GeometryTree, + NodeCollection, NodeType, TreeNode, + TreeSearch, ) __all__ = [ "GeometryTree", + "NodeCollection", "NodeType", "TreeNode", + "TreeSearch", ] diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 1df5c0e28..6b52309b5 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -18,7 +18,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi -from flow360.component.geometry_tree import GeometryTree, TreeNode +from flow360.component.geometry_tree import GeometryTree, NodeCollection, TreeNode, TreeSearch from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( AssetMetaBaseModelV2, @@ -618,10 +618,13 @@ def load_geometry_tree(self, tree_json_path: str) -> None: log.info(f"Loaded Geometry tree with {len(self._tree.all_faces)} faces") - # def group_faces_by_body(self) -> None: + #body_nodes = self.tree_root.search(type = NodeType.RiBrepModel) - def create_face_group(self, name: str, selection: List[TreeNode]) -> List[str]: + + def create_face_group( + self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + ) -> List[str]: """ Create a face group based on explicit selection of tree nodes @@ -632,10 +635,14 @@ def create_face_group(self, name: str, selection: List[TreeNode]) -> List[str]: ---------- name : str Name of the face group - selection : List[TreeNode] - List of tree nodes to include in the group. All faces under these nodes - (recursively) will be added to the group. Typically obtained from - tree_root.search() method. + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + Can be one of: + - TreeSearch instance (returned from tree_root.search()) - will be executed internally + - NodeCollection (returned from tree_root.children()) - nodes will be extracted + - Single TreeNode - all faces under this node will be included + - List of TreeNode instances - all faces under these nodes will be included + + All faces under the selected nodes (recursively) will be added to the group. Returns ------- @@ -645,20 +652,53 @@ def create_face_group(self, name: str, selection: List[TreeNode]) -> List[str]: Examples -------- >>> from flow360.component.geometry_tree import NodeType - >>> # Search for nodes and create face group from selection - >>> wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*") - >>> geometry.create_face_group(name="wing", selection=wing_nodes) + >>> + >>> # Using TreeSearch (recommended - captures intent declaratively) + >>> geometry.create_face_group( + ... name="wing", + ... selection=geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*") + ... ) + >>> + >>> # Using children() chaining (fluent navigation with exact matching) + >>> geometry.create_face_group( + ... name="body", + ... selection=geometry.tree_root.children().children().children( + ... type=NodeType.FRMFeatureBasedEntity + ... ).children().children(type=NodeType.FRMFeature, name="body_main") + ... ) + >>> + >>> # Using a single TreeNode directly + >>> wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="wing").execute() + >>> geometry.create_face_group(name="single_wing", selection=wing_nodes[0]) + >>> + >>> # Using a list of TreeNodes + >>> all_wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*").execute() + >>> geometry.create_face_group(name="all_wings", selection=all_wing_nodes) """ if self._tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) + # Handle different selection types + if isinstance(selection, TreeSearch): + # Execute TreeSearch to get nodes + selected_nodes = selection.execute() + elif isinstance(selection, NodeCollection): + # Extract nodes from NodeCollection + selected_nodes = selection.nodes + elif isinstance(selection, TreeNode): + # Wrap single node in a list + selected_nodes = [selection] + else: + # Already a list of nodes + selected_nodes = selection + # Collect faces from selected nodes group_faces = [] new_face_uuids = set() - for node in selection: + for node in selected_nodes: faces = node.get_all_faces() for face in faces: if face.uuid: diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index e1e63c7a8..d87b82498 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -46,12 +46,12 @@ def __init__( self.attributes = attributes self.colorRGB = colorRGB self.material = material - self.children = children + self._children = children # Renamed to avoid conflict with children() method self.parent: Optional[TreeNode] = None self.uuid = None if FLOW360_UUID_ATTRIBUTE_KEY in attributes: self.uuid = attributes[FLOW360_UUID_ATTRIBUTE_KEY] - for child in self.children: + for child in self._children: child.parent = self @classmethod @@ -86,7 +86,7 @@ def get_uuid_to_face(self) -> Dict[str, TreeNode]: uuid_to_face = {} if self.type == NodeType.TopoFace: uuid_to_face[self.uuid] = self - for child in self.children: + for child in self._children: uuid_to_face.update(child.get_uuid_to_face()) return uuid_to_face @@ -106,7 +106,7 @@ def get_all_faces(self) -> List[TreeNode]: faces = [] if self.type == NodeType.TopoFace or self.type == NodeType.TopoFacePointer: faces.append(self) - for child in self.children: + for child in self._children: faces.extend(child.get_all_faces()) return faces @@ -117,9 +117,13 @@ def search( colorRGB: Optional[str] = None, material: Optional[str] = None, attributes: Optional[Dict[str, str]] = None, - ) -> List[TreeNode]: + ) -> "TreeSearch": """ - Search for nodes in the subtree matching the given criteria. + Create a deferred search operation for nodes in the subtree matching the given criteria. + + This method returns a TreeSearch instance that captures the search criteria + but does not execute the search immediately. The search is executed when + the TreeSearch instance is used (e.g., in create_face_group()). Supports wildcard matching for name using '*' character. All criteria are ANDed together. @@ -143,71 +147,317 @@ def search( Returns ------- - List[TreeNode] - List of matching nodes in the subtree + TreeSearch + A TreeSearch instance that can be executed later Examples -------- - >>> # Search for all FRMFeature nodes with "wing" in the name - >>> nodes = root.search(type=NodeType.FRMFeature, name="*wing*") + >>> # Create a search for FRMFeature nodes with "wing" in the name + >>> wing_search = root.search(type=NodeType.FRMFeature, name="*wing*") >>> - >>> # Search for nodes with specific attribute - >>> nodes = root.search(attributes={"Flow360UUID": "abc123"}) + >>> # Pass to create_face_group (will execute internally) + >>> geometry.create_face_group(name="wing", selection=wing_search) >>> - >>> # Search for nodes with specific material - >>> nodes = root.search(material="aluminum") + >>> # Or execute manually to get nodes + >>> wing_nodes = wing_search.execute() + """ + return TreeSearch( + node=self, + type=type, + name=name, + colorRGB=colorRGB, + material=material, + attributes=attributes, + ) + + def children( + self, + type: Optional[Union[NodeType, str]] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ) -> "NodeCollection": + """ + Get children of this node, optionally filtered by exact criteria. + + This method filters only the direct children (not the entire subtree) using + exact matching - no wildcards or patterns. It's designed for certain navigation + like clicking through folders in a file system. + + For pattern matching and recursive search, use .search() instead. + + Returns a NodeCollection that supports further chaining via .children() calls. + + Parameters + ---------- + type : Optional[Union[NodeType, str]] + Node type to filter by (exact match, e.g., NodeType.FRMFeature) + name : Optional[str] + Exact name to match (no wildcards) + colorRGB : Optional[str] + Exact RGB color string to match + material : Optional[str] + Exact material name to match (no wildcards) + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match (exact matches) + + Returns + ------- + NodeCollection + Collection of direct child nodes matching the criteria + + Examples + -------- + >>> # Get all direct children + >>> all_children = node.children() + >>> + >>> # Get children of specific type (exact match) + >>> features = node.children(type=NodeType.FRMFeature) + >>> + >>> # Chain to navigate tree structure with certainty + >>> result = root.children().children().children( + ... type=NodeType.FRMFeature, + ... name="body_main" # Exact name, no wildcards + ... ) + """ + filtered_children = [] + + for child in self._children: + match = True + + # Exact type matching + if type is not None: + target_type = type.value if isinstance(type, NodeType) else type + if child.type.value != target_type: + match = False + + # Exact name matching (no wildcards) + if match and name is not None: + if child.name != name: + match = False + + # Exact colorRGB matching + if match and colorRGB is not None: + if child.colorRGB != colorRGB: + match = False + + # Exact material matching (no wildcards) + if match and material is not None: + if child.material != material: + match = False + + # Exact attribute matching + if match and attributes is not None: + for key, value in attributes.items(): + if child.attributes.get(key) != value: + match = False + break + + if match: + filtered_children.append(child) + + return NodeCollection(filtered_children) + + def __repr__(self): + return f"TreeNode(type={self.type.value}, name={self.name})" + + +class NodeCollection: + """ + A collection of TreeNode objects that supports method chaining. + + This class wraps one or more TreeNode objects and provides a .children() + method to enable fluent tree navigation patterns like: + root.children().children().children(type=NodeType.FRMFeature) + """ + + def __init__(self, nodes: List[TreeNode]): + """ + Initialize a NodeCollection with a list of nodes. + + Parameters + ---------- + nodes : List[TreeNode] + List of TreeNode objects to wrap + """ + self._nodes = nodes if isinstance(nodes, list) else [nodes] + + @property + def nodes(self) -> List[TreeNode]: + """Get the list of nodes in this collection""" + return self._nodes + + def children( + self, + type: Optional[Union[NodeType, str]] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ) -> "NodeCollection": + """ + Get all children from all nodes in this collection, optionally filtered by exact criteria. + + Uses exact matching only (no wildcards or patterns) for certain navigation. + This enables chaining like: collection.children().children(type=...) + + For pattern matching, use .search() on individual nodes instead. + + Parameters + ---------- + type : Optional[Union[NodeType, str]] + Node type to filter by (exact match) + name : Optional[str] + Exact name to match (no wildcards) + colorRGB : Optional[str] + Exact RGB color string to match + material : Optional[str] + Exact material name to match (no wildcards) + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match (exact matches) + + Returns + ------- + NodeCollection + New collection containing children from all nodes + """ + all_children = [] + for node in self._nodes: + # Use the TreeNode.children() method which handles exact matching + child_collection = node.children( + type=type, name=name, colorRGB=colorRGB, material=material, attributes=attributes + ) + all_children.extend(child_collection.nodes) + + return NodeCollection(all_children) + + def __len__(self) -> int: + """Return the number of nodes in this collection""" + return len(self._nodes) + + def __iter__(self): + """Make the collection iterable""" + return iter(self._nodes) + + def __getitem__(self, index: int) -> TreeNode: + """Allow indexing into the collection""" + return self._nodes[index] + + def __repr__(self): + return f"NodeCollection({len(self._nodes)} nodes)" + + +class TreeSearch: + """ + Represents a deferred tree search operation. + + This class captures search criteria and the node from which to search, + but does not execute the search until explicitly requested via execute(). + This allows for lazy evaluation and cleaner API usage. + """ + + def __init__( + self, + node: TreeNode, + type: Optional[Union[NodeType, str]] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ): + """ + Initialize a TreeSearch with search criteria. + + Parameters + ---------- + node : TreeNode + The node from which to start the search (searches its subtree) + type : Optional[Union[NodeType, str]] + Node type to match (e.g., NodeType.FRMFeature) + name : Optional[str] + Name pattern to match. Supports wildcards (e.g., "*wing*") + colorRGB : Optional[str] + RGB color string to match (e.g., "255,0,0") + material : Optional[str] + Material name to match. Supports wildcards. + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match + """ + self.node = node + self.type = type + self.name = name + self.colorRGB = colorRGB + self.material = material + self.attributes = attributes + + def execute(self) -> List[TreeNode]: + """ + Execute the search and return matching nodes. + + Returns + ------- + List[TreeNode] + List of nodes matching the search criteria """ import fnmatch matches = [] - # Check if this node matches all criteria - match = True + def search_recursive(current_node: TreeNode): + # Check if this node matches all criteria + match = True - if type is not None: - target_type = type.value if isinstance(type, NodeType) else type - if self.type.value != target_type: - match = False - - if match and name is not None: - # Use fnmatch for wildcard matching (case-insensitive) - if not fnmatch.fnmatch(self.name.lower(), name.lower()): - match = False + if self.type is not None: + target_type = self.type.value if isinstance(self.type, NodeType) else self.type + if current_node.type.value != target_type: + match = False - if match and colorRGB is not None: - if self.colorRGB != colorRGB: - match = False + if match and self.name is not None: + # Use fnmatch for wildcard matching (case-insensitive) + if not fnmatch.fnmatch(current_node.name.lower(), self.name.lower()): + match = False - if match and material is not None: - # Support wildcard matching for material - if not fnmatch.fnmatch(self.material.lower(), material.lower()): - match = False + if match and self.colorRGB is not None: + if current_node.colorRGB != self.colorRGB: + match = False - if match and attributes is not None: - for key, value in attributes.items(): - if self.attributes.get(key) != value: + if match and self.material is not None: + # Support wildcard matching for material + if not fnmatch.fnmatch(current_node.material.lower(), self.material.lower()): match = False - break - - if match: - matches.append(self) - - # Recursively search children - for child in self.children: - matches.extend( - child.search( - type=type, - name=name, - colorRGB=colorRGB, - material=material, - attributes=attributes, - ) - ) + if match and self.attributes is not None: + for key, value in self.attributes.items(): + if current_node.attributes.get(key) != value: + match = False + break + + if match: + matches.append(current_node) + + # Recursively search children + for child in current_node._children: + search_recursive(child) + + search_recursive(self.node) return matches def __repr__(self): - return f"TreeNode(type={self.type.value}, name={self.name})" + criteria = [] + if self.type is not None: + type_str = self.type.value if isinstance(self.type, NodeType) else self.type + criteria.append(f"type={type_str}") + if self.name is not None: + criteria.append(f"name='{self.name}'") + if self.colorRGB is not None: + criteria.append(f"colorRGB='{self.colorRGB}'") + if self.material is not None: + criteria.append(f"material='{self.material}'") + if self.attributes is not None: + criteria.append(f"attributes={self.attributes}") + criteria_str = ", ".join(criteria) + return f"TreeSearch({criteria_str})" class GeometryTree: From 677f64abe3d7ee265bf88b2d877952209a913c3d Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Wed, 29 Oct 2025 15:35:30 -0400 Subject: [PATCH 09/15] type can only be in NodeType --- flow360/component/geometry_tree.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index d87b82498..e089a1761 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -112,7 +112,7 @@ def get_all_faces(self) -> List[TreeNode]: def search( self, - type: Optional[Union[NodeType, str]] = None, + type: Optional[NodeType] = None, name: Optional[str] = None, colorRGB: Optional[str] = None, material: Optional[str] = None, @@ -130,7 +130,7 @@ def search( Parameters ---------- - type : Optional[Union[NodeType, str]] + type : Optional[NodeType] Node type to match (e.g., NodeType.FRMFeature) name : Optional[str] Name pattern to match. Supports wildcards: @@ -172,7 +172,7 @@ def search( def children( self, - type: Optional[Union[NodeType, str]] = None, + type: Optional[NodeType] = None, name: Optional[str] = None, colorRGB: Optional[str] = None, material: Optional[str] = None, @@ -191,7 +191,7 @@ def children( Parameters ---------- - type : Optional[Union[NodeType, str]] + type : Optional[NodeType] Node type to filter by (exact match, e.g., NodeType.FRMFeature) name : Optional[str] Exact name to match (no wildcards) @@ -228,8 +228,7 @@ def children( # Exact type matching if type is not None: - target_type = type.value if isinstance(type, NodeType) else type - if child.type.value != target_type: + if child.type != type: match = False # Exact name matching (no wildcards) @@ -290,7 +289,7 @@ def nodes(self) -> List[TreeNode]: def children( self, - type: Optional[Union[NodeType, str]] = None, + type: Optional[NodeType] = None, name: Optional[str] = None, colorRGB: Optional[str] = None, material: Optional[str] = None, @@ -306,7 +305,7 @@ def children( Parameters ---------- - type : Optional[Union[NodeType, str]] + type : Optional[NodeType] Node type to filter by (exact match) name : Optional[str] Exact name to match (no wildcards) @@ -360,7 +359,7 @@ class TreeSearch: def __init__( self, node: TreeNode, - type: Optional[Union[NodeType, str]] = None, + type: Optional[NodeType] = None, name: Optional[str] = None, colorRGB: Optional[str] = None, material: Optional[str] = None, @@ -373,7 +372,7 @@ def __init__( ---------- node : TreeNode The node from which to start the search (searches its subtree) - type : Optional[Union[NodeType, str]] + type : Optional[NodeType] Node type to match (e.g., NodeType.FRMFeature) name : Optional[str] Name pattern to match. Supports wildcards (e.g., "*wing*") @@ -409,8 +408,7 @@ def search_recursive(current_node: TreeNode): match = True if self.type is not None: - target_type = self.type.value if isinstance(self.type, NodeType) else self.type - if current_node.type.value != target_type: + if current_node.type != self.type: match = False if match and self.name is not None: @@ -446,8 +444,7 @@ def search_recursive(current_node: TreeNode): def __repr__(self): criteria = [] if self.type is not None: - type_str = self.type.value if isinstance(self.type, NodeType) else self.type - criteria.append(f"type={type_str}") + criteria.append(f"type={self.type.value}") if self.name is not None: criteria.append(f"name='{self.name}'") if self.colorRGB is not None: From f6d81ad23cedc40876249d0c3903e8356bc454e3 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Thu, 30 Oct 2025 13:48:14 -0400 Subject: [PATCH 10/15] wip --- flow360/component/geometry.py | 52 ++++++++++++++--------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 6b52309b5..eb028a173 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -18,7 +18,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi -from flow360.component.geometry_tree import GeometryTree, NodeCollection, TreeNode, TreeSearch +from flow360.component.geometry_tree import GeometryTree, NodeCollection, TreeNode, TreeSearch, NodeType from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( AssetMetaBaseModelV2, @@ -240,8 +240,8 @@ class Geometry(AssetBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tree: Optional[GeometryTree] = None - self._face_to_group: Dict[str, str] = {} # face_uuid -> group_name - self._face_groups: Dict[str, List[TreeNode]] = {} # group_name -> list of faces + self._face_to_group_name: Dict[str, str] = {} # face_uuid -> group_name + self._group_name_to_faces: Dict[str, List[TreeNode]] = {} # group_name -> list of faces @property def face_group_tag(self): @@ -618,9 +618,17 @@ def load_geometry_tree(self, tree_json_path: str) -> None: log.info(f"Loaded Geometry tree with {len(self._tree.all_faces)} faces") - #body_nodes = self.tree_root.search(type = NodeType.RiBrepModel) - + body_nodes = self.tree_root.search(type = NodeType.RiBrepModel).execute() + print("abc: All body nodes:") + for body_node in body_nodes: + print(body_node) + self.create_face_group( + name = body_node.name, + selection = body_node, + ) + print("abc: After the default face grouping by body is finished: ") + self.print_face_grouping_stats() def create_face_group( self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] @@ -706,40 +714,23 @@ def create_face_group( new_face_uuids.add(face.uuid) # Remove these faces from their previous groups - for group_name, faces in list(self._face_groups.items()): + for group_name, faces in list(self._group_name_to_faces.items()): if group_name != name: - self._face_groups[group_name] = [ + self._group_name_to_faces[group_name] = [ f for f in faces if f.uuid not in new_face_uuids ] # Update face-to-group mapping for uuid in new_face_uuids: - self._face_to_group[uuid] = name + self._face_to_group_name[uuid] = name # Store the group - self._face_groups[name] = group_faces + self._group_name_to_faces[name] = group_faces face_uuids = [face.uuid for face in group_faces if face.uuid] log.info(f"Created face group '{name}' with {len(face_uuids)} faces") return face_uuids - @property - def face_groups(self) -> Dict[str, int]: - """ - Get dictionary of face groups and their face counts - - Returns - ------- - Dict[str, int] - Dictionary mapping group names to number of faces in each group - - Examples - -------- - >>> geometry.face_groups - {'wing': 45, 'fuselage': 32, 'tail': 18} - """ - return {name: len(faces) for name, faces in self._face_groups.items()} - def print_face_grouping_stats(self) -> None: """ Print statistics about face grouping @@ -763,12 +754,11 @@ def print_face_grouping_stats(self) -> None: ) total_faces = len(self._tree.all_faces) - faces_in_groups = sum(len(faces) for faces in self._face_groups.values()) + faces_in_groups = sum(len(faces) for faces in self._group_name_to_faces.values()) print(f"\n=== Face Grouping Statistics ===") print(f"Total faces: {total_faces}") - print(f"Faces in groups: {faces_in_groups}") - print(f"\nFace groups ({len(self._face_groups)}):") - for group_name, faces in self._face_groups.items(): + print(f"\nFace groups ({len(self._group_name_to_faces)}):") + for group_name, faces in self._group_name_to_faces.items(): print(f" - {group_name}: {len(faces)} faces") - print("=" * 33) + print("="*33) From ba67af4d13ece5a8fa7e550d0ae5b789d4a9ff8f Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Thu, 30 Oct 2025 14:55:23 -0400 Subject: [PATCH 11/15] FaceGroup class and allow .add() --- flow360/component/__init__.py | 3 + flow360/component/geometry.py | 216 ++++++++++++++++++++++++++++------ 2 files changed, 183 insertions(+), 36 deletions(-) diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index e023e4848..fc2472cf1 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -16,5 +16,8 @@ "TreeSearch", ] +# Note: FaceGroup is available but not exported here to avoid circular imports. +# Import it directly when needed: from flow360.component.geometry import FaceGroup + diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index eb028a173..62a0ccc43 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -18,7 +18,13 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi -from flow360.component.geometry_tree import GeometryTree, NodeCollection, TreeNode, TreeSearch, NodeType +from flow360.component.geometry_tree import ( + GeometryTree, + NodeCollection, + NodeType, + TreeNode, + TreeSearch, +) from flow360.component.interfaces import GeometryInterface from flow360.component.resource_base import ( AssetMetaBaseModelV2, @@ -225,6 +231,82 @@ def submit(self, description="", progress_callback=None, run_async=False) -> Geo return Geometry.from_cloud(info.id) +class FaceGroup: + """ + Represents a face group that can be incrementally built by adding nodes. + + This class is returned by Geometry.create_face_group() and provides + an .add() method to add more TreeNode instances to the group. + + FaceGroup instances are maintained by the parent Geometry object. + """ + + def __init__(self, geometry: "Geometry", name: str): + """ + Initialize a FaceGroup. + + Parameters + ---------- + geometry : Geometry + The parent Geometry object that maintains this group + name : str + The name of this face group + """ + self._geometry = geometry + self._name = name + self._faces: List[TreeNode] = [] + + @property + def name(self) -> str: + """Get the name of this face group""" + return self._name + + @property + def faces(self) -> List[TreeNode]: + """Get the list of face nodes in this group""" + return self._faces + + @property + def face_count(self) -> int: + """Get the number of faces in this group""" + return len(self._faces) + + def add( + self, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + ) -> "FaceGroup": + """ + Add more nodes to this face group. + + This method delegates to the parent Geometry object to handle the addition. + + Parameters + ---------- + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + Nodes to add to this group. Can be: + - TreeSearch instance - will be executed internally + - NodeCollection - nodes will be extracted + - Single TreeNode - will be wrapped in a list + - List of TreeNode instances + + Returns + ------- + FaceGroup + Returns self for method chaining + + Examples + -------- + >>> wing_group = geometry.create_face_group(name="wing", selection=...) + >>> wing_group.add(geometry.tree_root.search(type=NodeType.FRMFeature, name="flap")) + >>> wing_group.add(another_node) + """ + # Delegate to Geometry to handle the addition + self._geometry._add_to_face_group(self, selection) + return self + + def __repr__(self): + return f"FaceGroup(name='{self._name}', faces={self.face_count})" + + class Geometry(AssetBase): """ Geometry component for workbench (simulation V2) @@ -240,8 +322,8 @@ class Geometry(AssetBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tree: Optional[GeometryTree] = None - self._face_to_group_name: Dict[str, str] = {} # face_uuid -> group_name - self._group_name_to_faces: Dict[str, List[TreeNode]] = {} # group_name -> list of faces + self._face_groups: Dict[str, FaceGroup] = {} # group_name -> FaceGroup instance + self._face_uuid_to_face_group: Dict[str, FaceGroup] = {} # face_uuid -> FaceGroup instance @property def face_group_tag(self): @@ -632,12 +714,14 @@ def load_geometry_tree(self, tree_json_path: str) -> None: def create_face_group( self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] - ) -> List[str]: + ) -> FaceGroup: """ Create a face group based on explicit selection of tree nodes This method groups all faces under the selected nodes in the Geometry hierarchy tree. If any faces already belong to another group, they will be reassigned to the new group. + + Returns a FaceGroup object that can be used to incrementally add more nodes. Parameters ---------- @@ -654,82 +738,142 @@ def create_face_group( Returns ------- - List[str] - List of face UUIDs in the created group + FaceGroup + A FaceGroup object that can be used to add more nodes via .add() method Examples -------- >>> from flow360.component.geometry_tree import NodeType >>> >>> # Using TreeSearch (recommended - captures intent declaratively) - >>> geometry.create_face_group( + >>> wing_group = geometry.create_face_group( ... name="wing", ... selection=geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*") ... ) >>> >>> # Using children() chaining (fluent navigation with exact matching) - >>> geometry.create_face_group( + >>> body_group = geometry.create_face_group( ... name="body", ... selection=geometry.tree_root.children().children().children( ... type=NodeType.FRMFeatureBasedEntity ... ).children().children(type=NodeType.FRMFeature, name="body_main") ... ) >>> - >>> # Using a single TreeNode directly - >>> wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="wing").execute() - >>> geometry.create_face_group(name="single_wing", selection=wing_nodes[0]) - >>> - >>> # Using a list of TreeNodes - >>> all_wing_nodes = geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*").execute() - >>> geometry.create_face_group(name="all_wings", selection=all_wing_nodes) + >>> # Incrementally add more nodes to the group + >>> body_group.add( + ... geometry.tree_root.children().children().children( + ... type=NodeType.FRMFeatureBasedEntity + ... ).children().children(type=NodeType.FRMFeature, name="body_cut") + ... ) """ if self._tree is None: raise Flow360ValueError( "Geometry tree not loaded. Call load_geometry_tree() first with path to tree.json" ) + # Get or create FaceGroup + if name not in self._face_groups: + face_group = FaceGroup(self, name) + self._face_groups[name] = face_group + log.info(f"Created face group '{name}'") + else: + face_group = self._face_groups[name] + log.info(f"Using existing face group '{name}'") + + # Add faces to the group + self._add_to_face_group(face_group, selection) + + return face_group + + def _add_to_face_group( + self, face_group: FaceGroup, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + ) -> None: + """ + Internal method to add faces to a face group. + + This method handles the core logic of: + - Converting selection to a list of nodes + - Extracting all faces from selected nodes + - Removing faces from their previous groups + - Adding faces to the target group + - Updating the face-to-group mapping + - Automatically removing any face groups that become empty + + Parameters + ---------- + face_group : FaceGroup + The face group to add faces to + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + The selection to add (TreeSearch, NodeCollection, TreeNode, or list of TreeNodes) + + Notes + ----- + If moving faces causes any group to become empty (0 faces), that group will be + automatically removed from the Geometry's face group registry. + """ # Handle different selection types if isinstance(selection, TreeSearch): - # Execute TreeSearch to get nodes selected_nodes = selection.execute() elif isinstance(selection, NodeCollection): - # Extract nodes from NodeCollection selected_nodes = selection.nodes elif isinstance(selection, TreeNode): - # Wrap single node in a list selected_nodes = [selection] else: - # Already a list of nodes selected_nodes = selection # Collect faces from selected nodes - group_faces = [] + new_faces = [] new_face_uuids = set() for node in selected_nodes: faces = node.get_all_faces() for face in faces: if face.uuid: - group_faces.append(face) + new_faces.append(face) new_face_uuids.add(face.uuid) # Remove these faces from their previous groups - for group_name, faces in list(self._group_name_to_faces.items()): - if group_name != name: - self._group_name_to_faces[group_name] = [ - f for f in faces if f.uuid not in new_face_uuids - ] + groups_to_check = set() + for uuid in new_face_uuids: + if uuid in self._face_uuid_to_face_group: + old_group = self._face_uuid_to_face_group[uuid] + if old_group != face_group: + # Remove from old group + old_group._faces = [f for f in old_group._faces if f.uuid != uuid] + groups_to_check.add(old_group) + + # Clean up empty face groups + for group in groups_to_check: + if len(group._faces) == 0: + # Remove the empty group from the registry + if group.name in self._face_groups: + del self._face_groups[group.name] + log.info(f"Removed empty face group '{group.name}'") # Update face-to-group mapping for uuid in new_face_uuids: - self._face_to_group_name[uuid] = name + self._face_uuid_to_face_group[uuid] = face_group - # Store the group - self._group_name_to_faces[name] = group_faces + # Add to this group (avoiding duplicates) + existing_uuids = {f.uuid for f in face_group._faces if f.uuid} + + added_count = 0 + for face in new_faces: + if face.uuid not in existing_uuids: + face_group._faces.append(face) + existing_uuids.add(face.uuid) + added_count += 1 + + log.info( + f"Added {added_count} faces to group '{face_group.name}' " + f"(total: {len(face_group._faces)} faces)" + ) - face_uuids = [face.uuid for face in group_faces if face.uuid] - log.info(f"Created face group '{name}' with {len(face_uuids)} faces") - return face_uuids + def face_grouping_configuration(self) -> Dict[str, str]: + face_uuid_to_face_group_name = {} + for face_uuid, face_group in self._face_uuid_to_face_group.items(): + face_uuid_to_face_group_name[face_uuid] = face_group.name + return face_uuid_to_face_group_name def print_face_grouping_stats(self) -> None: """ @@ -754,11 +898,11 @@ def print_face_grouping_stats(self) -> None: ) total_faces = len(self._tree.all_faces) - faces_in_groups = sum(len(faces) for faces in self._group_name_to_faces.values()) + faces_in_groups = sum(group.face_count for group in self._face_groups.values()) print(f"\n=== Face Grouping Statistics ===") print(f"Total faces: {total_faces}") - print(f"\nFace groups ({len(self._group_name_to_faces)}):") - for group_name, faces in self._group_name_to_faces.items(): - print(f" - {group_name}: {len(faces)} faces") + print(f"\nFace groups ({len(self._face_groups)}):") + for group_name, group in self._face_groups.items(): + print(f" - {group_name}: {group.face_count} faces") print("="*33) From 82e60bde06ac45c7e3534c100cff064288985f3d Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Thu, 30 Oct 2025 17:00:45 -0400 Subject: [PATCH 12/15] allow empty entity info --- flow360/component/geometry.py | 15 ++++++------- .../component/simulation/web/asset_base.py | 21 +++++++++++++------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 62a0ccc43..2869b7819 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -366,11 +366,12 @@ def _get_default_geometry_accuracy(simulation_dict: dict) -> LengthType.Positive # pylint: disable=no-member return LengthType.validate(simulation_dict["meshing"]["defaults"]["geometry_accuracy"]) - self.default_settings["geometry_accuracy"] = ( - self._entity_info.default_geometry_accuracy - if self._entity_info.default_geometry_accuracy - else _get_default_geometry_accuracy(simulation_dict=simulation_dict) - ) + if self._entity_info is not None: + self.default_settings["geometry_accuracy"] = ( + self._entity_info.default_geometry_accuracy + if self._entity_info.default_geometry_accuracy + else _get_default_geometry_accuracy(simulation_dict=simulation_dict) + ) @classmethod # pylint: disable=redefined-builtin @@ -415,7 +416,7 @@ def show_available_groupings(self, verbose_mode: bool = False): @classmethod def from_local_storage( - cls, geometry_id: str = None, local_storage_path="", meta_data: GeometryMeta = None + cls, geometry_id: str = None, local_storage_path="", meta_data: GeometryMeta = None, allow_missing_entity_info = False ) -> Geometry: """ Parameters @@ -433,7 +434,7 @@ def from_local_storage( """ return super()._from_local_storage( - asset_id=geometry_id, local_storage_path=local_storage_path, meta_data=meta_data + asset_id=geometry_id, local_storage_path=local_storage_path, meta_data=meta_data, allow_missing_entity_info = allow_missing_entity_info ) def _show_available_entity_groups( diff --git a/flow360/component/simulation/web/asset_base.py b/flow360/component/simulation/web/asset_base.py index 48c2f9fa4..30504be25 100644 --- a/flow360/component/simulation/web/asset_base.py +++ b/flow360/component/simulation/web/asset_base.py @@ -130,6 +130,7 @@ def _from_supplied_entity_info( cls, simulation_dict: dict, asset_obj: AssetBase, + allow_missing_entity_info: Boolean, ): # pylint: disable=protected-access simulation_dict, forward_compatibility_mode = SimulationParams._update_param_dict( @@ -142,9 +143,13 @@ def _from_supplied_entity_info( asset_cache = simulation_dict["private_attribute_asset_cache"] if "project_entity_info" not in asset_cache: - raise KeyError( - "[Internal] Could not find project_entity_info in the asset's simulation settings." - ) + if allow_missing_entity_info: + return asset_obj + else: + raise KeyError( + "[Internal] Could not find project_entity_info in the asset's simulation settings." + ) + entity_info_dict = asset_cache["project_entity_info"] entity_info_dict = SimulationParams._sanitize_params_dict(entity_info_dict) # pylint: disable=protected-access @@ -302,7 +307,7 @@ def from_file( @classmethod def _from_local_storage( - cls, asset_id: str = None, local_storage_path="", meta_data: AssetMetaBaseModelV2 = None + cls, asset_id: str = None, local_storage_path="", meta_data: AssetMetaBaseModelV2 = None, allow_missing_entity_info = False ): """ Create asset from local storage @@ -317,8 +322,12 @@ def _from_local_storage( params_dict = json.load(f) asset_obj = cls(asset_id) -# asset_obj = cls._from_supplied_entity_info(params_dict, cls(asset_id)) -# asset_obj.get_dynamic_default_settings(params_dict) + asset_obj = cls._from_supplied_entity_info( + simulation_dict=params_dict, + asset_obj = cls(asset_id), + allow_missing_entity_info = allow_missing_entity_info + ) + asset_obj.get_dynamic_default_settings(params_dict) # pylint: disable=protected-access if not hasattr(asset_obj, "_webapi"): From fa98b9e83316cb7745c9a45343c13902d219b8a5 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Fri, 31 Oct 2025 16:48:58 -0400 Subject: [PATCH 13/15] remove root_tree --- flow360/component/geometry.py | 100 ++++++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 2869b7819..300635c57 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -296,7 +296,7 @@ def add( Examples -------- >>> wing_group = geometry.create_face_group(name="wing", selection=...) - >>> wing_group.add(geometry.tree_root.search(type=NodeType.FRMFeature, name="flap")) + >>> wing_group.add(geometry.search(type=NodeType.FRMFeature, name="flap")) >>> wing_group.add(another_node) """ # Delegate to Geometry to handle the addition @@ -749,20 +749,20 @@ def create_face_group( >>> # Using TreeSearch (recommended - captures intent declaratively) >>> wing_group = geometry.create_face_group( ... name="wing", - ... selection=geometry.tree_root.search(type=NodeType.FRMFeature, name="*wing*") + ... selection=geometry.search(type=NodeType.FRMFeature, name="*wing*") ... ) >>> >>> # Using children() chaining (fluent navigation with exact matching) >>> body_group = geometry.create_face_group( ... name="body", - ... selection=geometry.tree_root.children().children().children( + ... selection=geometry.children().children().children( ... type=NodeType.FRMFeatureBasedEntity ... ).children().children(type=NodeType.FRMFeature, name="body_main") ... ) >>> >>> # Incrementally add more nodes to the group >>> body_group.add( - ... geometry.tree_root.children().children().children( + ... geometry.children().children().children( ... type=NodeType.FRMFeatureBasedEntity ... ).children().children(type=NodeType.FRMFeature, name="body_cut") ... ) @@ -907,3 +907,95 @@ def print_face_grouping_stats(self) -> None: for group_name, group in self._face_groups.items(): print(f" - {group_name}: {group.face_count} faces") print("="*33) + + def search( + self, + type: Optional[NodeType] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ) -> TreeSearch: + """ + Search the geometry tree for nodes matching the criteria. + + This is a convenience method that delegates to tree_root.search(). + It performs a recursive search through the entire geometry tree using + wildcard pattern matching for name and material fields. + + Parameters + ---------- + type : Optional[NodeType] + Node type to filter by (exact match, e.g., NodeType.FRMFeature) + name : Optional[str] + Name pattern to match (supports wildcards like "*wing*") + colorRGB : Optional[str] + RGB color string to match (exact match) + material : Optional[str] + Material pattern to match (supports wildcards) + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match (exact matches) + + Returns + ------- + TreeSearch + A TreeSearch object that can be executed via .execute() or passed + directly to create_face_group() + + Examples + -------- + >>> # Search for all wing features + >>> wing_nodes = geometry.search(type=NodeType.FRMFeature, name="*wing*") + >>> wing_group = geometry.create_face_group(name="wing", selection=wing_nodes) + """ + return self.tree_root.search( + type=type, name=name, colorRGB=colorRGB, material=material, attributes=attributes + ) + + def children( + self, + type: Optional[NodeType] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ) -> NodeCollection: + """ + Get the direct children of the root node, optionally filtered by exact criteria. + + This is a convenience method that delegates to tree_root.children(). + It filters only the direct children using exact matching (no wildcards). + Returns a NodeCollection that supports further chaining via .children() calls. + + For pattern matching and recursive search, use .search() instead. + + Parameters + ---------- + type : Optional[NodeType] + Node type to filter by (exact match, e.g., NodeType.FRMFeature) + name : Optional[str] + Exact name to match (no wildcards) + colorRGB : Optional[str] + Exact RGB color string to match + material : Optional[str] + Exact material name to match (no wildcards) + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match (exact matches) + + Returns + ------- + NodeCollection + Collection of direct child nodes matching the criteria, supports chaining + + Examples + -------- + >>> # Navigate tree structure with certainty + >>> body_nodes = geometry.children().children().children( + ... type=NodeType.FRMFeatureBasedEntity + ... ).children().children(type=NodeType.FRMFeature, name="body_main") + >>> + >>> body_group = geometry.create_face_group(name="body", selection=body_nodes) + """ + return self.tree_root.children( + type=type, name=name, colorRGB=colorRGB, material=material, attributes=attributes + ) From c296a130c86dc6e405556c4ae1c41799cbce79e6 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Tue, 4 Nov 2025 19:30:06 +0000 Subject: [PATCH 14/15] add search for NodeCollection class --- flow360/component/__init__.py | 2 + flow360/component/geometry.py | 19 ++-- flow360/component/geometry_tree.py | 152 +++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 8 deletions(-) diff --git a/flow360/component/__init__.py b/flow360/component/__init__.py index fc2472cf1..8979fa42e 100644 --- a/flow360/component/__init__.py +++ b/flow360/component/__init__.py @@ -1,6 +1,7 @@ """Flow360 Component Module""" from flow360.component.geometry_tree import ( + CollectionTreeSearch, GeometryTree, NodeCollection, NodeType, @@ -9,6 +10,7 @@ ) __all__ = [ + "CollectionTreeSearch", "GeometryTree", "NodeCollection", "NodeType", diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 300635c57..3011f4624 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -19,6 +19,7 @@ from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi from flow360.component.geometry_tree import ( + CollectionTreeSearch, GeometryTree, NodeCollection, NodeType, @@ -272,7 +273,7 @@ def face_count(self) -> int: return len(self._faces) def add( - self, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + self, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] ) -> "FaceGroup": """ Add more nodes to this face group. @@ -281,9 +282,10 @@ def add( Parameters ---------- - selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] Nodes to add to this group. Can be: - TreeSearch instance - will be executed internally + - CollectionTreeSearch instance - will be executed internally - NodeCollection - nodes will be extracted - Single TreeNode - will be wrapped in a list - List of TreeNode instances @@ -714,7 +716,7 @@ def load_geometry_tree(self, tree_json_path: str) -> None: self.print_face_grouping_stats() def create_face_group( - self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] ) -> FaceGroup: """ Create a face group based on explicit selection of tree nodes @@ -728,9 +730,10 @@ def create_face_group( ---------- name : str Name of the face group - selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] Can be one of: - TreeSearch instance (returned from tree_root.search()) - will be executed internally + - CollectionTreeSearch instance (returned from NodeCollection.search()) - will be executed internally - NodeCollection (returned from tree_root.children()) - nodes will be extracted - Single TreeNode - all faces under this node will be included - List of TreeNode instances - all faces under these nodes will be included @@ -787,7 +790,7 @@ def create_face_group( return face_group def _add_to_face_group( - self, face_group: FaceGroup, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] + self, face_group: FaceGroup, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] ) -> None: """ Internal method to add faces to a face group. @@ -804,8 +807,8 @@ def _add_to_face_group( ---------- face_group : FaceGroup The face group to add faces to - selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch] - The selection to add (TreeSearch, NodeCollection, TreeNode, or list of TreeNodes) + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] + The selection to add (TreeSearch, CollectionTreeSearch, NodeCollection, TreeNode, or list of TreeNodes) Notes ----- @@ -813,7 +816,7 @@ def _add_to_face_group( automatically removed from the Geometry's face group registry. """ # Handle different selection types - if isinstance(selection, TreeSearch): + if isinstance(selection, (TreeSearch, CollectionTreeSearch)): selected_nodes = selection.execute() elif isinstance(selection, NodeCollection): selected_nodes = selection.nodes diff --git a/flow360/component/geometry_tree.py b/flow360/component/geometry_tree.py index e089a1761..f617f3f4f 100644 --- a/flow360/component/geometry_tree.py +++ b/flow360/component/geometry_tree.py @@ -331,6 +331,63 @@ def children( return NodeCollection(all_children) + def search( + self, + type: Optional[NodeType] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ) -> "CollectionTreeSearch": + """ + Create a deferred search operation across all nodes in the collection. + + This method searches the subtrees of all nodes in this collection for nodes + matching the given criteria. It returns a CollectionTreeSearch instance that + captures the search criteria but does not execute until needed. + + Supports wildcard matching for name and material using '*' character. + All criteria are ANDed together. + + Parameters + ---------- + type : Optional[NodeType] + Node type to match (e.g., NodeType.FRMFeature) + name : Optional[str] + Name pattern to match. Supports wildcards: + - "*wing*" matches any name containing "wing" + - "wing*" matches any name starting with "wing" + - "*wing" matches any name ending with "wing" + - "wing" matches exact name "wing" + colorRGB : Optional[str] + RGB color string to match (e.g., "255,0,0" for red) + material : Optional[str] + Material name to match. Supports wildcard matching like name parameter. + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match + + Returns + ------- + CollectionTreeSearch + A search instance that can be executed later to get matching nodes + + Examples + -------- + >>> # Search for FRMFeature nodes across multiple nodes + >>> results = collection.search(type=NodeType.FRMFeature, name="Boss-Extrude3") + >>> + >>> # Pass to create_face_group or add methods + >>> wing.add(results) + """ + return CollectionTreeSearch( + nodes=self._nodes, + type=type, + name=name, + colorRGB=colorRGB, + material=material, + attributes=attributes, + ) + def __len__(self) -> int: """Return the number of nodes in this collection""" return len(self._nodes) @@ -457,6 +514,101 @@ def __repr__(self): return f"TreeSearch({criteria_str})" +class CollectionTreeSearch: + """ + Represents a deferred tree search operation across multiple nodes. + + This class is similar to TreeSearch but operates on a collection of nodes + instead of a single node. It captures search criteria and executes the search + across all nodes when requested. + """ + + def __init__( + self, + nodes: List[TreeNode], + type: Optional[NodeType] = None, + name: Optional[str] = None, + colorRGB: Optional[str] = None, + material: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + ): + """ + Initialize a CollectionTreeSearch with search criteria. + + Parameters + ---------- + nodes : List[TreeNode] + The nodes from which to start the search (searches their subtrees) + type : Optional[NodeType] + Node type to match (e.g., NodeType.FRMFeature) + name : Optional[str] + Name pattern to match. Supports wildcards (e.g., "*wing*") + colorRGB : Optional[str] + RGB color string to match (e.g., "255,0,0") + material : Optional[str] + Material name to match. Supports wildcards. + attributes : Optional[Dict[str, str]] + Dictionary of attribute key-value pairs to match + """ + self.nodes = nodes + self.type = type + self.name = name + self.colorRGB = colorRGB + self.material = material + self.attributes = attributes + + def execute(self) -> List[TreeNode]: + """ + Execute the search across all nodes and return matching nodes. + + Searches the subtree of each node in the collection and combines + all matching results, avoiding duplicates. + + Returns + ------- + List[TreeNode] + List of unique nodes matching the search criteria across all subtrees + """ + all_matches = [] + seen_ids = set() + + for node in self.nodes: + # Create a TreeSearch for this node + tree_search = TreeSearch( + node=node, + type=self.type, + name=self.name, + colorRGB=self.colorRGB, + material=self.material, + attributes=self.attributes, + ) + + # Execute and collect matches, avoiding duplicates + matches = tree_search.execute() + for match in matches: + node_id = id(match) + if node_id not in seen_ids: + seen_ids.add(node_id) + all_matches.append(match) + + return all_matches + + def __repr__(self): + criteria = [] + if self.type is not None: + criteria.append(f"type={self.type.value}") + if self.name is not None: + criteria.append(f"name='{self.name}'") + if self.colorRGB is not None: + criteria.append(f"colorRGB='{self.colorRGB}'") + if self.material is not None: + criteria.append(f"material='{self.material}'") + if self.attributes is not None: + criteria.append(f"attributes={self.attributes}") + criteria_str = ", ".join(criteria) + return f"CollectionTreeSearch({len(self.nodes)} nodes, {criteria_str})" + + class GeometryTree: """Pure tree structure representing Geometry hierarchy""" From ca0f79a899332a7ef8a6093d8d3b9154c8928281 Mon Sep 17 00:00:00 2001 From: Feilin Jia Date: Tue, 11 Nov 2025 20:57:55 +0000 Subject: [PATCH 15/15] allow subtraction between geometry and face group --- flow360/component/geometry.py | 206 ++++++++++++++++++++++++++++++---- 1 file changed, 187 insertions(+), 19 deletions(-) diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 3011f4624..3388e40d9 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -232,6 +232,102 @@ def submit(self, description="", progress_callback=None, run_async=False) -> Geo return Geometry.from_cloud(info.id) +class FaceSelection: + """ + Represents a selection of faces that can be used in boolean operations. + + This class supports subtraction operations to create complex face selections, + such as "all geometry faces minus wing faces minus tail faces". + """ + + def __init__(self, geometry: "Geometry", include_all: bool = False, + face_groups_to_subtract: Optional[List["FaceGroup"]] = None): + """ + Initialize a FaceSelection. + + Parameters + ---------- + geometry : Geometry + The parent Geometry object + include_all : bool + If True, starts with all faces in the geometry + face_groups_to_subtract : Optional[List[FaceGroup]] + List of face groups whose faces should be subtracted + """ + self._geometry = geometry + self._include_all = include_all + self._face_groups_to_subtract = face_groups_to_subtract or [] + + def __sub__(self, other: "FaceGroup") -> "FaceSelection": + """ + Subtract a face group from this selection. + + Parameters + ---------- + other : FaceGroup + The face group to subtract + + Returns + ------- + FaceSelection + A new FaceSelection with the subtraction applied + """ + if not isinstance(other, FaceGroup): + raise Flow360ValueError( + f"Can only subtract FaceGroup from FaceSelection, got {type(other)}" + ) + + # Create a new FaceSelection with the additional subtraction + new_subtractions = self._face_groups_to_subtract + [other] + return FaceSelection( + self._geometry, + include_all=self._include_all, + face_groups_to_subtract=new_subtractions + ) + + def get_selected_faces(self) -> List[TreeNode]: + """ + Execute the selection and return the list of face nodes. + + Returns + ------- + List[TreeNode] + List of face nodes after applying all operations + """ + if self._geometry._tree is None: + raise Flow360ValueError( + "Geometry tree not loaded. Call load_geometry_tree() first" + ) + + if self._include_all: + # Start with all faces + all_faces = self._geometry._tree.all_faces + selected_face_uuids = {face.uuid for face in all_faces if face.uuid} + else: + # Start with empty set + selected_face_uuids = set() + + # Subtract face groups + for face_group in self._face_groups_to_subtract: + face_uuids_to_remove = {face.uuid for face in face_group.faces if face.uuid} + selected_face_uuids -= face_uuids_to_remove + + # Convert UUIDs back to face nodes + uuid_to_face = self._geometry._tree.uuid_to_face + result_faces = [uuid_to_face[uuid] for uuid in selected_face_uuids if uuid in uuid_to_face] + + return result_faces + + def __repr__(self): + parts = [] + if self._include_all: + parts.append("All geometry") + if self._face_groups_to_subtract: + subtracted = ", ".join([fg.name for fg in self._face_groups_to_subtract]) + parts.append(f"minus [{subtracted}]") + return f"FaceSelection({' '.join(parts)})" + + class FaceGroup: """ Represents a face group that can be incrementally built by adding nodes. @@ -273,7 +369,7 @@ def face_count(self) -> int: return len(self._faces) def add( - self, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] + self, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch, FaceSelection] ) -> "FaceGroup": """ Add more nodes to this face group. @@ -282,8 +378,9 @@ def add( Parameters ---------- - selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch, FaceSelection] Nodes to add to this group. Can be: + - FaceSelection instance - computed faces will be added - TreeSearch instance - will be executed internally - CollectionTreeSearch instance - will be executed internally - NodeCollection - nodes will be extracted @@ -661,6 +758,37 @@ def __getitem__(self, key: str): def __setitem__(self, key: str, value: Any): raise NotImplementedError("Assigning/setting entities is not supported.") + def __sub__(self, other: FaceGroup) -> FaceSelection: + """ + Subtract a face group from the geometry to create a face selection. + + This allows intuitive syntax like: + geometry - wing_group - tail_group + + Parameters + ---------- + other : FaceGroup + The face group to subtract from all geometry faces + + Returns + ------- + FaceSelection + A FaceSelection representing all faces minus the specified group + + Examples + -------- + >>> fuselage = geometry.create_face_group( + ... name="fuselage", + ... selection=geometry - wing - tail + ... ) + """ + if not isinstance(other, FaceGroup): + raise Flow360ValueError( + f"Can only subtract FaceGroup from Geometry, got {type(other)}" + ) + + return FaceSelection(self, include_all=True, face_groups_to_subtract=[other]) + # ========== Tree-based face grouping methods ========== @property @@ -716,7 +844,7 @@ def load_geometry_tree(self, tree_json_path: str) -> None: self.print_face_grouping_stats() def create_face_group( - self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] + self, name: str, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch, FaceSelection] ) -> FaceGroup: """ Create a face group based on explicit selection of tree nodes @@ -730,8 +858,9 @@ def create_face_group( ---------- name : str Name of the face group - selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch, FaceSelection] Can be one of: + - FaceSelection instance (returned from geometry - face_group operations) - TreeSearch instance (returned from tree_root.search()) - will be executed internally - CollectionTreeSearch instance (returned from NodeCollection.search()) - will be executed internally - NodeCollection (returned from tree_root.children()) - nodes will be extracted @@ -755,6 +884,12 @@ def create_face_group( ... selection=geometry.search(type=NodeType.FRMFeature, name="*wing*") ... ) >>> + >>> # Using subtraction (boolean operations) + >>> fuselage_group = geometry.create_face_group( + ... name="fuselage", + ... selection=geometry - wing_group - tail_group + ... ) + >>> >>> # Using children() chaining (fluent navigation with exact matching) >>> body_group = geometry.create_face_group( ... name="body", @@ -790,7 +925,7 @@ def create_face_group( return face_group def _add_to_face_group( - self, face_group: FaceGroup, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] + self, face_group: FaceGroup, selection: Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch, FaceSelection] ) -> None: """ Internal method to add faces to a face group. @@ -807,8 +942,8 @@ def _add_to_face_group( ---------- face_group : FaceGroup The face group to add faces to - selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch] - The selection to add (TreeSearch, CollectionTreeSearch, NodeCollection, TreeNode, or list of TreeNodes) + selection : Union[TreeNode, List[TreeNode], NodeCollection, TreeSearch, CollectionTreeSearch, FaceSelection] + The selection to add (TreeSearch, CollectionTreeSearch, NodeCollection, TreeNode, list of TreeNodes, or FaceSelection) Notes ----- @@ -816,25 +951,58 @@ def _add_to_face_group( automatically removed from the Geometry's face group registry. """ # Handle different selection types - if isinstance(selection, (TreeSearch, CollectionTreeSearch)): + if isinstance(selection, FaceSelection): + # FaceSelection: get the computed face list + new_faces = selection.get_selected_faces() + new_face_uuids = {face.uuid for face in new_faces if face.uuid} + elif isinstance(selection, (TreeSearch, CollectionTreeSearch)): selected_nodes = selection.execute() + # Collect faces from selected nodes + new_faces = [] + new_face_uuids = set() + + for node in selected_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + new_faces.append(face) + new_face_uuids.add(face.uuid) elif isinstance(selection, NodeCollection): selected_nodes = selection.nodes + # Collect faces from selected nodes + new_faces = [] + new_face_uuids = set() + + for node in selected_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + new_faces.append(face) + new_face_uuids.add(face.uuid) elif isinstance(selection, TreeNode): selected_nodes = [selection] + # Collect faces from selected nodes + new_faces = [] + new_face_uuids = set() + + for node in selected_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + new_faces.append(face) + new_face_uuids.add(face.uuid) else: selected_nodes = selection - - # Collect faces from selected nodes - new_faces = [] - new_face_uuids = set() - - for node in selected_nodes: - faces = node.get_all_faces() - for face in faces: - if face.uuid: - new_faces.append(face) - new_face_uuids.add(face.uuid) + # Collect faces from selected nodes + new_faces = [] + new_face_uuids = set() + + for node in selected_nodes: + faces = node.get_all_faces() + for face in faces: + if face.uuid: + new_faces.append(face) + new_face_uuids.add(face.uuid) # Remove these faces from their previous groups groups_to_check = set()