diff --git a/doc/changelog.d/2359.added.md b/doc/changelog.d/2359.added.md new file mode 100644 index 0000000000..11e1be1648 --- /dev/null +++ b/doc/changelog.d/2359.added.md @@ -0,0 +1 @@ +Tracking updates diff --git a/src/ansys/geometry/core/_grpc/_services/v1/conversions.py b/src/ansys/geometry/core/_grpc/_services/v1/conversions.py index 0de2f8eb66..e50b723799 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/conversions.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/conversions.py @@ -44,6 +44,8 @@ Quantity as GRPCQuantity, ) from ansys.api.discovery.v1.design.designmessages_pb2 import ( + BodyEntity as GRPCBodyEntity, + ComponentEntity as GRPCComponentEntity, CurveGeometry as GRPCCurveGeometry, DatumPointEntity as GRPCDesignPoint, DrivingDimensionEntity as GRPCDrivingDimension, @@ -55,6 +57,7 @@ Matrix as GRPCMatrix, NurbsCurve as GRPCNurbsCurve, NurbsSurface as GRPCNurbsSurface, + PartEntity as GRPCPartEntity, Surface as GRPCSurface, Tessellation as GRPCTessellation, TessellationOptions as GRPCTessellationOptions, @@ -1654,6 +1657,178 @@ def from_enclosure_options_to_grpc_enclosure_options( ) +def serialize_body(body: GRPCBodyEntity) -> dict: + """Serialize a GRPCBodyEntity object into a dictionary. + + It is not directly converted to a pygeometry object because we'll assign it in place and + construct the object while updating the design object by the tracker output. + + Parameters + ---------- + body : GRPCBodyEntity + The gRPC BodyEntity object to serialize. + + Returns + ------- + dict + A dictionary representation of the BodyEntity object without gRPC dependencies. + """ + # Extract basic fields + body_id = body.id.id + body_name = body.name + body_can_suppress = body.can_suppress + body_master_id = ( + body.master_id.id.id + if hasattr(body.master_id, "id") and hasattr(body.master_id.id, "id") + else (body.master_id.id if hasattr(body.master_id, "id") else "") + ) + body_parent_id = ( + body.parent_id.id.id + if hasattr(body.parent_id, "id") and hasattr(body.parent_id.id, "id") + else (body.parent_id.id if hasattr(body.parent_id, "id") else "") + ) + body_is_surface = body.is_surface + + # Extract transform_to_master matrix + transform_m00 = body.transform_to_master.m00 + transform_m11 = body.transform_to_master.m11 + transform_m22 = body.transform_to_master.m22 + transform_m33 = body.transform_to_master.m33 + + transform_to_master = { + "m00": transform_m00, + "m11": transform_m11, + "m22": transform_m22, + "m33": transform_m33, + } + + return { + "id": body_id, + "name": body_name, + "can_suppress": body_can_suppress, + "transform_to_master": transform_to_master, + "master_id": body_master_id, + "parent_id": body_parent_id, + "is_surface": body_is_surface, + } + + +def serialize_component(component: GRPCComponentEntity) -> dict: + """Serialize a GRPCComponentEntity object into a dictionary. + + Parameters + ---------- + component : GRPCComponentEntity + The gRPC ComponentEntity object to serialize. + + Returns + ------- + dict + A dictionary representation of the ComponentEntity object without gRPC dependencies. + """ + + def extract_id(obj): + if hasattr(obj, "id"): + if hasattr(obj.id, "id"): + return obj.id.id + return obj.id + return "" + + # Extract basic fields + component_id = component.id.id + component_name = component.name + component_display_name = component.display_name + + # Extract part_occurrence + part_occurrence = None + if hasattr(component, "part_occurrence"): + part_occurrence_id = extract_id(component.part_occurrence) + part_occurrence_name = component.part_occurrence.name + part_occurrence = { + "id": part_occurrence_id, + "name": part_occurrence_name, + } + + # Extract placement matrix + placement_m00 = 1.0 + placement_m11 = 1.0 + placement_m22 = 1.0 + placement_m33 = 1.0 + if hasattr(component, "placement"): + placement_m00 = component.placement.m00 + placement_m11 = component.placement.m11 + placement_m22 = component.placement.m22 + placement_m33 = component.placement.m33 + + placement = { + "m00": placement_m00, + "m11": placement_m11, + "m22": placement_m22, + "m33": placement_m33, + } + + # Extract part_master + part_master = None + if hasattr(component, "part_master"): + part_master_id = extract_id(component.part_master) + part_master_name = component.part_master.name + part_master = { + "id": part_master_id, + "name": part_master_name, + } + + # Extract master_id and parent_id + master_id = extract_id(component.master_id) if hasattr(component, "master_id") else "" + parent_id = extract_id(component.parent_id) if hasattr(component, "parent_id") else "" + + return { + "id": component_id, + "name": component_name, + "display_name": component_display_name, + "part_occurrence": part_occurrence, + "placement": placement, + "part_master": part_master, + "master_id": master_id, + "parent_id": parent_id, + } + + +def serialize_part(part: GRPCPartEntity) -> dict: + """Serialize a GRPCPartEntity object into a dictionary. + + Parameters + ---------- + part : GRPCPartEntity + The gRPC PartEntity object to serialize. + + Returns + ------- + dict + A dictionary representation of the PartEntity object without gRPC dependencies. + """ + return { + "id": part.id.id, + } + + +def serialize_entity_identifier(entity: EntityIdentifier) -> dict: + """Serialize an EntityIdentifier object into a dictionary. + + Parameters + ---------- + entity : EntityIdentifier + The gRPC EntityIdentifier object to serialize. + + Returns + ------- + dict + A dictionary representation of the EntityIdentifier object without gRPC dependencies. + """ + return { + "id": entity.id, + } + + def serialize_tracked_command_response(response: GRPCTrackedCommandResponse) -> dict: """Serialize a TrackedCommandResponse object into a dictionary. @@ -1667,66 +1842,59 @@ def serialize_tracked_command_response(response: GRPCTrackedCommandResponse) -> dict A dictionary representation of the TrackedCommandResponse object. """ + # Extract command response success status + success = getattr(response.command_response, "success", False) - def serialize_body(body): - return { - "id": body.id, - "name": body.name, - "can_suppress": body.can_suppress, - "transform_to_master": { - "m00": body.transform_to_master.m00, - "m11": body.transform_to_master.m11, - "m22": body.transform_to_master.m22, - "m33": body.transform_to_master.m33, - }, - "master_id": body.master_id, - "parent_id": body.parent_id, - "is_surface": body.is_surface, - } + # Extract tracked changes + tracked_changes = response.tracked_changes - def serialize_entity_identifier(entity): - """Serialize an EntityIdentifier object into a dictionary.""" - return { - "id": entity.id, - } + # Extract and serialize parts + created_parts = [serialize_part(part) for part in getattr(tracked_changes, "created_parts", [])] + modified_parts = [ + serialize_part(part) for part in getattr(tracked_changes, "modified_parts", []) + ] + deleted_parts = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "deleted_parts", []) + ] + + # Extract and serialize components + created_components = [ + serialize_component(component) + for component in getattr(tracked_changes, "created_components", []) + ] + modified_components = [ + serialize_component(component) + for component in getattr(tracked_changes, "modified_components", []) + ] + deleted_components = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "deleted_components", []) + ] + + # Extract and serialize bodies + created_bodies = [ + serialize_body(body) for body in getattr(tracked_changes, "created_bodies", []) + ] + modified_bodies = [ + serialize_body(body) for body in getattr(tracked_changes, "modified_bodies", []) + ] + deleted_bodies = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "deleted_bodies", []) + ] return { - "success": getattr(response.command_response, "success", False), - "created_bodies": [ - serialize_body(body) for body in getattr(response.tracked_changes, "created_bodies", []) - ], - "modified_bodies": [ - serialize_body(body) - for body in getattr(response.tracked_changes, "modified_bodies", []) - ], - "deleted_bodies": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "deleted_bodies", []) - ], - "created_faces": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "created_face_ids", []) - ], - "modified_faces": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "modified_face_ids", []) - ], - "deleted_faces": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "deleted_face_ids", []) - ], - "created_edges": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "created_edge_ids", []) - ], - "modified_edges": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "modified_edge_ids", []) - ], - "deleted_edges": [ - serialize_entity_identifier(entity) - for entity in getattr(response.tracked_changes, "deleted_edge_ids", []) - ], + "success": success, + "created_parts": created_parts, + "modified_parts": modified_parts, + "deleted_parts": deleted_parts, + "created_components": created_components, + "modified_components": modified_components, + "deleted_components": deleted_components, + "created_bodies": created_bodies, + "modified_bodies": modified_bodies, + "deleted_bodies": deleted_bodies, } diff --git a/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py b/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py index 04cb4a0765..f453a62e5f 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/prepare_tools.py @@ -67,10 +67,16 @@ def extract_volume_from_faces(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.ExtractVolumeFromFaces(request) + # Convert grpc tracked command response to serialized format. + serialized_tracker_response = serialize_tracked_command_response( + response=response.tracked_command_response + ) + # Return the response - formatted as a dictionary return { "success": response.tracked_command_response.command_response.success, "created_bodies": [body.id.id for body in response.created_bodies], + "complete_command_response": serialized_tracker_response, } @protect_grpc diff --git a/src/ansys/geometry/core/designer/design.py b/src/ansys/geometry/core/designer/design.py index b958f7056d..7d6977950f 100644 --- a/src/ansys/geometry/core/designer/design.py +++ b/src/ansys/geometry/core/designer/design.py @@ -1405,17 +1405,178 @@ def _update_design_inplace(self) -> None: # Read the existing design self.__read_existing_design() - def _update_from_tracker(self, tracker_response: list[dict]): - """Update the design with the changed bodies while preserving unchanged ones.""" + def _update_from_tracker(self, tracker_response: dict): + """Update the design with the changed entities while preserving unchanged ones. + + This method is alternative to update_design_inplace method. + + Parameters + ---------- + tracker_response : dict + Dictionary containing lists of created, modified, and deleted entities + including parts, components, bodies, faces, edges, and other geometry entities. + Processing order: parts → components → bodies → deletions (reverse dependency order). + """ self._grpc_client.log.debug( f"Starting _update_from_tracker with response: {tracker_response}" ) - self._handle_modified_bodies(tracker_response.get("modified_bodies", [])) - self._handle_deleted_bodies(tracker_response.get("deleted_bodies", [])) - self._handle_created_bodies(tracker_response.get("created_bodies", [])) - def _handle_modified_bodies(self, modified_bodies): - for body_info in modified_bodies: + # Track created entities for use in subsequent steps + created_parts_dict = {} + created_master_components_dict = {} + created_components_dict = {} + created_bodies_dict = {} + + # ================== HANDLE PARTS ================== + + # Handle created parts + for part_info in tracker_response.get("created_parts", []): + part_id = part_info["id"] + # fall back to string if id is not an object with id attribute. + part_name = part_info.get("name", f"Part_{part_id}") + self._grpc_client.log.debug( + f"Processing created part: ID={part_id}, Name='{part_name}'" + ) + + # Check if part already exists + existing_part = self._find_existing_part(part_id) + if existing_part: + self._grpc_client.log.debug( + f"Created part '{part_name}' (ID: {part_id}) already exists." + ) + continue + + # Create new part + new_part = Part(part_id, part_name, [], []) + created_parts_dict[part_id] = new_part + # TODO: Add part to appropriate collection/registry + self._grpc_client.log.debug(f"Created new part '{part_name}' (ID: {part_id})") + + # Handle modified parts + # Do nothing for now, because this will almost always have the root part. + + # Handle deleted parts + for part_info in tracker_response.get("deleted_parts", []): + part_id = part_info["id"] + self._grpc_client.log.debug(f"Processing deleted part: ID={part_id}") + + existing_part = self._find_existing_part(part_id) + if existing_part: + # Mark as not alive (if applicable) + if hasattr(existing_part, "_is_alive"): + existing_part._is_alive = False + self._grpc_client.log.debug(f"Removed part (ID: {part_id})") + # TODO: Implement actual removal logic based on where parts are stored + else: + self._grpc_client.log.warning(f"Could not find part to delete: ID={part_id}") + + # ================== HANDLE COMPONENTS ================== + + # Handle created master components + for component_info in tracker_response.get("created_components", []): + # Check and create master components. + if component_info.get("id") == component_info.get("master_id"): + # This is a MasterComponent + master_part_id = component_info.get("part_master").get("id") + master_part = created_parts_dict.get(master_part_id) or self._find_existing_part( + master_part_id + ) + if not master_part: + self._grpc_client.log.warning( + f"Could not find part for MasterComponent ID={component_info.get('id')}" + ) + continue + + new_master = MasterComponent( + component_info["id"], + component_info.get("name", f"MasterComponent_{component_info['id']}"), + master_part, + component_info.get("placement"), + ) + created_master_components_dict[component_info["id"]] = new_master + self._grpc_client.log.debug( + f"Created new MasterComponent: ID={new_master.id}, Name='{new_master.name}'" + ) + continue + + # Handle created occurrence components + for component_info in tracker_response.get("created_components", []): + # This is an OccurrenceComponent + master_part_id = component_info.get("part_master").get("id") + master_part = created_parts_dict.get(master_part_id) or self._find_existing_part( + master_part_id + ) + if not master_part: + self._grpc_client.log.warning( + f"Could not find part for Component ID={component_info.get('id')}" + ) + continue + + # Find and assign parent component + self._find_and_add_component_to_design( + component_info, self.components, created_parts_dict, created_master_components_dict + ) + + # Handle modified components + for component_info in tracker_response.get("modified_components", []): + component_id = component_info["id"] + component_name = component_info.get("name", f"Component_{component_id}") + self._grpc_client.log.debug( + f"Processing modified component: ID={component_id}, Name='{component_name}'" + ) + + # Try to find and update the component + updated = self._find_and_update_component(component_info, self.components) + if not updated: + self._grpc_client.log.warning( + f"Could not find component to update: '{component_name}' (ID: {component_id})" + ) + + # Handle deleted components + for component_info in tracker_response.get("deleted_components", []): + component_id = component_info["id"] + self._grpc_client.log.debug(f"Processing deleted component: ID={component_id}") + + # Try to find and remove the component + removed = self._find_and_remove_component(component_info, self.components) + if not removed: + self._grpc_client.log.warning( + f"Could not find component to delete: ID={component_id}" + ) + + # ================== HANDLE BODIES ================== + + # Handle created bodies + for created_body_info in tracker_response.get("created_bodies", []): + body_id = created_body_info["id"] + body_name = created_body_info["name"] + is_surface = created_body_info.get("is_surface", False) + self._grpc_client.log.debug( + f"Processing created body: ID={body_id}, Name='{body_name}'" + ) + + if any(body.id == body_id for body in self.bodies): + self._grpc_client.log.debug( + f"Created body '{body_name}' (ID: {body_id}) already exists at root level." + ) + continue + + new_body = self._find_and_add_body_to_design( + created_body_info, self.components, created_parts_dict, created_components_dict + ) + if not new_body: + new_body = MasterBody(body_id, body_name, self._grpc_client, is_surface=is_surface) + self._master_component.part.bodies.append(new_body) + self._clear_cached_bodies() + self._grpc_client.log.debug( + f"Added new body '{body_name}' (ID: {body_id}) to root level." + ) + + if new_body: + created_bodies_dict[body_id] = new_body + + # Handle modified bodies + for body_info in tracker_response.get("modified_bodies", []): body_id = body_info["id"] body_name = body_info["name"] self._grpc_client.log.debug( @@ -1437,8 +1598,8 @@ def _handle_modified_bodies(self, modified_bodies): if self._find_and_update_body(body_info, component): break - def _handle_deleted_bodies(self, deleted_bodies): - for body_info in deleted_bodies: + # Handle deleted bodies + for body_info in tracker_response.get("deleted_bodies", []): body_id = body_info["id"] self._grpc_client.log.debug(f"Processing deleted body: ID={body_id}") removed = False @@ -1462,31 +1623,193 @@ def _handle_deleted_bodies(self, deleted_bodies): if self._find_and_remove_body(body_info, component): break - def _handle_created_bodies(self, created_bodies): - for body_info in created_bodies: - body_id = body_info["id"] - body_name = body_info["name"] - is_surface = body_info.get("is_surface", False) - self._grpc_client.log.debug( - f"Processing created body: ID={body_id}, Name='{body_name}'" + # ================== HELPER METHODS ================== + # + # Processing order for tracker updates: + # 1. Parts (foundational - no dependencies) + # 2. Components (depend on parts via master_component.part) + # 3. Bodies (depend on parts/components as containers) + # 4. Deletions (reverse order to avoid dependency issues) + + def _find_existing_part(self, part_id): + """Find if a part with the given ID already exists.""" + # Search through master component parts + if hasattr(self, "_master_component") and self._master_component: + if self._master_component.part.id == part_id: + return self._master_component.part + + # Search through all component master parts + for component in self._get_all_components(): + if ( + hasattr(component, "_master_component") + and component._master_component + and component._master_component.part.id == part_id + ): + return component._master_component.part + + return None + + def _get_all_components(self): + """Get all components in the hierarchy recursively.""" + all_components = [] + + def _collect_components(components): + for comp in components: + all_components.append(comp) + _collect_components(comp.components) + + _collect_components(self.components) + return all_components + + def _find_and_update_part(self, part_info): + """Find and update an existing part.""" + part_id = part_info["id"] + existing_part = self._find_existing_part(part_id) + + if existing_part: + # Update part properties + if "name" in part_info: + existing_part._name = part_info["name"] + self._grpc_client.log.debug(f"Updated part '{existing_part.name}' (ID: {part_id})") + return True + + return False + + def _find_and_remove_part(self, part_info): + """Find and remove a part from the design.""" + part_id = part_info["id"] + existing_part = self._find_existing_part(part_id) + + if existing_part: + # Mark as not alive (if applicable) + if hasattr(existing_part, "_is_alive"): + existing_part._is_alive = False + self._grpc_client.log.debug(f"Removed part (ID: {part_id})") + # TODO: Implement actual removal logic based on where parts are stored + return True + + return False + + def _find_and_add_component_to_design( + self, + component_info: dict, + parent_components: list["Component"], + created_parts: dict[str, Part] | None = None, + created_master_components: dict[str, MasterComponent] | None = None, + ) -> "Component | None": + """Recursively find the appropriate parent and add a new component to it. + + Parameters + ---------- + component_info : dict + Information about the component to create. + parent_components : list + List of potential parent components to search. + created_parts : dict, optional + Dictionary of created parts from previous step. + created_master_components : dict, optional + Dictionary of created master components from current step. + + Returns + ------- + Component or None + The newly created component if successful, None otherwise. + """ + # Early return if there are no components to search through + if not parent_components: + return None + + new_component_parent_id = component_info.get("parent_id") + master_id = component_info.get("master_id") + + # Find the master component for this component + master_component = None + if created_master_components and master_id: + master_component = created_master_components.get(master_id) + + # Check if this should be added to the root design + if new_component_parent_id == self.id: + # Create the Component object with master_component + new_component = Component( + parent_component=None, + name=component_info["name"], + template=self, + grpc_client=self._grpc_client, + master_component=master_component, + preexisting_id=component_info["id"], + read_existing_comp=True, + ) + self.components.append(new_component) + self._grpc_client.log.debug(f"Added component '{component_info['id']}' to root design") + return new_component + + # Search through existing components for the parent + for component in parent_components: + if component.id == new_component_parent_id: + new_component = Component( + name=component_info["name"], + template=component, + grpc_client=self._grpc_client, + master_component=master_component, + preexisting_id=component_info["id"], + read_existing_comp=True, + ) + component.components.append(new_component) + self._grpc_client.log.debug( + f"Added component '{component_info['id']}' to component '{component.name}'" + ) + return new_component + + # Recursively search in child components + result = self._find_and_add_component_to_design( + component_info, component.components, created_parts, created_master_components ) + if result: + return result - if any(body.id == body_id for body in self.bodies): + return None + + # This method is subject to change based on how component updates are defined. + def _find_and_update_component(self, component_info, components): + """Recursively find and update an existing component in the hierarchy.""" + component_id = component_info["id"] + + for component in components: + if component.id == component_id: + # Update component properties + if "name" in component_info: + component._name = component_info["name"] self._grpc_client.log.debug( - f"Created body '{body_name}' (ID: {body_id}) already exists at root level." + f"Updated component '{component.name}' (ID: {component.id})" ) - continue + return True - added = self._find_and_add_body(body_info, self.components) - if not added: - new_body = MasterBody(body_id, body_name, self._grpc_client, is_surface=is_surface) - self._master_component.part.bodies.append(new_body) - self._clear_cached_bodies() + if self._find_and_update_component(component_info, component.components): + return True + + return False + + def _find_and_remove_component(self, component_info, components, parent_component=None): + """Recursively find and remove a component from the hierarchy.""" + component_id = component_info["id"] + + for i, component in enumerate(components): + if component.id == component_id: + component._is_alive = False + components.pop(i) self._grpc_client.log.debug( - f"Added new body '{body_name}' (ID: {body_id}) to root level." + f"Removed component '{component.name}' (ID: {component_id}) " + f"from {'root design' if parent_component is None else parent_component.name}" ) + return True + + if self._find_and_remove_component(component_info, component.components, component): + return True + + return False def _update_body(self, existing_body, body_info): + """Update an existing body with new information from tracker response.""" self._grpc_client.log.debug( f"Updating body '{existing_body.name}' " f"(ID: {existing_body.id}) with new info: {body_info}" @@ -1494,31 +1817,63 @@ def _update_body(self, existing_body, body_info): existing_body.name = body_info["name"] existing_body._template._is_surface = body_info.get("is_surface", False) - def _find_and_add_body(self, body_info, components): + def _find_and_add_body_to_design( + self, + tracked_body_info: dict, + components: list["Component"], + created_parts: dict[str, Part] | None = None, + created_components: dict[str, "Component"] | None = None, + ) -> MasterBody | None: + """Recursively find the appropriate component and add a new body to it. + + Parameters + ---------- + body_info : dict + Information about the body to create. + components : list[Component] + List of components to search. + created_parts : dict[str, Part], optional + Dictionary of created parts from previous step. + created_components : dict[str, Component], optional + Dictionary of created components from previous step. + + Returns + ------- + MasterBody | None + The newly created body if successful, None otherwise. + """ + if not components: + return None + for component in components: parent_id_for_body = component._master_component.part.id - if parent_id_for_body == body_info["parent_id"]: - new_body = MasterBody( - body_info["id"], - body_info["name"], + if parent_id_for_body == tracked_body_info.get("parent_id"): + new_master_body = MasterBody( + tracked_body_info["id"], + tracked_body_info["name"], self._grpc_client, - is_surface=body_info.get("is_surface", False), + is_surface=tracked_body_info.get("is_surface", False), ) - # component.bodies.append(new_body) - component._master_component.part.bodies.append(new_body) + + component._master_component.part.bodies.append(new_master_body) + component._clear_cached_bodies() self._grpc_client.log.debug( - f"Added new body '{new_body.name}' (ID: {new_body.id}) " + f"Added new body '{new_master_body.name}' (ID: {new_master_body.id}) " f"to component '{component.name}' (ID: {component.id})" ) - return True + return new_master_body - if self._find_and_add_body(body_info, component.components): - return True + result = self._find_and_add_body_to_design( + tracked_body_info, component.components, created_parts, created_components + ) + if result: + return result - return False + return None def _find_and_update_body(self, body_info, component): + """Recursively find and update an existing body in the component hierarchy.""" for body in component.bodies: if body.id == body_info["id"]: self._update_body(body, body_info) @@ -1535,11 +1890,11 @@ def _find_and_update_body(self, body_info, component): return False def _find_and_remove_body(self, body_info, component): + """Recursively find and remove a body from the component hierarchy.""" for body in component.bodies: body_info_id = body_info["id"] if body.id == f"{component.id}/{body_info_id}": body._is_alive = False - # component.bodies.remove(body) for bd in component._master_component.part.bodies: if bd.id == body_info_id: component._master_component.part.bodies.remove(bd) diff --git a/src/ansys/geometry/core/tools/prepare_tools.py b/src/ansys/geometry/core/tools/prepare_tools.py index 49c47d47b4..6f47377e8c 100644 --- a/src/ansys/geometry/core/tools/prepare_tools.py +++ b/src/ansys/geometry/core/tools/prepare_tools.py @@ -158,7 +158,7 @@ def extract_volume_from_faces( if not pyansys_geom.USE_TRACKER_TO_UPDATE_DESIGN: parent_design._update_design_inplace() else: - parent_design._update_from_tracker(response.get("tracker_response")) + parent_design._update_from_tracker(response.get("complete_command_response")) return get_bodies_from_ids(parent_design, bodies_ids) else: self._grpc_client.log.info("Failed to extract volume from faces...") diff --git a/tests/integration/files/hollowCylinder1.dsco b/tests/integration/files/hollowCylinder1.dsco new file mode 100644 index 0000000000..e0cceaf057 Binary files /dev/null and b/tests/integration/files/hollowCylinder1.dsco differ diff --git a/tests/integration/files/intersect-with-2-components 2.scdocx b/tests/integration/files/intersect-with-2-components 2.scdocx new file mode 100644 index 0000000000..906c7af91e Binary files /dev/null and b/tests/integration/files/intersect-with-2-components 2.scdocx differ diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index ea40a2b64c..74f88ed2f3 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -4337,3 +4337,111 @@ def test_design_point_get_named_selections(modeler: Modeler): assert any(ns.name == "design_point_ns_2" for ns in ns_list) else: assert len(ns_list) == 0 # No named selection for this design point + + +def test_check_design_update(modeler: Modeler): + """Test that design updates are tracked when USE_TRACKER_TO_UPDATE_DESIGN is enabled.""" + + # Open a disco file + design = modeler.open_file(Path(FILES_DIR, "hollowCylinder1_sc.scdocx")) + # Record initial state + initial_component_count = len(design.components) + assert initial_component_count > 0, "Design should have at least one component" + + # Get the body and faces + body = design.components[0].bodies[0] + inside_faces = [body.faces[0]] + sealing_faces = [body.faces[1], body.faces[2]] + + # Extract volume from faces - this should trigger design update tracking + modeler.prepare_tools.extract_volume_from_faces(sealing_faces, inside_faces) + + # Verify design was updated with new component + assert len(design.components) > initial_component_count, ( + "Design should have more components after extract_volume_from_faces" + ) + + # Verify first component still has bodies + assert len(design.components[0].bodies) > 0, "Component 0 should have bodies" + assert design.components[0].bodies[0].name, "Body in component 0 should have a name" + + # Verify new component was created with the extracted body + assert len(design.components[1].bodies) > 0, "Component 1 should have bodies" + assert design.components[1].bodies[0].name, "Body in component 1 should have a name" + + +def test_design_update_with_booleans(modeler: Modeler): + """Test that design updates are tracked when performing boolean operations.""" + # Open a design file with multiple components + design = modeler.open_file(Path(FILES_DIR, "intersect-with-2-components 2.scdocx")) + + # Check initial state + initial_num_components = len(design.components) + assert initial_num_components >= 3, "Design should have at least 3 components" + + # Record initial body counts + initial_bodies_comp0 = len(design.components[0].bodies) + initial_bodies_comp1 = len(design.components[1].bodies) + initial_bodies_comp2 = len(design.components[2].bodies) + + assert initial_bodies_comp0 > 0, "Component 0 should have at least one body" + assert initial_bodies_comp1 > 0, "Component 1 should have at least one body" + + # Get bodies for boolean operation + b0 = design.components[0].bodies[0] + b1 = design.components[1].bodies[0] + + # Perform unite operation + b0.unite(b1) + + # Check state after boolean operation + final_num_components = len(design.components) + + # Component 0 should still exist with the united body + final_bodies_comp0 = len(design.components[0].bodies) + assert final_bodies_comp0 > 0, "Component 0 should still have bodies after unite" + + # Get the new body and verify it has faces + new_body = design.components[0].bodies[0] + assert len(new_body.faces) > 0, "United body should have faces" + + # Component 1 should have one less body after unite + final_bodies_comp1 = len(design.components[1].bodies) + assert final_bodies_comp1 == initial_bodies_comp1 - 1, ( + "Component 1 should have one less body after unite" + ) + + # Component 2 should remain unchanged + final_bodies_comp2 = len(design.components[2].bodies) + assert final_bodies_comp2 == initial_bodies_comp2, "Component 2 should remain unchanged" + + +def test_check_design_update_2(modeler: Modeler): + """Test that design updates are tracked when USE_TRACKER_TO_UPDATE_DESIGN is enabled.""" + + # Open a disco file + design = modeler.open_file(Path(FILES_DIR, "hollowCylinder2.dsco")) + # Record initial state + initial_component_count = len(design.components) + assert initial_component_count > 0, "Design should have at least one component" + + # Get the body and faces + body = design.components[0].bodies[0] + inside_faces = [body.faces[0]] + sealing_faces = [body.faces[1], body.faces[2]] + + # Extract volume from faces - this should trigger design update tracking + modeler.prepare_tools.extract_volume_from_faces(sealing_faces, inside_faces) + + # Verify design was updated with new component + assert len(design.components) > initial_component_count, ( + "Design should have more components after extract_volume_from_faces" + ) + + # Verify first component still has bodies + assert len(design.components[0].bodies) > 0, "Component 0 should have bodies" + assert design.components[0].bodies[0].name, "Body in component 0 should have a name" + + # Verify new component was created with the extracted body + assert len(design.components[1].bodies) > 0, "Component 1 should have bodies" + assert design.components[1].bodies[0].name, "Body in component 1 should have a name"