Files
reducers/backend/src/relation_validators/mesh.py
T

273 lines
10 KiB
Python

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,
)