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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 151 additions & 1 deletion flow360/component/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from __future__ import annotations

import json
import os
import threading
from enum import Enum
Expand All @@ -19,6 +20,14 @@
)
from flow360.cloud.heartbeat import post_upload_heartbeat
from flow360.cloud.rest_api import RestApi

# Re-exports for face grouping API
from flow360.component.geometry_tree import (
GeometryTreeNode,
GeometryTreeNodeSet,
TreeBackend,
)
from flow360.component.geometry_tree.face_group import FaceGroup
from flow360.component.interfaces import GeometryInterface
from flow360.component.resource_base import (
AssetMetaBaseModelV2,
Expand Down Expand Up @@ -374,7 +383,7 @@ def submit(
return Geometry.from_cloud(info.id)


class Geometry(AssetBase):
class Geometry(AssetBase): # pylint: disable=too-many-public-methods
"""
Geometry component for workbench (simulation V2)
"""
Expand All @@ -387,9 +396,150 @@ class Geometry(AssetBase):

# pylint: disable=redefined-builtin
def __init__(self, id: Union[str, None]):
self._tree = None # TreeBackend for tree navigation and face grouping
self._face_groups = {} # name -> FaceGroup
super().__init__(id)
self.snappy_body_registry = None

@classmethod
def from_local_tree(
cls, tree_json_path: str = "geometryHierarchicalMetadata.json"
) -> "Geometry":
"""
Create a Geometry from a local hierarchical metadata JSON file.

Loads the tree directly from a local JSON file, without requiring
cloud upload.

Parameters
----------
tree_json_path : str
Path to the hierarchical metadata JSON file.

Returns
-------
Geometry
Geometry with tree loaded (supports faces(), create_face_group(), etc.)
"""
geo = cls(id=None)
geo.snappy_body_registry = None
with open(tree_json_path, "r", encoding="utf-8") as f:
tree_data = json.load(f)
geo._tree = TreeBackend()
geo._tree.load_from_json(tree_data)
log.info(f"Geometry loaded from local tree: {len(geo.faces())} faces")
return geo

# ================================================================
# Tree Navigation Methods
# ================================================================

def root_node(self) -> GeometryTreeNode:
"""Get the root node of the geometry tree."""
if self._tree is None:
raise Flow360ValueError(
"Geometry tree not loaded. Use Geometry(file_path) to load from file."
)
root_id = self._tree.get_root()
return GeometryTreeNode(self, self._tree, root_id)

def children(self, **filters) -> GeometryTreeNodeSet:
"""Get direct children of the root node."""
return self.root_node().children(**filters)

def descendants(self, **filters) -> GeometryTreeNodeSet:
"""Get all descendants of the root."""
return self.root_node().descendants(**filters)

def faces(self, **filters) -> GeometryTreeNodeSet:
"""Get all face nodes in the geometry."""
return self.root_node().faces(**filters)

# ================================================================
# Face Group Management
# ================================================================

def create_face_group(self, name: str, selection: GeometryTreeNodeSet) -> FaceGroup:
"""
Create a named face group from a selection.

Each face can only belong to one group. Faces in the selection
are removed from any previous group they belonged to.
"""
if name in self._face_groups:
raise ValueError(f"Group '{name}' already exists")

# Extract face node IDs from the selection
face_nodes = selection.faces()
face_node_ids = face_nodes._node_ids # pylint: disable=protected-access

# Remove these faces from any existing groups (exclusive ownership)
for group in self._face_groups.values():
group._node_ids -= face_node_ids

group = FaceGroup(name, face_node_ids)
self._face_groups[name] = group
return group

def get_face_group(self, name: str) -> FaceGroup:
"""Get a face group by name."""
if name not in self._face_groups:
raise KeyError(f"Group '{name}' not found")
return self._face_groups[name]

def list_groups(self):
"""List all group names."""
return list(self._face_groups.keys())

def clear_groups(self) -> None:
"""Remove all face groups."""
self._face_groups.clear()

def _build_face_grouping_config(self) -> dict:
"""Build versioned face grouping config.

Returns a dict with structure:
{"version": "1.0", "face_group_mapping": {uuid: group_name, ...}}
"""
face_group_mapping = {}
for group_name, group in self._face_groups.items():
for node_id in group._node_ids: # pylint: disable=protected-access
face_group_mapping[node_id] = group_name
return {"version": "1.0", "face_group_mapping": face_group_mapping}

def export_face_grouping_config(self, output_path: str) -> None:
"""
Save face groups to a versioned local JSON file.

Parameters
----------
output_path : str
Path to write the face grouping JSON file.
"""
face_grouping_config = self._build_face_grouping_config()
with open(output_path, "w", encoding="utf-8") as fh:
json.dump(face_grouping_config, fh, indent=4)
mapping = face_grouping_config["face_group_mapping"]
log.info(f"Saved {len(mapping)} face group entries to {output_path}")

# ================================================================
# Set Operations
# ================================================================

def __sub__(self, other) -> GeometryTreeNodeSet:
"""Subtract faces from total geometry (geometry - FaceGroup)."""
all_faces = self.faces()
if isinstance(other, FaceGroup):
other_nodes = GeometryTreeNodeSet(
self, self._tree, other._node_ids
) # pylint: disable=protected-access
return all_faces - other_nodes
if isinstance(other, GeometryTreeNodeSet):
raise Flow360ValueError(
"Geometry subtraction with GeometryTreeNodeSet is not supported. "
)
return NotImplemented

@property
def face_group_tag(self):
"getter for face_group_tag"
Expand Down
13 changes: 13 additions & 0 deletions flow360/component/geometry_tree/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
geometry_tree - Hierarchical geometry tree for face grouping

Provides tree navigation and face grouping using a fluent, scope-based API.
"""

from .face_group import FaceGroup
from .node import GeometryTreeNode
from .node_set import GeometryTreeNodeSet
from .node_type import NodeType
from .tree_backend import TreeBackend

__all__ = ["TreeBackend", "GeometryTreeNodeSet", "GeometryTreeNode", "NodeType", "FaceGroup"]
24 changes: 24 additions & 0 deletions flow360/component/geometry_tree/face_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
face_group.py - FaceGroup class for named face groups
"""

from typing import Set


class FaceGroup:
"""
Represents a named group of faces.

Can be used in set operations with Geometry to get remaining faces.
"""

def __init__(self, name: str, node_ids: Set[str]):
self.name = name
self._node_ids = node_ids.copy()

def face_count(self) -> int:
"""Get number of faces in group."""
return len(self._node_ids)

def __repr__(self) -> str:
return f"FaceGroup('{self.name}', {self.face_count()} faces)"
84 changes: 84 additions & 0 deletions flow360/component/geometry_tree/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""
filters.py - Filter matching logic

Provides functions for matching node attributes against filter criteria.
Supports glob patterns (* and ?) for string matching.
"""

import re
from typing import Any, Dict

from .node_type import NodeType


def glob_to_regex(pattern: str) -> re.Pattern:
"""
Convert glob pattern to regex.

Supports:
* - matches any number of characters
? - matches exactly one character
"""
# Escape special regex characters except * and ?
escaped = re.escape(pattern)
# Convert glob wildcards to regex
regex_pattern = escaped.replace(r"\*", ".*").replace(r"\?", ".")
return re.compile(f"^{regex_pattern}$", re.IGNORECASE)


def matches_pattern(value: str, pattern: str) -> bool:
"""
Check if a string value matches a glob pattern.
"""
if value is None:
return False

# If no wildcards, do exact case-insensitive match
if "*" not in pattern and "?" not in pattern:
return value.lower() == pattern.lower()

regex = glob_to_regex(pattern)
return bool(regex.match(value))


def matches_criteria(node_attrs: Dict[str, Any], criteria: Dict[str, Any]) -> bool:
"""
Check if node attributes match all given criteria.

System attributes (name, type, colorRGB, etc.) are matched at top level.
Custom attributes should be passed via 'attributes' parameter:
attributes={"groupName": "wing"}
"""
for key, expected_value in criteria.items():
# Handle custom attributes dict separately
if key == "attributes" and isinstance(expected_value, dict):
node_custom_attrs = node_attrs.get("attributes", {})
for attr_key, attr_pattern in expected_value.items():
actual_value = node_custom_attrs.get(attr_key)
if actual_value is None:
return False
if not matches_pattern(str(actual_value), str(attr_pattern)):
return False
continue

# Type uses exact NodeType comparison
if key == "type":
if node_attrs.get("type") != expected_value:
return False
continue

# Other system attributes - match via pattern
actual_value = node_attrs.get(key)

if actual_value is None:
return False

if not matches_pattern(str(actual_value), str(expected_value)):
return False

return True


def is_face_node(node_attrs: Dict[str, Any]) -> bool:
"""Check if a node is a face."""
return node_attrs.get("type") == NodeType.FACE
91 changes: 91 additions & 0 deletions flow360/component/geometry_tree/node.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""
node.py - GeometryTreeNode class for individual tree nodes

A GeometryTreeNode represents a single node in the geometry tree with direct attribute access.
"""

from typing import TYPE_CHECKING, Optional

from .node_type import NodeType

if TYPE_CHECKING:
from .node_set import GeometryTreeNodeSet


class GeometryTreeNode:
"""
Represents a single node in the geometry tree.

Provides direct attribute access and navigation from a single node.
"""

def __init__(self, geometry, tree, node_id: str):
self._geometry = geometry
self._tree = tree
self._node_id = node_id
self._attrs = tree.get_node_attrs(node_id)

@property
def name(self) -> str:
"""Get the node name."""
return self._attrs.get("name", "")

@property
def type(self) -> Optional[NodeType]:
"""Get the node type (e.g., NodeType.PART, NodeType.FACE)."""
return self._attrs.get("type")

@property
def color(self) -> str:
"""Get the node color (colorRGB value)."""
return self._attrs.get("colorRGB", "")

def children(self, **filters) -> "GeometryTreeNodeSet":
"""Get direct children of this node."""
from .node_set import ( # pylint: disable=import-outside-toplevel
GeometryTreeNodeSet,
)

child_ids = set(self._tree.get_children(self._node_id))
if filters:
child_ids = self._tree.filter_nodes(child_ids, **filters)
return GeometryTreeNodeSet(self._geometry, self._tree, child_ids)

def descendants(self, **filters) -> "GeometryTreeNodeSet":
"""Get all descendants of this node."""
from .node_set import ( # pylint: disable=import-outside-toplevel
GeometryTreeNodeSet,
)

descendant_ids = self._tree.get_descendants(self._node_id)
if filters:
descendant_ids = self._tree.filter_nodes(descendant_ids, **filters)
return GeometryTreeNodeSet(self._geometry, self._tree, descendant_ids)

def faces(self, **filters) -> "GeometryTreeNodeSet":
"""Get all face nodes under this node."""
from .node_set import ( # pylint: disable=import-outside-toplevel
GeometryTreeNodeSet,
)

node_set = GeometryTreeNodeSet(self._geometry, self._tree, {self._node_id})
return node_set.faces(**filters)

def is_face(self) -> bool:
"""Check if this node is a face."""
return self.type == NodeType.FACE

def __repr__(self) -> str:
info = f"GeometryTreeNode('{self.name}', type='{self.type}'"
if self.color:
info += f", color='{self.color}'"
info += ")"
return info

def __eq__(self, other) -> bool:
if not isinstance(other, GeometryTreeNode):
return False
return self._node_id == other._node_id

def __hash__(self) -> int:
return hash(self._node_id)
Loading
Loading