"""Topology evidence contracts for single-feature multi-position through holes.""" from __future__ import annotations from copy import deepcopy from math import pi import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) sys.path.insert(0, str(ROOT / "backend" / "engine")) from app.cad_agent.domain.verifier_registry import default_registry # noqa: E402 from cdsl_engine.runtime import rebuild_cdsl # noqa: E402 def _plate_with_holes(depth_mm: float, positions: list[list[float]] | None = None) -> dict: positions = positions or [[-2, -2, 0], [2, -2, 0], [2, 2, 0], [-2, 2, 0]] return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "topology-proof", "geometry": {"sketches": [{ "id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, "profile": {"type": "polygon", "vertices": [[-5, -5], [5, -5], [5, 5], [-5, 5]]}, }]}, "features": [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"}, { "id": "mounting_holes", "atomic_id": "hole_blind", "depends_on": ["base_add"], "params": { "diameter_mm": 2, "depth_mm": depth_mm, # All four locations belong to this one atomic feature. "positions": [{"mm": position} for position in positions], "host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}}, }, }, ], } def _plate_with_inner_contour_bores() -> dict: """A single base extrusion whose three bores come from analytic contours.""" return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "contour-bore-proof", "geometry": {"sketches": [{ "id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, "profile": {"type": "analytic_contours", "contours": [ {"role": "outer", "closed": True, "segments": [ {"type": "line", "start": [-10, -5], "end": [10, -5]}, {"type": "line", "start": [10, -5], "end": [10, 5]}, {"type": "line", "start": [10, 5], "end": [-10, 5]}, {"type": "line", "start": [-10, 5], "end": [-10, -5]}, ]}, *[ {"role": "inner", "closed": True, "segments": [{"type": "circle", "center": [center, 0], "radius_mm": 1}]} for center in (-4, 0, 4) ], ]}, }]}, "features": [{ "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base", }], } def _plate_with_mixed_host_face_chain() -> dict: """Three through holes added on alternating top/bottom host faces.""" document = _plate_with_holes(12, [[0, 0, 0]]) document["geometry"]["sketches"][0]["profile"]["vertices"] = [[-80, -20], [160, -20], [160, 20], [-80, 20]] top = {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}} bottom = {"frame": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, -1, 0], "normal": [0, 0, -1]}} document["features"][1] = { "id": "top_hole", "atomic_id": "hole_blind", "depends_on": ["base_add"], "params": {"diameter_mm": 2, "depth_mm": 12, "positions": [{"mm": [0, 0, 0]}], "host_face": top}, } document["features"].extend([ { "id": "bottom_hole", "atomic_id": "hole_blind", "depends_on": ["top_hole"], "params": {"diameter_mm": 2, "depth_mm": 12, "positions": [{"mm": [60, 0, 0]}], "host_face": bottom}, }, { "id": "final_top_hole", "atomic_id": "hole_blind", "depends_on": ["bottom_hole"], "params": {"diameter_mm": 2, "depth_mm": 12, "positions": [{"mm": [140, 0, 0]}], "host_face": top}, }, ]) return document def _sloped_side_hosted_wizard_hole() -> dict: """A real side-hosted hole shape whose OCC cylinder radius is unavailable.""" return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "side-hole-radius", "geometry": {"sketches": [{ "id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, "profile": {"type": "polygon", "vertices": [ [-16, 0], [-12, 12], [0, 16], [20, 16], [44, 17], [60, 20], [76, 17], [120, 16], [140, 16], [152, 12], [156, 0], [152, -12], [140, -16], [120, -16], [76, -17], [60, -20], [44, -17], [20, -16], [0, -16], [-12, -12], ]}, }]}, "features": [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"}, { "id": "side_hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"], "params": { "hole_type": "simple", "diameter_mm": 12, "depth_mm": 10, "end_condition": {"type": "through_all", "solidworks_code": 1}, "positions": [{"mm": [0, 0, 0]}], "host_face": {"frame": { "origin_mm": [154, 6, 5], "x_dir": [-0.31622776601683805, 0.9486832980505138, 0], "y_dir": [0, 0, 1], "normal": [0.9486832980505139, 0.316227766016838, 0], }}, }, }, ], } def _cylindrical_disc() -> dict: return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "outer-cylinder", "geometry": {"sketches": [{ "id": "disc", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, "profile": {"type": "circle", "center_mm": [0, 0], "radius_mm": 5}, }]}, "features": [{"id": "disc_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "disc"}], } def _stepped_shaft() -> dict: return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "stepped-shaft", "geometry": {"sketches": [{ "id": "profile", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, -1, 0]}, "profile": {"type": "polygon", "vertices": [[0, 0], [0, 15], [40, 15], [40, 12], [90, 12], [90, 9], [120, 9], [120, 0]]}, }]}, "features": [{ "id": "shaft", "atomic_id": "revolve_add", "depends_on": [], "sketch_id": "profile", "params": {"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}}, }], } def _flanged_sleeve() -> dict: return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "flanged-sleeve", "geometry": {"sketches": [{ "id": "profile", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, -1, 0]}, "profile": {"type": "polygon", "vertices": [[0, 0], [0, 35], [10, 35], [10, 25], [60, 25], [60, 0]]}, }]}, "features": [{ "id": "sleeve", "atomic_id": "revolve_add", "depends_on": [], "sketch_id": "profile", "params": {"angle_deg": 360, "axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}}, }], } def _plate_with_countersinks() -> dict: document = deepcopy(_plate_with_holes(12)) document["features"][1]["atomic_id"] = "hole_countersink" document["features"][1]["params"] = { "diameter_mm": 2, "depth_mm": 12, "countersink_diameter_mm": 4, "countersink_angle_rad": pi / 2, "positions": [{"mm": value} for value in [[-2, -2, 0], [2, -2, 0], [2, 2, 0], [-2, 2, 0]]], "host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}}, } return document class ThroughHoleTopologyTests(unittest.TestCase): def test_absent_target_bore_is_pending_until_the_action_creates_it(self) -> None: verdict = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 2, "count": 1, "tolerance_mm": 0.01}, {"topology": {"records": [{"kind": "face", "geometry": {"surface_type": "plane"}}]}}, ) self.assertEqual(verdict["status"], "pending") def test_multi_position_hole_blind_proves_four_through_bores_from_brep(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_holes(12), Path(temporary) / "through.step") cylinders = [item for item in result["topology_records"] if item["kind"] == "face" and item["geometry"].get("surface_type") == "cylinder"] self.assertEqual(len(cylinders), 4) self.assertTrue(all(item["geometry"].get("through") for item in cylinders)) self.assertTrue(all(item["geometry"].get("cylinder_role") == "inner" for item in cylinders)) self.assertTrue(all(len(item["geometry"].get("connected_plane_ids") or []) >= 2 for item in cylinders)) verdict = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 2, "count": 4, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) def test_analytic_contour_bores_are_coalesced_and_proven_through(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_inner_contour_bores(), Path(temporary) / "contour-bores.step") facts = {"topology": {"records": result["topology_records"]}} raw_bores = [ item for item in result["topology_records"] if item["kind"] == "face" and item["geometry"].get("surface_type") == "cylinder" and item["geometry"].get("cylinder_role") == "inner" ] self.assertGreater(len(raw_bores), 3) bore = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 2, "count": 3, "tolerance_mm": 0.01}, facts, ) self.assertEqual(bore["status"], "pass", bore) self.assertEqual(len(bore["evidence"]["through_bores"]), 3) chain = default_registry().evaluate( "collinear_through_bore_chain", {"diameter_mm": 2, "adjacent_distances_mm": [4, 4], "tolerance_mm": 0.01}, facts, ) self.assertEqual(chain["status"], "pass", chain) def test_brep_axes_and_plate_bounds_prove_equal_corner_hole_offsets(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_holes(12), Path(temporary) / "corner-holes.step") verdict = default_registry().evaluate( "rectangular_corner_through_bore_pattern", {"diameter_mm": 2, "count": 4, "edge_offset_mm": 3, "tolerance_mm": 0.01}, { "health": {"bbox_mm": result["bbox_mm"]}, "topology": {"records": result["topology_records"]}, }, ) self.assertEqual(verdict["status"], "pass", verdict) def test_blind_bores_do_not_pass_the_through_topology_claim(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_holes(5), Path(temporary) / "blind.step") verdict = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 2, "count": 4, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "fail") def test_brep_cylinder_axial_span_proves_blind_counterbore_depth(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_holes(5), Path(temporary) / "counterbore.step") verdict = default_registry().evaluate( "cylindrical_bore_depth", {"diameter_mm": 2, "count": 4, "depth_mm": 5, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) def test_brep_cone_proves_countersink_diameters_and_included_angle(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_countersinks(), Path(temporary) / "countersink.step") cones = [ item for item in result["topology_records"] if item["kind"] == "face" and item["geometry"].get("surface_type") == "cone" ] self.assertEqual(len(cones), 4) self.assertTrue(all(item["geometry"].get("cylinder_role") == "inner" for item in cones)) verdict = default_registry().evaluate( "conical_bore", {"small_diameter_mm": 2, "large_diameter_mm": 4, "included_angle_deg": 90, "count": 4, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) def test_countersink_channel_proves_through_cylindrical_bores(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_plate_with_countersinks(), Path(temporary) / "countersink-through.step") cylinders = [ item for item in result["topology_records"] if item["kind"] == "face" and item["geometry"].get("surface_type") == "cylinder" and item["geometry"].get("cylinder_role") == "inner" ] self.assertEqual(len(cylinders), 4) self.assertTrue(all(item["geometry"].get("through") for item in cylinders)) # The cone touches the top plane, so the cylinder itself has only the # bottom plane as a direct neighbour. Channel connectivity must carry # the opposite endpoint through the joined co-axial cone. self.assertTrue(all(len(item["geometry"].get("connected_plane_ids") or []) == 1 for item in cylinders)) self.assertTrue(all(len(item["geometry"].get("channel_connected_plane_ids") or []) >= 2 for item in cylinders)) verdict = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 2, "count": 4, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) def test_blind_countersink_channel_does_not_pass_through_claim(self) -> None: document = _plate_with_countersinks() document["features"][1]["params"]["depth_mm"] = 5 with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(document, Path(temporary) / "blind-countersink.step") verdict = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 2, "count": 4, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "fail", verdict) def test_external_cylindrical_wall_never_counts_as_a_through_bore(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_cylindrical_disc(), Path(temporary) / "disc.step") cylinders = [ item for item in result["topology_records"] if item["kind"] == "face" and item["geometry"].get("surface_type") == "cylinder" ] self.assertTrue(cylinders) self.assertTrue(all(item["geometry"].get("through") for item in cylinders)) self.assertTrue(all(item["geometry"].get("cylinder_role") == "outer" for item in cylinders)) verdict = default_registry().evaluate( "through_cylindrical_bore", {"diameter_mm": 10, "count": 1, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pending", verdict) def test_extruded_disc_periodic_patches_count_as_one_outer_cylindrical_surface(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_cylindrical_disc(), Path(temporary) / "disc-outer.step") verdict = default_registry().evaluate( "outer_cylindrical_surface", {"diameter_mm": 10, "count": 1, "axial_span_mm": 10, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) self.assertEqual(len(verdict["evidence"]["external_cylindrical_surface_groups"]), 1) def test_revolved_step_faces_prove_each_external_diameter(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_stepped_shaft(), Path(temporary) / "shaft.step") facts = {"topology": {"records": result["topology_records"]}} for diameter in (30, 24, 18): verdict = default_registry().evaluate( "outer_cylindrical_surface", {"diameter_mm": diameter, "count": 1, "tolerance_mm": 0.01}, facts, ) self.assertEqual(verdict["status"], "pass", verdict) def test_revolved_flange_face_proves_outer_diameter_and_axial_thickness(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl(_flanged_sleeve(), Path(temporary) / "flange.step") verdict = default_registry().evaluate( "outer_cylindrical_surface", {"diameter_mm": 70, "count": 1, "axial_span_mm": 10, "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) def test_bore_chain_proves_collinearity_in_either_topology_order_from_brep(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl( _plate_with_holes(12, [[-3.5, 0, 0], [-0.5, 0, 0], [3.5, 0, 0]]), Path(temporary) / "chain.step", ) expected = {"diameter_mm": 2, "adjacent_distances_mm": [3, 4], "tolerance_mm": 0.01} verdict = default_registry().evaluate( "collinear_through_bore_chain", expected, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) reversed_verdict = default_registry().evaluate( "collinear_through_bore_chain", expected, {"topology": {"records": list(reversed(result["topology_records"]))}}, ) self.assertEqual(reversed_verdict["status"], "pass", reversed_verdict) self.assertEqual(reversed_verdict["evidence"]["expected_orientation"], "reversed") def test_bore_chain_ignores_axial_endpoint_difference_between_host_faces(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl( _plate_with_mixed_host_face_chain(), Path(temporary) / "mixed-host-chain.step", ) verdict = default_registry().evaluate( "collinear_through_bore_chain", {"diameter_mm": 2, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.01}, {"topology": {"records": result["topology_records"]}}, ) self.assertEqual(verdict["status"], "pass", verdict) self.assertEqual(verdict["evidence"]["adjacent_distances_mm"], [60.0, 80.0]) def test_side_hosted_wizard_hole_exports_topology_when_occ_omits_cylinder_radius(self) -> None: with tempfile.TemporaryDirectory() as temporary: result = rebuild_cdsl( _sloped_side_hosted_wizard_hole(), Path(temporary) / "side-hosted-hole.step", ) cylinders = [ item for item in result["topology_records"] if item["kind"] == "face" and item["geometry"].get("surface_type") == "cylinder" ] self.assertTrue(cylinders) self.assertTrue(all(isinstance(item["geometry"].get("radius_mm"), float) for item in cylinders)) if __name__ == "__main__": unittest.main()