403 lines
20 KiB
Python
403 lines
20 KiB
Python
"""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()
|