Files
autocad-mcp-pro/tests/test_measure.py
T
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00

201 lines
9.0 KiB
Python

"""T0.5 — measuring the drawing, not the caller's memory of it.
``analysis_measure_area`` never touched the document: it ran a shoelace over
points the *caller* typed in. So "what is the area of the polyline I just drew?"
had no answer — the model had to recall the vertices, which is precisely what
CLAUDE.md's no-coordinate-guessing rule forbids.
The only workflow available was worse than absent. ``entity_get`` reported
LWPOLYLINE vertices through the default ``get_points()`` format and kept just
x and y, silently dropping the **bulge** that makes an edge an arc. Measured on a
100x100 square with one edge bulged into an outward semicircle: shoelace over the
reported points gives 10000 against a true 13926.9908 — **28.20% low** — and the
perimeter 400 against 457.0796, **12.49% low**, with nothing in the response
saying so. ``props["length"]`` was dead code: ``LWPolyline`` has no ``length()``
method, so the line raised into a debug log and the key simply never appeared.
Bulge geometry is always a circular arc, so this needs no approximation: area is
the shoelace plus each arc's signed circular segment, and perimeter uses arc
length. The tests below check that against two independent oracles — closed-form
values worked out by hand, and ezdxf's own flattening at a fine tolerance.
"""
from __future__ import annotations
import math
import pytest
from engineering.measure import (
ellipse_area,
ellipse_perimeter_ramanujan,
is_self_intersecting,
polygon_area_perimeter,
)
#: 100x100 square, bottom edge bulged outward into a semicircle (bulge=1.0 means
#: a 180 deg sweep, so the chord of 100 is the diameter and the radius is 50).
SQUARE_WITH_SEMICIRCLE = [
(0.0, 0.0, 0.0),
(100.0, 0.0, 1.0),
(100.0, 100.0, 0.0),
(0.0, 100.0, 0.0),
]
SEMICIRCLE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0 # 13926.990817
SEMICIRCLE_PERIMETER = 300.0 + math.pi * 50.0 # 457.079633
PLAIN_SQUARE = [(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)]
def _flattened_oracle(vertices, closed, segments_per_arc=200_000):
"""Area and perimeter from a dense polyline approximation of the same shape.
Independent of the implementation under test: it subdivides each arc and
runs a plain shoelace, so agreement to 6 decimal places is real evidence
rather than a formula checking itself.
"""
points: list[tuple[float, float]] = []
count = len(vertices)
limit = count if closed else count - 1
for i in range(limit):
x1, y1, bulge = vertices[i]
x2, y2, _ = vertices[(i + 1) % count]
points.append((x1, y1))
if abs(bulge) < 1e-12:
continue
theta = 4.0 * math.atan(bulge)
chord = math.hypot(x2 - x1, y2 - y1)
radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
# Centre lies on the perpendicular bisector, offset by the sagitta side.
mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
height = radius * math.cos(abs(theta) / 2.0)
ux, uy = (y2 - y1) / chord, -(x2 - x1) / chord
sign = 1.0 if theta > 0 else -1.0
cx, cy = mx - sign * ux * height, my - sign * uy * height
a1 = math.atan2(y1 - cy, x1 - cx)
steps = max(2, segments_per_arc // max(1, limit))
for step in range(1, steps):
angle = a1 + theta * step / steps
points.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
if not closed:
points.append((vertices[-1][0], vertices[-1][1]))
total = len(points)
shoelace = sum(
points[i][0] * points[(i + 1) % total][1] - points[(i + 1) % total][0] * points[i][1]
for i in range(total)
)
perimeter = sum(
math.dist(points[i], points[(i + 1) % total]) for i in range(total if closed else total - 1)
)
return abs(shoelace) / 2.0, perimeter
# ── the arithmetic the server was missing ───────────────────────────────────
def test_a_straight_polygon_is_the_plain_shoelace():
area, perimeter = polygon_area_perimeter(PLAIN_SQUARE, closed=True)
assert area == pytest.approx(10000.0)
assert perimeter == pytest.approx(400.0)
def test_an_outward_bulge_adds_its_circular_segment():
"""The 28.20% the old path lost, recovered exactly."""
area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True)
assert area == pytest.approx(SEMICIRCLE_AREA, rel=1e-12)
assert perimeter == pytest.approx(SEMICIRCLE_PERIMETER, rel=1e-12)
# ...and it really is 28% more than the xy-only answer.
assert area / 10000.0 == pytest.approx(1.3926990817, rel=1e-9)
def test_an_inward_bulge_subtracts_it():
inward = [(0.0, 0.0, 0.0), (100.0, 0.0, -0.5), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)]
area, perimeter = polygon_area_perimeter(inward, closed=True)
oracle_area, oracle_perimeter = _flattened_oracle(inward, closed=True)
assert area == pytest.approx(oracle_area, rel=1e-9)
assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7)
assert area < 10000.0, "an inward bulge must remove material"
def test_a_bulge_matches_a_dense_flattening_of_the_same_arc():
area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True)
oracle_area, oracle_perimeter = _flattened_oracle(SQUARE_WITH_SEMICIRCLE, closed=True)
assert area == pytest.approx(oracle_area, rel=1e-9)
assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7)
def test_an_open_boundary_is_closed_implicitly(bulge_free=PLAIN_SQUARE):
"""AutoCAD's AREA closes an open boundary; the caller is told it happened."""
open_area, open_perimeter = polygon_area_perimeter(bulge_free, closed=False)
assert open_area == pytest.approx(10000.0), "the area is of the closed region"
assert open_perimeter == pytest.approx(300.0), "the walked length is the open one"
# ── the trap shoelace hides ─────────────────────────────────────────────────
def test_a_self_intersecting_loop_is_detected():
"""Shoelace CANCELS crossed lobes: a bowtie of two 2500 triangles reads 0."""
bowtie = [(0.0, 0.0, 0.0), (100.0, 100.0, 0.0), (100.0, 0.0, 0.0), (0.0, 100.0, 0.0)]
area, _ = polygon_area_perimeter(bowtie, closed=True)
assert area == pytest.approx(0.0, abs=1e-9), "this is exactly why it must be flagged"
assert is_self_intersecting([(x, y) for x, y, _ in bowtie]) is True
def test_a_simple_polygon_is_not_flagged():
assert is_self_intersecting([(x, y) for x, y, _ in PLAIN_SQUARE]) is False
def test_self_intersection_reports_unknown_rather_than_guessing_on_huge_loops():
"""The check is O(n^2); above the cap it must answer None, not False."""
from engineering.measure import MAX_SELF_INTERSECT_VERTS
huge = [(float(i), float(i % 7), 0.0) for i in range(MAX_SELF_INTERSECT_VERTS + 1)]
assert is_self_intersecting([(x, y) for x, y, _ in huge]) is None
# ── ellipses ────────────────────────────────────────────────────────────────
def test_ellipse_area_is_exact_and_perimeter_is_flagged_approximate():
assert ellipse_area(10.0, 0.5) == pytest.approx(math.pi * 10.0 * 5.0)
# A circle is the one ellipse whose perimeter is closed-form, so Ramanujan
# must be exact there — a cheap check that the approximation is wired right.
assert ellipse_perimeter_ramanujan(10.0, 1.0) == pytest.approx(2 * math.pi * 10.0, rel=1e-12)
# ── the two-vertex circle ───────────────────────────────────────────────────
def test_a_circle_stored_as_two_bulged_vertices_has_its_real_area():
"""DXF's most compact circle, and the arithmetic answered 0.0 for it.
Two semicircular arcs joined into a circle store as a closed LWPOLYLINE of
*two* vertices, each bulge 1.0 — what AutoCAD writes for a JOINed pair of
arcs, and what `boundary_from_entities` builds from any two-arc loop. The
`count < 3` guard treated that as degenerate and returned zero area, which
is the same silent-nothing this module exists to stop: the shoelace over
two points is genuinely 0, but the two circular segments are the whole
shape and they were never added.
"""
circle = [(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)]
area, perimeter = polygon_area_perimeter(circle, closed=True)
assert area == pytest.approx(math.pi * 100.0, rel=1e-12)
assert perimeter == pytest.approx(2 * math.pi * 10.0, rel=1e-12)
def test_two_straight_vertices_still_enclose_nothing():
"""The fix must not invent area where there is none."""
area, perimeter = polygon_area_perimeter([(0.0, 0.0, 0.0), (10.0, 0.0, 0.0)], closed=True)
assert area == 0.0
assert perimeter == pytest.approx(20.0), "there and back"
def test_a_single_vertex_is_still_degenerate():
assert polygon_area_perimeter([(0.0, 0.0, 1.0)], closed=True) == (0.0, 0.0)