Files
autocad-mcp-pro/engineering/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

150 lines
5.7 KiB
Python

"""Exact area and perimeter for DXF boundary geometry.
Pure arithmetic: no ezdxf, no COM, no I/O. Both backends call the same functions,
so the headless and live engines cannot drift apart on the numbers, and the tests
exercise the real implementation rather than a stand-in.
The thing that makes this exact rather than approximate is the **bulge**. A DXF
polyline edge carries a bulge factor, and a non-zero bulge means that edge is a
circular arc — always circular, never a spline — so the enclosed area is the
shoelace of the vertices plus each arc's signed circular segment, and the walked
length is arc length rather than chord length. Both are closed-form.
Getting this wrong is not a rounding matter. Measured on a 100x100 square with
one edge bulged into an outward semicircle: ignoring bulge reports 10000 against
a true 13926.9908, i.e. **28.2% low**, and a perimeter 12.5% low.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
#: Max chord deviation (drawing units) when geometry has to be flattened because
#: it is not closed-form — splines, partial ellipses.
DEFAULT_FLATTEN_TOLERANCE = 0.001
#: Above this vertex count the O(n^2) self-intersection test is skipped and the
#: answer is reported as unknown. A slow honest "I did not check" beats both a
#: hang and a confident False.
MAX_SELF_INTERSECT_VERTS = 2000
_EPS = 1e-12
def polygon_area_perimeter(
vertices: Sequence[tuple[float, float, float]],
closed: bool,
) -> tuple[float, float]:
"""Exact area and perimeter of a bulge-carrying polygon.
``vertices`` are ``(x, y, bulge)``; the bulge belongs to the edge *leaving*
that vertex, which is the DXF convention.
``closed=False`` still returns the area of the implicitly closed region —
that is what AutoCAD's AREA command does — but the perimeter is the length
actually walked, without the closing segment. Callers must report the
assumption rather than let it pass silently.
Two vertices are not degenerate when they carry bulges: that is how DXF
stores a circle built from two semicircular arcs, and the shoelace over its
two points is genuinely 0 while the two circular segments are the entire
shape. Only zero or one vertex bounds nothing.
"""
count = len(vertices)
if count < 2:
return 0.0, _open_length(vertices)
shoelace = 0.0
for i in range(count):
x1, y1, _ = vertices[i]
x2, y2, _ = vertices[(i + 1) % count]
shoelace += x1 * y2 - x2 * y1
shoelace *= 0.5
segments = 0.0
perimeter = 0.0
limit = count if closed else count - 1
for i in range(count):
x1, y1, bulge = vertices[i]
x2, y2, _ = vertices[(i + 1) % count]
chord = math.hypot(x2 - x1, y2 - y1)
if abs(bulge) < _EPS or chord < _EPS:
if i < limit:
perimeter += chord
continue
theta = 4.0 * math.atan(bulge) # signed sweep, radians
radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
# The circular segment cut off by the chord. Signed: a positive bulge
# bows the edge outward and adds material, a negative one removes it.
segments += math.copysign(
radius * radius / 2.0 * (abs(theta) - math.sin(abs(theta))), theta
)
if i < limit:
perimeter += radius * abs(theta)
return abs(shoelace + segments), perimeter
def _open_length(vertices: Sequence[tuple[float, float, float]]) -> float:
return sum(
math.dist(vertices[i][:2], vertices[i + 1][:2]) for i in range(max(0, len(vertices) - 1))
)
def is_self_intersecting(vertices: Sequence[tuple[float, float]]) -> bool | None:
"""True when non-adjacent edges of the closed loop cross; None if not checked.
Worth reporting because the shoelace *cancels* crossed lobes rather than
failing: a bowtie made of two 2500-unit triangles measures 0.0, which is a
confident wrong number of exactly the kind this release removes.
"""
count = len(vertices)
if count < 4:
return False
if count > MAX_SELF_INTERSECT_VERTS:
return None # unknown beats a guess
for i in range(count):
a1, a2 = vertices[i], vertices[(i + 1) % count]
for j in range(i + 1, count):
if j == i or (j + 1) % count == i or j == (i + 1) % count:
continue # adjacent edges share an endpoint by construction
b1, b2 = vertices[j], vertices[(j + 1) % count]
if _segments_cross(a1, a2, b1, b2):
return True
return False
def _orientation(p, q, r) -> float:
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
def _segments_cross(p1, p2, q1, q2) -> bool:
"""Proper crossing only — touching at an endpoint is not an intersection."""
d1 = _orientation(q1, q2, p1)
d2 = _orientation(q1, q2, p2)
d3 = _orientation(p1, p2, q1)
d4 = _orientation(p1, p2, q2)
return ((d1 > _EPS) != (d2 > _EPS)) and ((d3 > _EPS) != (d4 > _EPS))
def ellipse_area(major: float, ratio: float) -> float:
"""pi*a*b — closed form, exact."""
return math.pi * abs(float(major)) * abs(float(major) * float(ratio))
def ellipse_perimeter_ramanujan(major: float, ratio: float) -> float:
"""Ramanujan's second approximation.
Relative error is around 1e-10 for ratio >= 0.1, but it is an approximation
and not closed form, so anything reporting it must set ``perimeter_exact``
to False. Exact for a circle (ratio == 1), which is the useful sanity check.
"""
a = abs(float(major))
b = abs(float(major) * float(ratio))
if a < _EPS or b < _EPS:
return 4.0 * max(a, b)
h = ((a - b) ** 2) / ((a + b) ** 2)
return math.pi * (a + b) * (1.0 + (3.0 * h) / (10.0 + math.sqrt(4.0 - 3.0 * h)))