Source code for flow360.component.simulation.draft_context.context

"""Draft context manager for local entity sandboxing."""

from __future__ import annotations

import warnings
from contextlib import AbstractContextManager
from contextvars import ContextVar, Token
from typing import TYPE_CHECKING, List, Optional, Union, get_args

from flow360_schema.framework.entity.entity_base import EntityBase
from flow360_schema.framework.entity.entity_expansion_config import (
    DEFAULT_TARGET_CLASS_EXPANSION_MAP,
)
from flow360_schema.framework.entity.entity_registry import (
    EntityRegistry,
    EntityRegistryView,
)
from flow360_schema.framework.entity.entity_selector import EntitySelector
from flow360_schema.framework.entity.entity_utils import get_entity_key
from flow360_schema.models.asset_cache import CoordinateSystemStatus, MirrorStatus
from flow360_schema.models.entities.geometry_entities import Edge, GeometryBodyGroup
from flow360_schema.models.entities.surface_entities import (
    ImportedSurface,
    MirroredGeometryBodyGroup,
    MirroredSurface,
    Surface,
)
from flow360_schema.models.entities.volume_entities import GenericVolume
from flow360_schema.models.entity_info import (
    DraftEntityTypes,
    EntityInfoModel,
    GeometryEntityInfo,
)
from flow360_schema.models.simulation.framework.updater_utils import (
    deprecation_reminder,
)

from flow360.component.simulation.draft_context.coordinate_system_manager import (
    CoordinateSystemManager,
)
from flow360.component.simulation.draft_context.mirror import MirrorManager
from flow360.component.simulation.framework.entity_expansion_utils import (
    EntitySelection,
    infer_target_class,
    normalize_selection,
)
from flow360.exceptions import Flow360RuntimeError, Flow360ValueError
from flow360.log import log

if TYPE_CHECKING:
    from flow360.component.geometry import Geometry

__all__ = [
    "DraftContext",
    "get_active_draft",
]


_ACTIVE_DRAFT: ContextVar[Optional["DraftContext"]] = ContextVar("_ACTIVE_DRAFT", default=None)

_DRAFT_ENTITY_TYPE_TUPLE: tuple[type[EntityBase], ...] = tuple(
    get_args(get_args(DraftEntityTypes)[0])
)


def get_active_draft() -> Optional["DraftContext"]:
    """Return the current active draft context if any."""
    return _ACTIVE_DRAFT.get()


[docs] class DraftContext( # pylint: disable=too-many-instance-attributes AbstractContextManager["DraftContext"] ): """ Context manager that tracks locally modified simulation entities/status. This should (eventually, not right now) be replacement of accessing entities directly from assets. """ __slots__ = ( # Persistent entities data storage. "_entity_info", # Interface accessing ALL types of entities. "_entity_registry", "_imported_surfaces", "_imported_geometries", # Lightweight mirror relationships storage (compared to entity storages) "_mirror_manager", # Internal mirror related entities data storage. "_mirror_status", # Lightweight coordinate system relationships storage (compared to entity storages) "_coordinate_system_manager", # Geometry root resource from which this draft was created. "_geometry_root", # Beta notices already shown for this draft, so loops do not repeat them. "_beta_notices_shown", # Rule-based face grouping carried as draft state (persisted at submission) "_face_grouping_rules", # Lazy provider of the source geometry's topology tree (geometry-root # drafts only) — what preview_face_grouping resolves against. A provider, # not the tree: fetching is a cloud call that most drafts never need. "_topology_tree_provider", "_token", ) # pylint: disable=too-many-arguments def __init__( self, *, entity_info: EntityInfoModel, entity_registry: Optional[EntityRegistry] = None, imported_geometries: Optional[List] = None, imported_surfaces: Optional[List[ImportedSurface]] = None, mirror_status: Optional[MirrorStatus] = None, coordinate_system_status: Optional[CoordinateSystemStatus] = None, geometry_root: Optional[Geometry] = None, face_grouping_rules=None, topology_tree_provider=None, ) -> None: """ Data members: - _token: Token to track the active draft context. - _mirror_manager: Manager for mirror planes and mirrored entities. - _entity_registry: Registry of entities of self._entity_info. This provides interface for user to access the entities in the draft. A prebuilt registry may be passed in, in which case it MUST have been built from the exact `entity_info` instance handed over (shared instances are the draft's identity guarantee). """ if entity_info is None: raise Flow360RuntimeError( "[Internal] DraftContext requires `entity_info` to initialize." ) self._token: Optional[Token] = None self._geometry_root = geometry_root self._beta_notices_shown: set = set() # Rules are draft state, not a cloud call: they ride the normal submission # flow (run/draft_only) inside simulation.json's asset cache, never a # per-feature save method. The resolved groups live in _entity_info. self._face_grouping_rules = face_grouping_rules self._topology_tree_provider = topology_tree_provider # The caller transfers ownership of entity_info (and the registry when prebuilt): # it must be an instance no other owner mutates, e.g. a fresh deserialization. self._entity_info = entity_info self._entity_registry: EntityRegistry = ( entity_registry if entity_registry is not None else EntityRegistry.from_entity_info(entity_info) ) self._imported_surfaces: List = imported_surfaces or [] known_frozen_hashes = set() for imported_surface in self._imported_surfaces: known_frozen_hashes = self._entity_registry.fast_register( imported_surface, known_frozen_hashes ) self._imported_geometries: List = imported_geometries if imported_geometries else [] # Pre-compute face_group_to_body_group map for mirror operations. # This is only available for GeometryEntityInfo. face_group_to_body_group = None if isinstance(self._entity_info, GeometryEntityInfo): try: face_group_to_body_group = self._entity_info.get_face_group_to_body_group_id_map() except Flow360ValueError as exc: # Face grouping spans across body groups. log.warning( "Failed to derive surface-to-body-group mapping for mirroring: %s. " "Mirroring will be disabled.", exc, ) self._mirror_manager = MirrorManager._from_status( status=mirror_status, face_group_to_body_group=face_group_to_body_group, entity_registry=self._entity_registry, ) self._coordinate_system_manager = CoordinateSystemManager._from_status( status=coordinate_system_status, entity_registry=self._entity_registry, ) def __enter__(self) -> DraftContext: if get_active_draft() is not None: raise Flow360RuntimeError("Nested draft contexts are not allowed.") self._token = _ACTIVE_DRAFT.set(self) return self def __exit__(self, exc_type, exc, exc_tb) -> None: if self._token is None: raise Flow360RuntimeError( "[Internal] DraftContext exit called without a matching enter." ) _ACTIVE_DRAFT.reset(self._token) self._token = None return False # region -----------------------------Private implementations Below----------------------------- def _warn_beta_once(self, feature: str) -> None: """Emit the beta notice for `feature` at most once per draft. These helpers get called in loops -- one selector per boundary condition is normal -- so warning per call buries the rest of the output. """ if feature in self._beta_notices_shown: return self._beta_notices_shown.add(feature) log.warning( f"!!! `{feature}` is a beta feature and may be removed or changed " "in future releases. !!!" ) # endregion ------------------------------------------------------------------------------------ # region -----------------------------Public properties Below------------------------------------- @property def face_grouping_rules(self): """The rule-based face-grouping payload this draft carries. Raises when the draft uses a plain grouping tag. Persisted automatically at submission (the resolved assignment is internal draft state, not API surface). Read-only: rules are chosen at create_draft(face_grouping=...) and the resolved groups are materialized from them right there, so re-pointing this attribute afterwards could not move the draft's surfaces with it.""" if self._face_grouping_rules is not None: return self._face_grouping_rules active_tag = getattr(self._entity_info, "face_group_tag", None) if active_tag == "rule-based-grouping": raise Flow360RuntimeError( "This draft's grouping uses the 'rule-based-grouping' tag but carries no " "rules payload — the rules were authored elsewhere (e.g. the Web UI). " "Re-author them via create_draft(face_grouping=FaceGroupingRules(...)) " "to work with them here." ) raise Flow360RuntimeError( "This draft does not carry rule-based face-grouping rules " f"(grouping tag: '{active_tag}'). Author them via " "create_draft(face_grouping=FaceGroupingRules(...))." )
[docs] def preview_face_grouping(self, face_grouping=None) -> dict: """Resolve face-grouping rules against this draft's topology tree, READ-ONLY — nothing is materialized or persisted. With no argument, previews the draft's own rules; pass a FaceGroupingRules payload to try an alternative before re-authoring the draft. Returns ``{group_name: [face_id, ...]}`` — the ``draft.surfaces`` names the same rules materialize (first-match-wins partition; overlaps warn loudly; leftover faces without an ``is_remaining`` group, a rule group left with no faces, or a group spanning several model files is refused, exactly as at create_draft).""" # pylint: disable=import-outside-toplevel from flow360_schema.geometry.selection_rules import resolve_group_assignments from flow360.component.geometry_face_grouping import faces_by_group if self._topology_tree_provider is None: raise Flow360RuntimeError( "preview_face_grouping needs a topology tree — only drafts created " "from a geometry root carry one." ) if face_grouping is None: face_grouping = self.face_grouping_rules tree = self._topology_tree_provider() return faces_by_group(resolve_group_assignments(face_grouping, tree))
# Persistent entities @property def body_groups(self) -> EntityRegistryView: """ Return the list of body groups in the draft. Example ------- >>> with fl.create_draft(new_run_from=geometry) as draft: ... draft.body_groups["body_group_1"] ... draft.body_groups["body_group*"] """ return self._entity_registry.view(GeometryBodyGroup) @property def surfaces(self) -> EntityRegistryView: """ Return the list of surfaces in the draft. """ return self._entity_registry.view(Surface) @property def mirrored_body_groups(self) -> EntityRegistryView: """ Return the list of mirrored body groups in the draft. Notes ----- Mirrored entities are draft-only entities derived from mirror actions and stored in the draft registry. """ return self._entity_registry.view(MirroredGeometryBodyGroup) @property def mirrored_surfaces(self) -> EntityRegistryView: """ Return the list of mirrored surfaces in the draft. Notes ----- Mirrored entities are draft-only entities derived from mirror actions and stored in the draft registry. """ return self._entity_registry.view(MirroredSurface) @property def edges(self) -> EntityRegistryView: """ Return the list of edges in the draft. """ return self._entity_registry.view(Edge) @property def volumes(self) -> EntityRegistryView: """ Return the list of volumes (volume zones) in the draft. """ return self._entity_registry.view(GenericVolume) # Non-persistent entities @property def boxes(self) -> EntityRegistryView: """ Return the list of boxes in the draft. """ # pylint: disable=import-outside-toplevel from flow360_schema.models.entities.volume_entities import Box return self._entity_registry.view(Box) @property def cylinders(self) -> EntityRegistryView: """ Return the list of cylinders in the draft. """ # pylint: disable=import-outside-toplevel from flow360_schema.models.entities.volume_entities import Cylinder return self._entity_registry.view(Cylinder) @property def imported_geometries(self) -> List: """ Return the list of imported geometries in the draft. """ return self._imported_geometries @property def imported_surfaces(self) -> EntityRegistryView: """ Return the list of imported surfaces in the draft. """ return self._entity_registry.view(ImportedSurface) @property def coordinate_systems(self) -> CoordinateSystemManager: """ Coordinate system manager for this draft. This is the primary user entry point to create/remove coordinate systems, define parent relationships, and assign coordinate systems to draft entities. See Also -------- CoordinateSystemManager """ return self._coordinate_system_manager @property def mirror(self) -> MirrorManager: """ Mirror manager for this draft. This is the primary user entry point to define mirror planes and create/remove mirrored draft-only entities derived from geometry body groups. See Also -------- MirrorManager """ return self._mirror_manager
[docs] def preview_selector(self, selector: EntitySelector, *, return_names: bool = True): """ Preview which entities a selector would match in this draft context. Parameters ---------- selector : EntitySelector The selector to preview (SurfaceSelector, EdgeSelector, VolumeSelector, or BodyGroupSelector). return_names : bool, default True When True, returns entity names. When False, returns entity instances. Returns ------- list[str] or list[EntityBase] Matched entity names or instances depending on ``return_names``. Example ------- >>> import flow360 as fl >>> geometry = fl.Geometry.from_cloud(id="...") >>> with fl.create_draft(new_run_from=geometry) as draft: ... selector = fl.SurfaceSelector(name="wing_surfaces").match("wing*") ... matched = draft.preview_selector(selector) ... print(matched) # ['wing_upper', 'wing_lower', ...] See Also -------- preview_unselected : the complement -- entities no selection covers. """ self._warn_beta_once("preview_selector") if not isinstance(selector, EntitySelector): raise Flow360ValueError( f"Expected EntitySelector, got {type(selector).__name__}. " "Use fl.SurfaceSelector, fl.EdgeSelector, fl.VolumeSelector, or fl.BodyGroupSelector." ) matched_entities = normalize_selection( self._entity_registry, selector, operation="preview_selector()" ) if return_names: return [entity.name for entity in matched_entities] return matched_entities
[docs] def preview_unselected(self, selection: EntitySelection, *, return_names: bool = True): """ Report which entities in this draft a selection does **not** cover. Catches silent under-selection: a glob matching nothing, or a name whose capitalization differs from the geometry, leaves surfaces out of every assignment without raising. The comparison pool is every entity the selection's kind of selector can reach, read from the configuration selector expansion itself uses. For surfaces that is ``Surface`` and ``MirroredSurface``; imported surfaces and ghost boundaries are not selectable and never reported. The pool spans the whole draft. Parameters ---------- selection : EntitySelection What counts as selected. Must name exactly one entity kind and be non-empty, since the kind is inferred from it. return_names : bool, default True When True, returns entity names. When False, returns entity instances. Returns ------- list[str] or list[EntityBase] Unselected entity names or instances depending on ``return_names``. Example ------- >>> import flow360 as fl >>> geometry = fl.Geometry.from_cloud(id="...") >>> with fl.create_draft(new_run_from=geometry, face_grouping="faceId") as draft: ... wheels = fl.SurfaceSelector(name="wheels").match("*rim*") ... body = fl.SurfaceSelector(name="body").match("*body*") ... missed = draft.preview_unselected([wheels, body]) ... if missed: ... raise ValueError(f"No selector covers: {missed}") See Also -------- preview_selector : the complement -- what a single selector matches. """ self._warn_beta_once("preview_unselected") operation = "preview_unselected()" target_class = infer_target_class(selection, operation=operation) selected = normalize_selection(self._entity_registry, selection, operation=operation) selected_keys = {get_entity_key(entity) for entity in selected} pool = self._entity_registry.find_by_type_name( DEFAULT_TARGET_CLASS_EXPANSION_MAP[target_class] ) unselected = [entity for entity in pool if get_entity_key(entity) not in selected_keys] if return_names: return [entity.name for entity in unselected] return unselected
[docs] @deprecation_reminder( "`draft.compute_obb()` is deprecated and will be replaced by a new API " "in a future release.", ) def compute_obb( self, entities: Union[Surface, List[Surface], EntityRegistryView, EntitySelector], ): """Compute an oriented bounding box for selected surfaces. Deprecated: this method will be replaced by a new API in a future release. """ warnings.warn( "`draft.compute_obb()` is deprecated and will be replaced by a new API " "in a future release.", DeprecationWarning, stacklevel=2, ) # pylint: disable=import-outside-toplevel from flow360.component.simulation.measurement import oriented_bounding_box return oriented_bounding_box(self, surfaces=entities)
# endregion ------------------------------------------------------------------------------------