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:
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user