Files
autocad-mcp-pro/tests/test_measure.py
T
Umut CANandClaude Opus 5 e24b46d6f0 fix: three measurement defects the adversarial triage surfaced
**COM discarded areas it had already read.** `entity_measure` fell back to
rebuilding geometry from `.Coordinates` when `.Area` *or* `.Length` was
missing, coupling two independent property reads. HATCH, REGION and 3DSOLID
expose `.Area` and neither of the others, so the fallback fired, found no
vertices, and raised "AutoCAD reported neither an area nor usable vertices" --
while holding the area. Those are the exact types the README sells the COM
backend on. Now only a missing `.Area` triggers the fallback; an absent
perimeter is reported as `None` with `perimeter_exact: False` instead of being
invented, which also removes a `round(None, 6)` TypeError waiting for the first
type that answers `.Area` and nothing else.

**A plain square hatch reported itself self-intersecting.** `ezdxf.path
.flattening()` repeats the start point, and `is_self_intersecting` excluded
adjacent edges *by index*, so with the duplicate present the first and last
real edges were cross-tested and registered as crossing. Measured: a 40x40
SOLID hatch came back `area: 1600.0, self_intersecting: True`. A closed loop
now answers the same whether or not it carries its closing vertex, and a real
bowtie is still caught (`area: 0.0, self_intersecting: True`).

**The bowtie warning never fired on the live backend.** `self_intersecting` was
only computed inside the COM fallback, so an LWPOLYLINE bowtie -- which has
both `.Area` and `.Length` and never reaches it -- returned `area: 0.0,
self_intersecting: null`. The confident zero this field exists to flag, going
out unflagged. It is now computed on the normal path too.

Also corrects a docstring written earlier today in `contracts/analysis.py`
claiming the live backend "asks AutoCAD for `.Area`, which has already applied
the [hatch island] style". Whether ActiveX's hatch area applies the style is
not verified against a live seat, and the method could not measure a hatch at
all. It now says so.

The ActiveX member profiles behind the COM tests are assumptions, not facts,
and the tests say so; a live seat settles them. `.Circumference` for CIRCLE and
`.Perimeter` for REGION are deliberately NOT added here -- writing more members
from the reference is what produced four wrong beliefs in v1.4/v1.5.

1267 passed, 1 xfailed.

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

237 lines
10 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)
# ── a closed loop must answer the same however it was handed over ───────────
def test_a_repeated_closing_vertex_is_not_a_self_intersection():
"""The false alarm this shipped with.
`ezdxf.path.flattening()` repeats the start point, so every hatch boundary
and every flattened curve arrived here with a duplicate closing vertex. The
adjacency guard excludes neighbours *by index*, so the first and last real
edges — which share a point but not an index neighbourhood — were
cross-tested and reported as crossing. A plain 40x40 square hatch came back
`self_intersecting: True`: a confident wrong warning on the field that
exists to warn.
"""
square = [(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (0.0, 50.0)]
assert is_self_intersecting(square) is False
assert is_self_intersecting([*square, (0.0, 0.0)]) is False
def test_a_bowtie_is_still_caught_when_it_carries_a_closing_vertex():
"""Dropping the duplicate must not disarm the check."""
bowtie = [(0.0, 0.0), (50.0, 50.0), (50.0, 0.0), (0.0, 50.0)]
assert is_self_intersecting(bowtie) is True
assert is_self_intersecting([*bowtie, (0.0, 0.0)]) is True
def test_a_triangle_with_a_closing_vertex_is_still_not_degenerate():
"""Four entries, three real corners — must not fall under the `< 4` guard
and answer False for the wrong reason."""
triangle = [(0.0, 0.0), (60.0, 0.0), (30.0, 40.0), (0.0, 0.0)]
assert is_self_intersecting(triangle) is False