feat(deterministic-geometry): I9 point_intersection + point_tangent

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.
This commit is contained in:
Umutcan Edizsalan
2026-06-18 11:40:08 +03:00
parent 1d6a8664b3
commit c3f8883a52
3 changed files with 304 additions and 0 deletions
+141
View File
@@ -702,6 +702,147 @@ class AutoCADBackend(ABC):
"Use one of: end, mid, center, quad, perp, near." "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 @abstractmethod
async def _create_xline( async def _create_xline(
self, x: float, y: float, dx: float, dy: float, layer: str, self, x: float, y: float, dx: float, dy: float, layer: str,
+38
View File
@@ -2398,6 +2398,44 @@ async def point_from_snap(
return {"x": float(pt[0]), "y": float(pt[1])} 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( @mcp.tool(
annotations={"title": "Construction: XLine (infinite reference)", annotations={"title": "Construction: XLine (infinite reference)",
"destructiveHint": False}, "destructiveHint": False},
+125
View File
@@ -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)