From c3f8883a529036c33d345aca1b63d5550536b786 Mon Sep 17 00:00:00 2001 From: Umutcan Edizsalan Date: Thu, 18 Jun 2026 11:40:08 +0300 Subject: [PATCH] feat(deterministic-geometry): I9 point_intersection + point_tangent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New concrete base methods (backend-agnostic, use entity_get + pure math): - AutoCADBackend.point_intersection: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE with ref_x/ref_y disambiguation for multiple candidates. - AutoCADBackend.point_tangent: external-point-to-circle tangent touch-point with ref_x/ref_y for side selection; correct perpendicularity formula (T-F)⊥(T-C) via cos(θ-α)=-r/d. Registered as MCP tools: point_intersection, point_tangent. 13 new tests in tests/test_meta_tools_geometry.py. 226 tests total. --- backends/base.py | 141 ++++++++++++++++++++++++++++++ server.py | 38 ++++++++ tests/test_meta_tools_geometry.py | 125 ++++++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 tests/test_meta_tools_geometry.py diff --git a/backends/base.py b/backends/base.py index c62b057..fb9afa2 100644 --- a/backends/base.py +++ b/backends/base.py @@ -702,6 +702,147 @@ class AutoCADBackend(ABC): "Use one of: end, mid, center, quad, perp, near." ) + async def point_intersection( + self, + handle1: str, + handle2: str, + ref_x: float | None = None, + ref_y: float | None = None, + ) -> tuple[float, float]: + """Return the intersection point of two geometry entities. + + Supports LINE-LINE, LINE-CIRCLE, and CIRCLE-CIRCLE cases. When + multiple intersections exist (e.g. two circles), ``ref_x``/``ref_y`` + selects the candidate nearest to that reference point. If omitted the + first candidate is returned. + + Raises RuntimeError when no real intersection exists or entity types + are unsupported. + """ + info1 = await self.entity_get(handle1) + info2 = await self.entity_get(handle2) + t1, t2 = info1.type.upper(), info2.type.upper() + props1, props2 = info1.properties, info2.properties + + candidates: list[tuple[float, float]] = [] + + if t1 == "LINE" and t2 == "LINE": + # Line-line intersection via parametric form + x1, y1 = props1["start"][0], props1["start"][1] + x2, y2 = props1["end"][0], props1["end"][1] + x3, y3 = props2["start"][0], props2["start"][1] + x4, y4 = props2["end"][0], props2["end"][1] + dx1, dy1 = x2 - x1, y2 - y1 + dx2, dy2 = x4 - x3, y4 - y3 + denom = dx1 * dy2 - dy1 * dx2 + if abs(denom) < 1e-12: + raise RuntimeError("point_intersection: lines are parallel or coincident.") + t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom + candidates = [(x1 + t * dx1, y1 + t * dy1)] + + elif {t1, t2} == {"LINE", "CIRCLE"}: + line_p = props1 if t1 == "LINE" else props2 + circ_p = props2 if t1 == "LINE" else props1 + lx1, ly1 = line_p["start"][0], line_p["start"][1] + lx2, ly2 = line_p["end"][0], line_p["end"][1] + cx, cy = circ_p["center"][0], circ_p["center"][1] + r = circ_p["radius"] + dx, dy = lx2 - lx1, ly2 - ly1 + fx, fy = lx1 - cx, ly1 - cy + a = dx * dx + dy * dy + b = 2.0 * (fx * dx + fy * dy) + c = fx * fx + fy * fy - r * r + disc = b * b - 4.0 * a * c + if disc < 0: + raise RuntimeError("point_intersection: line does not intersect circle.") + sq = math.sqrt(disc) + for sign in (1, -1): + t = (-b + sign * sq) / (2.0 * a) + candidates.append((lx1 + t * dx, ly1 + t * dy)) + + elif t1 == "CIRCLE" and t2 == "CIRCLE": + cx1, cy1, r1 = props1["center"][0], props1["center"][1], props1["radius"] + cx2, cy2, r2 = props2["center"][0], props2["center"][1], props2["radius"] + dx, dy = cx2 - cx1, cy2 - cy1 + d = math.hypot(dx, dy) + if d < 1e-12: + raise RuntimeError("point_intersection: circles are concentric.") + if d > r1 + r2 + 1e-9 or d < abs(r1 - r2) - 1e-9: + raise RuntimeError("point_intersection: circles do not intersect.") + a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d) + h_sq = r1 * r1 - a * a + h = math.sqrt(max(h_sq, 0.0)) + mx = cx1 + a * dx / d + my = cy1 + a * dy / d + candidates = [ + (mx + h * dy / d, my - h * dx / d), + (mx - h * dy / d, my + h * dx / d), + ] + + else: + raise RuntimeError( + f"point_intersection: unsupported entity combination {t1}+{t2}. " + "Supported: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE." + ) + + if len(candidates) == 1: + return candidates[0] + if ref_x is not None and ref_y is not None: + return min( + candidates, + key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2, + ) + return candidates[0] + + async def point_tangent( + self, + circle_handle: str, + from_x: float, + from_y: float, + ref_x: float | None = None, + ref_y: float | None = None, + ) -> tuple[float, float]: + """Return the tangent point on a circle from an external point. + + When two tangent points exist, ``ref_x``/``ref_y`` selects the + nearer one; without it, the first is returned (counter-clockwise from + the from-point—circle-center line). + + Raises RuntimeError if the point is inside the circle. + """ + info = await self.entity_get(circle_handle) + if info.type.upper() != "CIRCLE": + raise RuntimeError( + f"point_tangent: handle {circle_handle!r} is {info.type}, expected CIRCLE." + ) + cx, cy = info.properties["center"][0], info.properties["center"][1] + r = info.properties["radius"] + fx, fy = float(from_x), float(from_y) + dx, dy = cx - fx, cy - fy + d = math.hypot(dx, dy) + if d < r - 1e-9: + raise RuntimeError( + f"point_tangent: from-point ({fx}, {fy}) is inside the circle " + f"(center ({cx}, {cy}), radius {r})." + ) + if d < 1e-12: + raise RuntimeError("point_tangent: from-point coincides with circle center.") + # Tangent point T lies on the circle such that (T-F)⊥(T-C). + # Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two + # circle angles θ at which the tangent touches the perimeter. + alpha = math.atan2(dy, dx) # direction from F to C + acos_neg = math.acos(-r / d) # in (π/2, π) for external point + candidates: list[tuple[float, float]] = [ + (cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)), + (cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)), + ] + if ref_x is not None and ref_y is not None: + return min( + candidates, + key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2, + ) + return candidates[0] + @abstractmethod async def _create_xline( self, x: float, y: float, dx: float, dy: float, layer: str, diff --git a/server.py b/server.py index 969357d..7a3bb0b 100644 --- a/server.py +++ b/server.py @@ -2398,6 +2398,44 @@ async def point_from_snap( return {"x": float(pt[0]), "y": float(pt[1])} +@mcp.tool( + annotations={"title": "Point: Intersection (deterministic)", "readOnlyHint": True}, + tags={"premium", "snap"}, +) +async def point_intersection( + handle1: Annotated[str, "First entity handle (LINE or CIRCLE)"], + handle2: Annotated[str, "Second entity handle (LINE or CIRCLE)"], + ref_x: Annotated[float | None, "Reference X to pick nearest candidate when multiple exist"] = None, + ref_y: Annotated[float | None, "Reference Y"] = None, + ctx: Context = None, +) -> dict: + """Compute the intersection of two geometry entities (LINE-LINE, LINE-CIRCLE, + CIRCLE-CIRCLE). When two candidates exist, ref_x/ref_y selects the nearest. + Returns {x, y}. + """ + pt = await _backend(ctx).point_intersection(handle1, handle2, ref_x, ref_y) + return {"x": float(pt[0]), "y": float(pt[1])} + + +@mcp.tool( + annotations={"title": "Point: Tangent from external point", "readOnlyHint": True}, + tags={"premium", "snap"}, +) +async def point_tangent( + circle_handle: Annotated[str, "Handle of the CIRCLE entity"], + from_x: Annotated[float, "X of the external point"], + from_y: Annotated[float, "Y of the external point"], + ref_x: Annotated[float | None, "Reference X to pick nearest tangent point when two exist"] = None, + ref_y: Annotated[float | None, "Reference Y"] = None, + ctx: Context = None, +) -> dict: + """Compute the tangent point on a circle from an external point. + Returns {x, y}. Raises if the from-point is inside the circle. + """ + pt = await _backend(ctx).point_tangent(circle_handle, from_x, from_y, ref_x, ref_y) + return {"x": float(pt[0]), "y": float(pt[1])} + + @mcp.tool( annotations={"title": "Construction: XLine (infinite reference)", "destructiveHint": False}, diff --git a/tests/test_meta_tools_geometry.py b/tests/test_meta_tools_geometry.py new file mode 100644 index 0000000..734807c --- /dev/null +++ b/tests/test_meta_tools_geometry.py @@ -0,0 +1,125 @@ +"""Tests for deterministic geometry helpers: point_intersection and point_tangent.""" + +from __future__ import annotations + +import math + +import pytest + +pytestmark = pytest.mark.asyncio + + +# --------------------------------------------------------------------------- +# point_intersection +# --------------------------------------------------------------------------- + +class TestPointIntersection: + async def test_line_line_perpendicular(self, backend): + """Horizontal and vertical lines cross at (50, 50).""" + h = await backend.entity_create_line(0, 50, 100, 50) + v = await backend.entity_create_line(50, 0, 50, 100) + pt = await backend.point_intersection(h.handle, v.handle) + assert abs(pt[0] - 50.0) < 1e-9 + assert abs(pt[1] - 50.0) < 1e-9 + + async def test_line_line_diagonal(self, backend): + """y=x and y=100-x intersect at (50, 50).""" + l1 = await backend.entity_create_line(0, 0, 100, 100) + l2 = await backend.entity_create_line(0, 100, 100, 0) + pt = await backend.point_intersection(l1.handle, l2.handle) + assert abs(pt[0] - 50.0) < 1e-9 + assert abs(pt[1] - 50.0) < 1e-9 + + async def test_line_line_parallel_raises(self, backend): + """Parallel lines raise RuntimeError.""" + l1 = await backend.entity_create_line(0, 0, 100, 0) + l2 = await backend.entity_create_line(0, 10, 100, 10) + with pytest.raises(RuntimeError, match="parallel"): + await backend.point_intersection(l1.handle, l2.handle) + + async def test_line_circle_two_intersections_ref_picks_nearest(self, backend): + """Horizontal line through circle center gives two intersections; ref selects.""" + circ = await backend.entity_create_circle(50, 50, 20) + line = await backend.entity_create_line(0, 50, 100, 50) + # Left intersection (~30, 50) — ref closer to x=0 + left = await backend.point_intersection(circ.handle, line.handle, ref_x=0, ref_y=50) + assert abs(left[0] - 30.0) < 1e-6 + # Right intersection (~70, 50) — ref closer to x=100 + right = await backend.point_intersection(line.handle, circ.handle, ref_x=100, ref_y=50) + assert abs(right[0] - 70.0) < 1e-6 + + async def test_line_circle_tangent_single_solution(self, backend): + """Line tangent to circle gives one solution (discriminant ≈ 0).""" + circ = await backend.entity_create_circle(50, 50, 20) + # Horizontal line at y=70 is tangent to circle (center y=50, r=20) + line = await backend.entity_create_line(0, 70, 100, 70) + pt = await backend.point_intersection(circ.handle, line.handle) + assert abs(pt[1] - 70.0) < 1e-6 + assert abs(pt[0] - 50.0) < 1e-6 + + async def test_line_circle_no_intersection_raises(self, backend): + """Line that misses the circle raises RuntimeError.""" + circ = await backend.entity_create_circle(50, 50, 10) + line = await backend.entity_create_line(0, 200, 100, 200) + with pytest.raises(RuntimeError, match="not intersect"): + await backend.point_intersection(circ.handle, line.handle) + + async def test_circle_circle_two_intersections_ref_picks_nearest(self, backend): + """Two circles: unit radius at (0,0) and (1,0); intersect at ±(0.5, √3/2).""" + c1 = await backend.entity_create_circle(0, 0, 1) + c2 = await backend.entity_create_circle(1, 0, 1) + top = await backend.point_intersection(c1.handle, c2.handle, ref_x=0.5, ref_y=1) + assert abs(top[0] - 0.5) < 1e-6 + assert abs(top[1] - math.sqrt(3) / 2.0) < 1e-6 + + async def test_circle_circle_no_intersection_raises(self, backend): + """Non-overlapping circles raise RuntimeError.""" + c1 = await backend.entity_create_circle(0, 0, 5) + c2 = await backend.entity_create_circle(100, 0, 5) + with pytest.raises(RuntimeError, match="not intersect"): + await backend.point_intersection(c1.handle, c2.handle) + + async def test_unsupported_types_raise(self, backend): + """ARC-ARC is not supported.""" + a1 = await backend.entity_create_arc(0, 0, 10, 0, 90) + a2 = await backend.entity_create_arc(20, 0, 10, 90, 180) + with pytest.raises(RuntimeError, match="unsupported"): + await backend.point_intersection(a1.handle, a2.handle) + + +# --------------------------------------------------------------------------- +# point_tangent +# --------------------------------------------------------------------------- + +class TestPointTangent: + async def test_tangent_from_outside(self, backend): + """From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5).""" + circ = await backend.entity_create_circle(5, 0, 3) + # from_point (0,0), circle (5,0) r=3; d=5 + # tangent_length = sqrt(25-9) = 4; cos_a = 3/5; sin_a = 4/5 + pt = await backend.point_tangent(circ.handle, 0, 0, ref_x=0, ref_y=3) + dist = math.hypot(pt[0] - 5, pt[1] - 0) + assert abs(dist - 3.0) < 1e-6 # point is on circle + # Verify perpendicularity: (pt - center) · (pt - from) == 0 + dot = (pt[0] - 5) * (pt[0] - 0) + (pt[1] - 0) * (pt[1] - 0) + assert abs(dot) < 1e-6 + + async def test_tangent_ref_selects_side(self, backend): + """ref_y > 0 selects upper tangent, ref_y < 0 selects lower.""" + circ = await backend.entity_create_circle(10, 0, 6) + upper = await backend.point_tangent(circ.handle, 0, 0, ref_x=5, ref_y=10) + lower = await backend.point_tangent(circ.handle, 0, 0, ref_x=5, ref_y=-10) + assert upper[1] > 0 + assert lower[1] < 0 + + async def test_point_inside_circle_raises(self, backend): + """From-point inside circle raises RuntimeError.""" + circ = await backend.entity_create_circle(0, 0, 10) + with pytest.raises(RuntimeError, match="inside"): + await backend.point_tangent(circ.handle, 1, 1) + + async def test_wrong_entity_type_raises(self, backend): + """Passing a LINE handle raises RuntimeError.""" + line = await backend.entity_create_line(0, 0, 100, 0) + with pytest.raises(RuntimeError, match="expected CIRCLE"): + await backend.point_tangent(line.handle, 0, 50)