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>
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""T0.5 — ``entity_measure``: ask the drawing, not the model's memory.
|
|
|
|
Companion to ``tests/test_measure.py``, which covers the arithmetic. This file
|
|
covers the part that was actually missing: addressing an entity **by handle**.
|
|
|
|
Before this, neither backend could answer "what is the area of the polyline I
|
|
just drew". ``analysis_measure_area`` shoelaced points the caller supplied, and
|
|
the live COM backend did the same — despite having a document, a
|
|
``HandleToObject`` lookup it already used fourteen times, and an ActiveX ``Area``
|
|
property sitting right there.
|
|
|
|
Two distinctions here are load-bearing and are asserted, not assumed:
|
|
|
|
* A LINE has no area on *any* engine, so that is a plain error — tagging it with
|
|
a ``capability`` would falsely imply "switch backends and this works".
|
|
* A REGION has no area on the *headless* engine only, so that is a capability
|
|
refusal naming the live backend as the way forward.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from backends.base import UnsupportedCapabilityError
|
|
from backends.com_backend import ComBackend
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
#: 100x100 square, bottom edge bulged outward into a semicircle of radius 50.
|
|
BULGED_SQUARE = [(0, 0, 0, 0, 0.0), (100, 0, 0, 0, 1.0), (100, 100, 0, 0, 0.0), (0, 100, 0, 0, 0.0)]
|
|
TRUE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0
|
|
TRUE_PERIMETER = 300.0 + math.pi * 50.0
|
|
|
|
|
|
def _add_bulged_square(backend, close=True):
|
|
pline = backend._doc.modelspace().add_lwpolyline(BULGED_SQUARE, format="xyseb", close=close)
|
|
return pline.dxf.handle
|
|
|
|
|
|
# ── the capability that did not exist ───────────────────────────────────────
|
|
|
|
|
|
async def test_measures_a_bulged_polyline_exactly(backend):
|
|
"""The 28.2% the reported-points workflow lost."""
|
|
handle = _add_bulged_square(backend)
|
|
|
|
result = await backend.entity_measure(handle)
|
|
|
|
assert result["handle"] == handle
|
|
assert result["type"] == "LWPOLYLINE"
|
|
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
|
|
assert result["perimeter"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
|
|
assert result["method"] == "analytic_bulge"
|
|
assert result["exact"] is True
|
|
assert result["flatten_tolerance"] is None
|
|
assert result["closed"] is True
|
|
assert result["assumed_closed"] is False
|
|
assert result["self_intersecting"] is False
|
|
assert result["backend"] == "ezdxf"
|
|
|
|
|
|
async def test_a_straight_polyline_reports_the_plain_analytic_method(backend):
|
|
pline = backend._doc.modelspace().add_lwpolyline(
|
|
[(0, 0), (10, 0), (10, 10), (0, 10)], close=True
|
|
)
|
|
result = await backend.entity_measure(pline.dxf.handle)
|
|
|
|
assert result["area"] == pytest.approx(100.0)
|
|
assert result["perimeter"] == pytest.approx(40.0)
|
|
assert result["method"] == "analytic"
|
|
assert result["exact"] is True
|
|
|
|
|
|
async def test_measures_a_circle(backend):
|
|
circle = await backend.entity_create_circle(5, 5, 4)
|
|
result = await backend.entity_measure(circle.handle)
|
|
|
|
assert result["area"] == pytest.approx(math.pi * 16.0)
|
|
assert result["perimeter"] == pytest.approx(2 * math.pi * 4.0)
|
|
assert result["method"] == "analytic"
|
|
assert result["exact"] is True
|
|
assert result["closed"] is True
|
|
|
|
|
|
async def test_an_open_boundary_says_it_was_closed(backend):
|
|
"""AutoCAD's AREA closes an open boundary; the payload must admit it."""
|
|
handle = _add_bulged_square(backend, close=False)
|
|
|
|
result = await backend.entity_measure(handle)
|
|
|
|
assert result["closed"] is False
|
|
assert result["assumed_closed"] is True
|
|
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
|
|
|
|
|
|
async def test_a_self_intersecting_polyline_is_flagged(backend):
|
|
"""Shoelace cancels crossed lobes, so the 0.0 has to come with a warning."""
|
|
pline = backend._doc.modelspace().add_lwpolyline(
|
|
[(0, 0), (100, 100), (100, 0), (0, 100)], close=True
|
|
)
|
|
result = await backend.entity_measure(pline.dxf.handle)
|
|
|
|
assert result["self_intersecting"] is True
|
|
|
|
|
|
# ── the two kinds of "no" ───────────────────────────────────────────────────
|
|
|
|
|
|
async def test_a_line_has_no_area_on_any_engine_so_it_is_not_a_capability_gap(backend):
|
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
|
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
await backend.entity_measure(line.handle)
|
|
|
|
assert not isinstance(excinfo.value, UnsupportedCapabilityError), (
|
|
"tagging this `capability` would tell the user to switch backends, "
|
|
"where no backend can give a LINE an area"
|
|
)
|
|
assert "LWPOLYLINE" in str(excinfo.value), "the refusal must list what IS measurable"
|
|
|
|
|
|
async def test_a_region_is_a_real_capability_boundary(backend):
|
|
"""ezdxf stores ACIS opaquely and cannot evaluate it; live AutoCAD can."""
|
|
region = backend._doc.modelspace().new_entity("REGION", {})
|
|
|
|
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
|
await backend.entity_measure(region.dxf.handle)
|
|
|
|
assert excinfo.value.capability == "measure_area_acis"
|
|
assert "com" in str(excinfo.value).lower(), "a refusal must name the way forward"
|
|
|
|
|
|
async def test_measure_capability_is_declared_on_both_backends():
|
|
assert EzdxfBackend().capabilities().features["measure_area_acis"].supported is False
|
|
assert ComBackend().capabilities().features["measure_area_acis"].supported is True
|
|
|
|
|
|
# ── HATCH: the type the refusal advertised and then refused ─────────────────
|
|
#
|
|
# `_MEASURABLE_TYPES` named HATCH, so a caller who asked for a hatch's area got
|
|
# a RuntimeError whose own text listed HATCH as measurable. Whichever half was
|
|
# right, the pair could not both be — and a hatch is the one entity whose area
|
|
# a drafter actually asks for, because it is the section they have to fill.
|
|
|
|
|
|
def _hatch_with_island(backend, *, style: int = 0):
|
|
"""20x20 outer square, 10x10 island. Filled area is 400 - 100 = 300."""
|
|
hatch = backend._doc.modelspace().add_hatch(color=1)
|
|
hatch.dxf.hatch_style = style
|
|
hatch.paths.add_polyline_path(
|
|
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
|
|
)
|
|
island = hatch.paths.add_edge_path(flags=0)
|
|
island.add_line((5, 5), (15, 5))
|
|
island.add_line((15, 5), (15, 15))
|
|
island.add_line((15, 15), (5, 15))
|
|
island.add_line((5, 15), (5, 5))
|
|
return hatch.dxf.handle
|
|
|
|
|
|
async def test_a_hatch_reports_the_area_it_actually_fills(backend):
|
|
handle = _hatch_with_island(backend)
|
|
|
|
result = await backend.entity_measure(handle)
|
|
|
|
assert result["type"] == "HATCH"
|
|
assert result["area"] == pytest.approx(300.0, abs=1e-6), (
|
|
"the island is a hole in the fill; counting it is the 33% error"
|
|
)
|
|
assert result["loop_count"] == 2
|
|
assert result["hatch_style"] == "normal"
|
|
assert result["exact"] is True, "every edge here is a straight line — nothing was flattened"
|
|
|
|
|
|
async def test_hatch_style_ignore_fills_straight_over_the_island(backend):
|
|
"""AutoCAD's HPISLAND=2 fills the island too. The number must follow."""
|
|
handle = _hatch_with_island(backend, style=2)
|
|
|
|
result = await backend.entity_measure(handle)
|
|
|
|
assert result["area"] == pytest.approx(400.0, abs=1e-6)
|
|
assert result["hatch_style"] == "ignore"
|
|
|
|
|
|
async def test_a_nested_island_alternates_under_the_normal_style(backend):
|
|
"""Depth 2 is where `normal` and `outer` stop agreeing, so it is the only
|
|
case that proves the depths were computed rather than assumed."""
|
|
hatch = backend._doc.modelspace().add_hatch(color=1)
|
|
hatch.dxf.hatch_style = 0 # normal
|
|
hatch.paths.add_polyline_path(
|
|
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
|
|
)
|
|
hatch.paths.add_polyline_path([(4, 4), (16, 4), (16, 16), (4, 16)], is_closed=True, flags=0)
|
|
hatch.paths.add_polyline_path([(8, 8), (12, 8), (12, 12), (8, 12)], is_closed=True, flags=0)
|
|
|
|
result = await backend.entity_measure(hatch.dxf.handle)
|
|
|
|
# 400 - 144 + 16: the innermost loop is filled again.
|
|
assert result["area"] == pytest.approx(272.0, abs=1e-6)
|
|
assert result["loop_count"] == 3
|
|
|
|
|
|
async def test_a_curved_hatch_edge_says_it_was_flattened(backend):
|
|
hatch = backend._doc.modelspace().add_hatch(color=1)
|
|
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
|
|
|
|
result = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-5)
|
|
|
|
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
|
|
assert result["exact"] is False, "a flattened arc is not an exact area"
|
|
assert result["flatten_tolerance"] == 1e-5
|
|
|
|
|
|
async def test_tightening_the_tolerance_does_not_make_a_curved_hatch_exact(backend):
|
|
"""The floor is the arc-to-Bezier conversion, not the flattening.
|
|
|
|
A caller who sees `flatten_tolerance` naturally assumes a smaller number
|
|
buys a better area, all the way to the truth. For hatch edges it does not:
|
|
ezdxf hands the boundary over as cubic Beziers, and tightening the
|
|
tolerance converges on the *Bezier's* area, which sits ~0.028% above the
|
|
circle it stands in for. The residual does not shrink with the tolerance,
|
|
which is why `exact` is False rather than the tolerance being presented as
|
|
the accuracy knob.
|
|
"""
|
|
hatch = backend._doc.modelspace().add_hatch(color=1)
|
|
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
|
|
|
|
true_area = math.pi * 100.0
|
|
coarse = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-3)
|
|
fine = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-7)
|
|
finest = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-9)
|
|
|
|
# Four orders of magnitude of extra tolerance buy nothing after the first.
|
|
assert finest["area"] == pytest.approx(fine["area"], rel=1e-7)
|
|
residual = abs(finest["area"] - true_area) / true_area
|
|
assert residual > 2e-4, "the Bezier error survives any tolerance"
|
|
assert abs(coarse["area"] - true_area) / true_area < residual, (
|
|
"the coarse run is only closer by accident — an inscribed chord error "
|
|
"that happens to cancel part of the Bezier overshoot"
|
|
)
|
|
assert finest["exact"] is False
|
|
|
|
|
|
# ── entity_get stops dropping the curve ─────────────────────────────────────
|
|
|
|
|
|
async def test_entity_get_reports_area_and_length_for_a_closed_polyline(backend):
|
|
"""``props["length"] = ent.length()`` was dead code — LWPolyline has no
|
|
``length()``, so the AttributeError went to a debug log and the key silently
|
|
never appeared."""
|
|
handle = _add_bulged_square(backend)
|
|
|
|
info = await backend.entity_get(handle)
|
|
|
|
assert info.properties["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
|
|
assert info.properties["length"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
|
|
|
|
|
|
async def test_entity_get_still_reports_only_xy_points(backend):
|
|
"""The bulge feeds the measurement; it must not change the points contract."""
|
|
handle = _add_bulged_square(backend)
|
|
info = await backend.entity_get(handle)
|
|
|
|
assert all(len(point) == 2 for point in info.properties["points"])
|