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/bodies.py b/src/ansys/geometry/core/_grpc/_services/v1/bodies.py index b85178f668..06d5b6e4c9 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/bodies.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/bodies.py @@ -43,6 +43,7 @@ from_tess_options_to_grpc_tess_options, from_trimmed_curve_to_grpc_trimmed_curve, from_unit_vector_to_grpc_direction, + serialize_tracked_command_response, ) @@ -1006,8 +1007,9 @@ def boolean(self, **kwargs) -> dict: # noqa: D102 if not response.tracked_command_response.command_response.success: raise ValueError(f"Boolean operation failed: {kwargs['err_msg']}") + serialized_response = serialize_tracked_command_response(response.tracked_command_response) # Return the response - formatted as a dictionary - return {"complete_command_response": response} + return {"complete_command_response": serialized_response} @protect_grpc def combine(self, **kwargs) -> dict: # noqa: D102 @@ -1032,11 +1034,14 @@ def combine(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.edit_stub.CombineIntersectBodies(request=request) + serialized_response = serialize_tracked_command_response( + response.tracked_command_response + ) # Local alias # noqa: E501 if not response.tracked_command_response.command_response.success: raise ValueError(f"Boolean operation failed: {kwargs['err_msg']}") # Return the response - formatted as a dictionary - return {"complete_command_response": response} + return {"complete_command_response": serialized_response} @protect_grpc def split_body(self, **kwargs) -> dict: # noqa: D102 diff --git a/src/ansys/geometry/core/_grpc/_services/v1/conversions.py b/src/ansys/geometry/core/_grpc/_services/v1/conversions.py index 0de2f8eb66..4d0ca8d087 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,90 @@ 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_component_ids", []) + ] + + # 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_body_ids", []) + ] + + created_faces = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "created_faces", []) + ] + modified_faces = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "modified_faces", []) + ] + deleted_faces = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "deleted_faces", []) + ] + created_edges = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "created_edges", []) + ] + modified_edges = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "modified_edges", []) + ] + deleted_edges = [ + serialize_entity_identifier(entity) + for entity in getattr(tracked_changes, "deleted_edges", []) + ] 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, + "created_faces": created_faces, + "modified_faces": modified_faces, + "deleted_faces": deleted_faces, + "created_edges": created_edges, + "modified_edges": modified_edges, + "deleted_edges": deleted_edges, } diff --git a/src/ansys/geometry/core/_grpc/_services/v1/edges.py b/src/ansys/geometry/core/_grpc/_services/v1/edges.py index ec88063492..f907f290e8 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/edges.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/edges.py @@ -222,9 +222,7 @@ def extrude_edges(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary return { "success": tracked_response.get("success"), - "created_bodies": [ - body.get("id").id for body in tracked_response.get("created_bodies") - ], + "created_bodies": [body.get("id") for body in tracked_response.get("created_bodies")], } @protect_grpc @@ -254,9 +252,7 @@ def extrude_edges_up_to(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary return { "success": tracked_response.get("success"), - "created_bodies": [ - body.get("id").id for body in tracked_response.get("created_bodies") - ], + "created_bodies": [body.get("id") for body in tracked_response.get("created_bodies")], } @protect_grpc diff --git a/src/ansys/geometry/core/_grpc/_services/v1/faces.py b/src/ansys/geometry/core/_grpc/_services/v1/faces.py index e9a806bc8b..a73569bca5 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/faces.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/faces.py @@ -352,9 +352,7 @@ def extrude_faces(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary return { "success": tracked_response.get("success"), - "created_bodies": [ - body.get("id").id for body in tracked_response.get("created_bodies") - ], + "created_bodies": [body.get("id") for body in tracked_response.get("created_bodies")], } @protect_grpc @@ -515,9 +513,7 @@ def revolve_faces_by_helix(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary return { "success": tracked_response.get("success"), - "created_bodies": [ - body.get("id").id for body in tracked_response.get("created_bodies") - ], + "created_bodies": [body.get("id") for body in tracked_response.get("created_bodies")], } @protect_grpc diff --git a/src/ansys/geometry/core/_grpc/_services/v1/model_tools.py b/src/ansys/geometry/core/_grpc/_services/v1/model_tools.py index d998e277ed..3eac063240 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/model_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/model_tools.py @@ -144,15 +144,9 @@ def move_rotate(self, **kwargs) -> dict: # noqa: D102 # Return the response as a dictionary return { "success": tracked_response.get("success"), - "modified_bodies": [ - body.get("id").id for body in tracked_response.get("modified_bodies") - ], - "modified_faces": [ - face.get("id").id for face in tracked_response.get("modified_faces") - ], - "modified_edges": [ - edge.get("id").id for edge in tracked_response.get("modified_edges") - ], + "modified_bodies": [body.get("id") for body in tracked_response.get("modified_bodies")], + "modified_faces": [face.get("id") for face in tracked_response.get("modified_faces")], + "modified_edges": [edge.get("id") for edge in tracked_response.get("modified_edges")], } @protect_grpc 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..8099ab3e5f 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 @@ -86,10 +92,15 @@ def extract_volume_from_edge_loops(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.ExtractVolumeFromEdgeLoops(request) + 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], + "tracker_response": serialized_tracker_response, } @protect_grpc @@ -105,9 +116,13 @@ def remove_rounds(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.RemoveRounds(request) + 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, + "tracker_response": serialized_tracker_response, } @protect_grpc @@ -124,9 +139,13 @@ def share_topology(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.ShareTopology(request) + 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, + "tracker_response": serialized_tracker_response, } @protect_grpc @@ -147,14 +166,15 @@ def enhanced_share_topology(self, **kwargs) -> dict: # noqa: D102 # Return the response - formatted as a dictionary return { "success": response.tracked_command_response.command_response.success, + "tracker_response": tracked_response, "found": getattr(response, "found", -1), "repaired": getattr(response, "repaired", -1), "created_bodies_monikers": [ - created_body.get("id").id + created_body.get("id") for created_body in tracked_response.get("created_bodies", []) ], "modified_bodies_monikers": [ - modified_body.get("id").id + modified_body.get("id") for modified_body in tracked_response.get("modified_bodies", []) ], } @@ -323,10 +343,15 @@ def create_box_enclosure(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.CreateEnclosureBox(request) + 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], + "tracker_response": serialized_tracker_response, } @protect_grpc @@ -349,10 +374,14 @@ def create_cylinder_enclosure(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.CreateEnclosureCylinder(request) + 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], + "tracker_response": serialized_tracker_response, } @protect_grpc @@ -372,11 +401,14 @@ def create_sphere_enclosure(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.CreateEnclosureSphere(request) - + 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], + "tracker_response": serialized_tracker_response, } @protect_grpc diff --git a/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py b/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py index a462b6a9aa..c5d26b0b05 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/repair_tools.py @@ -441,7 +441,6 @@ def fix_split_edges(self, **kwargs) -> dict: # noqa: D102 # Call the gRPC service response = self.stub.FixSplitEdges(request) - # Return the response - formatted as a dictionary return serialize_repair_command_response(response.result) diff --git a/src/ansys/geometry/core/connection/client.py b/src/ansys/geometry/core/connection/client.py index 12e08ada95..7330ed1e2e 100644 --- a/src/ansys/geometry/core/connection/client.py +++ b/src/ansys/geometry/core/connection/client.py @@ -171,13 +171,14 @@ def wait_until_healthy( # If transport mode is not specified and a channel creation is required, raise an error if channel_creation_required: if transport_mode is None: - raise ValueError( - "Transport mode must be specified." - " Use 'transport_mode' parameter with one of the possible options." - " Options are: 'insecure', 'uds', 'wnua', 'mtls'. See the following" - " documentation for more details:" - " https://geometry.docs.pyansys.com/version/stable/user_guide/connection.html#securing-connections" - ) + # raise ValueError( + # "Transport mode must be specified." + # " Use 'transport_mode' parameter with one of the possible options." + # " Options are: 'insecure', 'uds', 'wnua', 'mtls'. See the following" + # " documentation for more details:" + # " https://geometry.docs.pyansys.com/version/stable/user_guide/connection.html#securing-connections" + # ) + transport_mode = "insecure" else: from ansys.tools.common.cyberchannel import verify_transport_mode diff --git a/src/ansys/geometry/core/designer/design.py b/src/ansys/geometry/core/designer/design.py index b958f7056d..52f54aa76a 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( + 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,189 @@ 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})") + 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. + """ + 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=self, + 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"], + parent_component=component, + 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 - if any(body.id == body_id for body in self.bodies): + # 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 + + 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,33 +1813,71 @@ 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( + 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( + 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"]: + # Use master_id if available, otherwise use template id, otherwise use body id + body_master_id = ( + getattr(body, "master_id", None) + or (body._template.id if hasattr(body, "_template") and body._template else None) + or body.id + ) + if body_master_id == body_info["id"]: self._update_body(body, body_info) self._grpc_client.log.debug( f"Updated body '{body.name}' (ID: {body.id}) in component " @@ -1535,11 +1892,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/src/ansys/geometry/core/tools/problem_areas.py b/src/ansys/geometry/core/tools/problem_areas.py index 253c0ac63f..6121a224a6 100644 --- a/src/ansys/geometry/core/tools/problem_areas.py +++ b/src/ansys/geometry/core/tools/problem_areas.py @@ -142,7 +142,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -196,7 +196,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -251,7 +251,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -305,7 +305,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -359,7 +359,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -413,7 +413,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -467,7 +467,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -521,7 +521,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -570,7 +570,7 @@ def fix(self) -> RepairToolMessage: 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")) message = create_repair_message_from_response(response) return message @@ -624,7 +624,7 @@ def fix(self) -> RepairToolMessage: 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")) ## The tool does not return the created or modified objects. ## https://github.com/ansys/pyansys-geometry/issues/1319 diff --git a/src/ansys/geometry/core/tools/repair_tools.py b/src/ansys/geometry/core/tools/repair_tools.py index 2a76abd612..d900fd7ee9 100644 --- a/src/ansys/geometry/core/tools/repair_tools.py +++ b/src/ansys/geometry/core/tools/repair_tools.py @@ -589,7 +589,7 @@ def find_and_fix_short_edges( if not pyansys_geometry.USE_TRACKER_TO_UPDATE_DESIGN: parent_design._update_design_inplace() else: - parent_design._update_from_tracker(response["tracker_response"]) + parent_design._update_from_tracker(response["complete_command_response"]) # Build the response message return create_repair_message_from_response(response) @@ -641,7 +641,7 @@ def find_and_fix_extra_edges( if not pyansys_geometry.USE_TRACKER_TO_UPDATE_DESIGN: parent_design._update_design_inplace() else: - parent_design._update_from_tracker(response["tracker_response"]) + parent_design._update_from_tracker(response["complete_command_response"]) # Build the response message return create_repair_message_from_response(response) @@ -710,7 +710,7 @@ def find_and_fix_split_edges( if not pyansys_geometry.USE_TRACKER_TO_UPDATE_DESIGN: parent_design._update_design_inplace() else: - parent_design._update_from_tracker(response["tracker_response"]) + parent_design._update_from_tracker(response["complete_command_response"]) # Build the response message return create_repair_message_from_response(response) @@ -763,7 +763,7 @@ def find_and_fix_simplify( if not pyansys_geometry.USE_TRACKER_TO_UPDATE_DESIGN: parent_design._update_design_inplace() else: - parent_design._update_from_tracker(response["tracker_response"]) + parent_design._update_from_tracker(response["complete_command_response"]) # Build the response message return create_repair_message_from_response(response) @@ -837,7 +837,7 @@ def find_and_fix_stitch_faces( if not pyansys_geometry.USE_TRACKER_TO_UPDATE_DESIGN: parent_design._update_design_inplace() else: - parent_design._update_from_tracker(response["tracker_response"]) + parent_design._update_from_tracker(response["complete_command_response"]) # Build the response message return create_repair_message_from_response(response) diff --git a/tests/_incompatible_tests.yml b/tests/_incompatible_tests.yml index 511ad293d6..c30c51d39c 100644 --- a/tests/_incompatible_tests.yml +++ b/tests/_incompatible_tests.yml @@ -383,3 +383,8 @@ backends: - tests/integration/test_design.py::test_nurbs_surface_body_creation # Edge tessellation added in 26.1 - tests/integration/test_tessellation.py::test_body_tessellate_with_edges + # Tracker is introduced in 25.2 + - tests/integration/test_design.py::test_design_update_with_booleans + - tests/integration/test_design.py::test_check_design_update + - tests/integration/test_design.py::test_check_design_update_2 + 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/hollowCylinder1_sc.scdocx b/tests/integration/files/hollowCylinder1_sc.scdocx new file mode 100644 index 0000000000..032056ac4f Binary files /dev/null and b/tests/integration/files/hollowCylinder1_sc.scdocx differ diff --git a/tests/integration/files/hollowCylinder2.dsco b/tests/integration/files/hollowCylinder2.dsco new file mode 100644 index 0000000000..6c7a5d9a5f Binary files /dev/null and b/tests/integration/files/hollowCylinder2.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"