from __future__ import annotations from typing import Literal from ..models import ComponentManifest, ConcreteRelation, ValidationCheck from .base import ( BaseRelationValidator, RelationValidationContext, add_check, constraint_id_in_model, find_manifest_constraint, relation_code, xy_distance, ) def _component_pair( context: RelationValidationContext, relation: ConcreteRelation, ) -> tuple[ComponentManifest | None, ComponentManifest | None]: return ( context.component(relation.source_component_id), context.component(relation.target_component_id), ) def _gear_compatibility( source: ComponentManifest | None, target: ComponentManifest | None, *, relation_type: Literal["external_mesh", "internal_mesh"], ) -> dict[str, object]: if source is None or target is None: return {"passed": False, "reason": "missing_component"} source_helix_angle = source.helix_angle_deg target_helix_angle = target.helix_angle_deg source_hand = source.helix_hand target_hand = target.helix_hand source_tooth_form = source.tooth_form target_tooth_form = target.tooth_form base_passed = ( source.kind == "gear" and target.kind == "gear" and source.module_mm is not None and target.module_mm is not None and abs(source.module_mm - target.module_mm) <= 1e-12 and source.pressure_angle_deg is not None and target.pressure_angle_deg is not None and abs(source.pressure_angle_deg - target.pressure_angle_deg) <= 1e-12 and source_tooth_form in {"spur", "helical"} and target_tooth_form == source_tooth_form ) if source_tooth_form == "spur" and target_tooth_form == "spur": tooth_form_passed = ( abs(float(source_helix_angle or 0.0)) <= 1e-12 and abs(float(target_helix_angle or 0.0)) <= 1e-12 ) hand_rule = "not_applicable_for_spur" elif source_tooth_form == "helical" and target_tooth_form == "helical": angles_present = source_helix_angle is not None and target_helix_angle is not None equal_angle = ( angles_present and abs(float(source_helix_angle) - float(target_helix_angle)) <= 1e-12 ) if relation_type == "external_mesh": hand_rule = "opposite" hands_passed = source_hand is not None and target_hand is not None and source_hand != target_hand else: hand_rule = "same" hands_passed = source_hand is not None and target_hand is not None and source_hand == target_hand tooth_form_passed = bool(angles_present and equal_angle and hands_passed) else: tooth_form_passed = False hand_rule = "unsupported_or_missing_tooth_form" fields = { "source_kind": source.kind, "target_kind": target.kind, "source_module": source.module_mm, "target_module": target.module_mm, "source_pressure_angle": source.pressure_angle_deg, "target_pressure_angle": target.pressure_angle_deg, "source_tooth_form": source_tooth_form, "target_tooth_form": target_tooth_form, "source_helix_angle_deg": source_helix_angle, "target_helix_angle_deg": target_helix_angle, "source_helix_hand": source_hand, "target_helix_hand": target_hand, "helical_hand_rule": hand_rule, } passed = base_passed and tooth_form_passed return {"passed": passed, **fields} class ExternalMeshValidator(BaseRelationValidator): relation_type = "external_mesh" def validate_static( self, context: RelationValidationContext, relation: ConcreteRelation, checks: list[ValidationCheck], ) -> None: source_node = context.node(relation.source_component_id) target_node = context.node(relation.target_component_id) source, target = _component_pair(context, relation) compatibility = _gear_compatibility(source, target, relation_type=self.relation_type) add_check( checks, section="static", code=relation_code(relation, "gear_compatibility"), passed=bool(compatibility["passed"]) and source_node is not None and target_node is not None and source_node.gear_type == "external" and target_node.gear_type == "external", message="external mesh connects compatible external gears with valid tooth-form pairing", actual={ **compatibility, "source_gear_type": source_node.gear_type if source_node else None, "target_gear_type": target_node.gear_type if target_node else None, }, expected="external gear pair with equal module, pressure angle, tooth form, and valid helix hand rule", ) def validate_assembly( self, context: RelationValidationContext, relation: ConcreteRelation, checks: list[ValidationCheck], ) -> None: constraint_id = find_manifest_constraint( context.snapshot, relation_type=relation.relation_type, component_a=relation.source_component_id, component_b=relation.target_component_id, ) add_check( checks, section="assembly", code=relation_code(relation, "cad_constraint"), passed=constraint_id_in_model( context.snapshot, constraint_id=constraint_id, relation_type=relation.relation_type, ), message="external mesh relation exists in manifest and model constraints", actual=constraint_id, expected=f"{relation.source_component_id}<->{relation.target_component_id}", ) def validate_geometry( self, context: RelationValidationContext, relation: ConcreteRelation, checks: list[ValidationCheck], ) -> None: source, target = _component_pair(context, relation) origin_a = context.placement_origin(relation.source_component_id) origin_b = context.placement_origin(relation.target_component_id) if not source or not target or not origin_a or not origin_b: actual = "missing_component_or_placement" passed = False expected = None else: expected = ( None if source.pitch_radius_mm is None or target.pitch_radius_mm is None else source.pitch_radius_mm + target.pitch_radius_mm ) actual = xy_distance(origin_a, origin_b) passed = expected is not None and abs(actual - expected) <= 1e-6 add_check( checks, section="geometry", code=relation_code(relation, "center_distance"), passed=passed, message="external mesh pitch center distance equals the sum of pitch radii", actual=actual, expected=expected, ) class InternalMeshValidator(BaseRelationValidator): relation_type = "internal_mesh" def validate_static( self, context: RelationValidationContext, relation: ConcreteRelation, checks: list[ValidationCheck], ) -> None: source_node = context.node(relation.source_component_id) target_node = context.node(relation.target_component_id) source, target = _component_pair(context, relation) compatibility = _gear_compatibility(source, target, relation_type=self.relation_type) add_check( checks, section="static", code=relation_code(relation, "gear_compatibility"), passed=bool(compatibility["passed"]) and source_node is not None and target_node is not None and source_node.gear_type == "internal" and target_node.gear_type == "external", message="internal mesh connects an internal gear to a compatible external gear with valid tooth-form pairing", actual={ **compatibility, "source_gear_type": source_node.gear_type if source_node else None, "target_gear_type": target_node.gear_type if target_node else None, }, expected="internal-to-external gear pair with equal module, pressure angle, tooth form, and valid helix hand rule", ) def validate_assembly( self, context: RelationValidationContext, relation: ConcreteRelation, checks: list[ValidationCheck], ) -> None: constraint_id = find_manifest_constraint( context.snapshot, relation_type=relation.relation_type, component_a=relation.source_component_id, component_b=relation.target_component_id, ) add_check( checks, section="assembly", code=relation_code(relation, "cad_constraint"), passed=constraint_id_in_model( context.snapshot, constraint_id=constraint_id, relation_type=relation.relation_type, ), message="internal mesh relation exists in manifest and model constraints", actual=constraint_id, expected=f"{relation.source_component_id}<->{relation.target_component_id}", ) def validate_geometry( self, context: RelationValidationContext, relation: ConcreteRelation, checks: list[ValidationCheck], ) -> None: source, target = _component_pair(context, relation) origin_a = context.placement_origin(relation.source_component_id) origin_b = context.placement_origin(relation.target_component_id) if not source or not target or not origin_a or not origin_b: actual = "missing_component_or_placement" passed = False expected = None else: expected = ( None if source.pitch_radius_mm is None or target.pitch_radius_mm is None else source.pitch_radius_mm - target.pitch_radius_mm ) actual = xy_distance(origin_a, origin_b) passed = expected is not None and expected >= 0.0 and abs(actual - expected) <= 1e-6 add_check( checks, section="geometry", code=relation_code(relation, "center_distance"), passed=passed, message="internal mesh pitch center distance equals the pitch-radius difference", actual=actual, expected=expected, )