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>
448 lines
17 KiB
Python
448 lines
17 KiB
Python
"""F3 — boundary tracing: `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities`.
|
|
|
|
Two tools that turn loose edges into one closed LWPOLYLINE. Every assertion here
|
|
exists because a measured ezdxf behaviour makes the *plausible* implementation
|
|
lie:
|
|
|
|
* **A boundary with no boundary is the silent-success case.** A seed dropped in
|
|
open space, or inside a "region" whose edges leave a gap, has no enclosing
|
|
loop. Returning an empty or open polyline with ``ok: True`` is exactly the
|
|
failure mode this repo spent a milestone deleting, so both are refusals — and
|
|
the gap case must name the dangling endpoint, because "no loop here" and
|
|
"your top edge is 5 mm short" are different problems with different fixes.
|
|
|
|
* **`edgeminer.find_all_loops` is O(n!)** — measured 63.9 s at 40 edges and a
|
|
TimeoutError at 60. It must never be called; `find_loop_by_edge` is 0.0057 s
|
|
at 60 edges. Nothing below asserts on runtime, but the nested-region test
|
|
pins the *behaviour* that makes the cheap call correct: the loop returned is
|
|
the nearest enclosing one, not the outermost.
|
|
|
|
* **`edgesmith.lwpolyline_from_chain` does not close what it chains.** Measured
|
|
on four LINEs of a square: five points, the fifth a duplicate of the first,
|
|
and ``closed = False``. That polyline draws like a square and behaves like an
|
|
open one — HATCH association, area queries and `entity_offset` all see an open
|
|
path. So the stored entity is checked for the closed *flag* and for the
|
|
absence of the duplicate vertex, not just for looking right.
|
|
|
|
* **`edgesmith.loop_area` ignores bulges.** Measured on two semicircular ARCs
|
|
chained into a circle: it returns **0.0** where the true area is 314.16. Use
|
|
it and every arc-bounded boundary — every fillet, every slot, every keyway
|
|
pocket — reports an area of roughly nothing while claiming success.
|
|
|
|
* **The reported area comes from the polyline, not from a copy of it.** The
|
|
first fix for the bulge problem flattened the chain at a fixed 0.01 sag,
|
|
which left `boundary_trace` saying 139.2177 and `entity_measure` saying
|
|
139.2699 about the same figure, with nothing in either payload marking one
|
|
as the approximation. The stored vertices carry exact bulges, so both tools
|
|
now read those and round the same way.
|
|
|
|
Coordinates in and out are WCS, as everywhere else. ``area`` is unsigned:
|
|
the same square wound clockwise or counter-clockwise measures 1600.0 either
|
|
way, and a caller asking "how big is this pocket" never wants a negative
|
|
answer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
# A 40 x 40 square: area 1600.
|
|
SQUARE = [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)]
|
|
|
|
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
|
|
|
|
|
|
def _numbers(text: str) -> list[float]:
|
|
"""Every number an error message mentions, however it was formatted."""
|
|
return [float(match) for match in _NUMBER.findall(text)]
|
|
|
|
|
|
def _points(backend, handle) -> list[tuple[float, float]]:
|
|
entity = backend._doc.entitydb.get(handle)
|
|
return [(round(float(p[0]), 6), round(float(p[1]), 6)) for p in entity.get_points()]
|
|
|
|
|
|
def _bulges(backend, handle) -> list[float]:
|
|
entity = backend._doc.entitydb.get(handle)
|
|
return [float(p[4]) for p in entity.get_points()]
|
|
|
|
|
|
async def _square_of_lines(backend, points=SQUARE, layer=None):
|
|
"""Four LINEs, corner to corner, returned as handles."""
|
|
handles = []
|
|
for index in range(len(points)):
|
|
x1, y1 = points[index]
|
|
x2, y2 = points[(index + 1) % len(points)]
|
|
line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
|
|
handles.append(line.handle)
|
|
return handles
|
|
|
|
|
|
# ── boundary_trace: the happy path, measured ────────────────────────────────
|
|
|
|
|
|
async def test_a_seed_inside_a_square_traces_its_four_edges(backend):
|
|
await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
assert result["ok"] is True
|
|
assert result["closed"] is True
|
|
assert result["vertices"] == 4, "four edges, four corners — not five"
|
|
assert result["area"] == pytest.approx(1600.0)
|
|
|
|
|
|
async def test_the_traced_boundary_is_a_real_lwpolyline(backend):
|
|
await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
entity = backend._doc.entitydb.get(result["handle"])
|
|
assert entity.dxftype() == "LWPOLYLINE"
|
|
assert set(_points(backend, result["handle"])) == set(SQUARE)
|
|
|
|
|
|
async def test_the_boundary_is_closed_by_flag_not_by_a_duplicate_vertex(backend):
|
|
"""`lwpolyline_from_chain` measured: 5 points, last == first, closed False.
|
|
|
|
That polyline looks shut and is not. Hatching, offsetting and area queries
|
|
all treat it as an open path.
|
|
"""
|
|
await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
entity = backend._doc.entitydb.get(result["handle"])
|
|
assert entity.closed is True
|
|
stored = _points(backend, result["handle"])
|
|
assert len(stored) == 4
|
|
assert stored[0] != stored[-1]
|
|
|
|
|
|
async def test_the_boundary_survives_save_and_reload(backend, tmp_path):
|
|
import ezdxf
|
|
|
|
await _square_of_lines(backend)
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
path = tmp_path / "boundary.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
|
|
reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"])
|
|
assert reloaded.dxftype() == "LWPOLYLINE"
|
|
assert reloaded.closed is True
|
|
assert len(list(reloaded.get_points())) == 4
|
|
|
|
|
|
# ── boundary_trace: the refusals ────────────────────────────────────────────
|
|
|
|
|
|
async def test_a_seed_outside_every_closed_region_is_refused(backend):
|
|
"""A boundary with no boundary. An empty polyline reported as success is
|
|
the exact shape of lie this repo removes tools for."""
|
|
await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_trace(500.0, 500.0)
|
|
|
|
assert result["ok"] is False
|
|
assert result.get("handle") is None
|
|
assert result["error"]
|
|
|
|
|
|
async def test_a_seed_in_open_space_with_no_edges_at_all_is_refused(backend):
|
|
result = await backend.boundary_trace(0.0, 0.0)
|
|
|
|
assert result["ok"] is False
|
|
assert result["error"]
|
|
|
|
|
|
async def test_a_region_with_a_gap_is_refused_and_the_error_names_the_gap(backend):
|
|
"""The top edge stops 5 mm short at (35, 40).
|
|
|
|
"No loop found" sends the user hunting the whole drawing; naming the
|
|
dangling endpoint sends them to the one line that is short.
|
|
"""
|
|
await backend.entity_create_line(0, 0, 40, 0)
|
|
await backend.entity_create_line(40, 0, 40, 40)
|
|
await backend.entity_create_line(40, 40, 35, 40) # 5 mm short of the corner
|
|
await backend.entity_create_line(0, 40, 0, 0)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
assert result["ok"] is False
|
|
assert "gap" in result["error"].lower()
|
|
assert 35.0 in _numbers(result["error"]), (
|
|
"the error must carry the dangling endpoint, not just the word 'gap'"
|
|
)
|
|
|
|
|
|
async def test_a_gap_inside_the_tolerance_is_closed(backend):
|
|
"""Real drawings miss corners by microns; tolerance is what it is for."""
|
|
await backend.entity_create_line(0, 0, 40, 0)
|
|
await backend.entity_create_line(40, 0, 40, 40)
|
|
await backend.entity_create_line(40, 40, 0.001, 40)
|
|
await backend.entity_create_line(0, 40, 0, 0)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0, tolerance=0.01)
|
|
|
|
assert result["ok"] is True
|
|
assert result["closed"] is True
|
|
assert result["area"] == pytest.approx(1600.0, rel=1e-3)
|
|
|
|
|
|
async def test_the_same_gap_outside_the_tolerance_is_still_refused(backend):
|
|
"""Otherwise `tolerance` would be decoration."""
|
|
await backend.entity_create_line(0, 0, 40, 0)
|
|
await backend.entity_create_line(40, 0, 40, 40)
|
|
await backend.entity_create_line(40, 40, 0.001, 40)
|
|
await backend.entity_create_line(0, 40, 0, 0)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0, tolerance=1e-9)
|
|
|
|
assert result["ok"] is False
|
|
|
|
|
|
# ── boundary_trace: which loop, and what it is made of ──────────────────────
|
|
|
|
|
|
async def test_nested_regions_return_the_inner_loop(backend):
|
|
"""AutoCAD's BOUNDARY traces the nearest enclosing loop.
|
|
|
|
Returning the outer square here would hatch straight over the island —
|
|
and it is also the answer a whole-drawing loop search hands you first.
|
|
"""
|
|
await _square_of_lines(backend) # 40 x 40, area 1600
|
|
inner = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]
|
|
await _square_of_lines(backend, inner) # 20 x 20, area 400
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
assert result["ok"] is True
|
|
assert result["area"] == pytest.approx(400.0)
|
|
assert set(_points(backend, result["handle"])) == set(inner)
|
|
|
|
|
|
async def test_a_seed_between_the_two_loops_returns_the_outer_ring(backend):
|
|
"""Same drawing, different seed: the loop that encloses *this* point."""
|
|
await _square_of_lines(backend)
|
|
await _square_of_lines(backend, [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)])
|
|
|
|
result = await backend.boundary_trace(5.0, 5.0)
|
|
|
|
assert result["ok"] is True
|
|
assert result["area"] == pytest.approx(1600.0)
|
|
|
|
|
|
async def test_source_handles_report_which_entities_formed_the_boundary(backend):
|
|
"""A traced boundary the caller cannot attribute is a polyline from nowhere."""
|
|
handles = await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
assert set(result["source_handles"]) == set(handles)
|
|
assert result["handle"] not in result["source_handles"]
|
|
|
|
|
|
async def test_the_boundary_is_independent_of_its_sources(backend):
|
|
"""BOUNDARY makes a new entity, it does not alias the edges. Deleting a
|
|
source line must not empty the polyline that was traced from it."""
|
|
handles = await _square_of_lines(backend)
|
|
result = await backend.boundary_trace(20.0, 20.0)
|
|
|
|
await backend.entity_delete(handles[0])
|
|
|
|
entity = backend._doc.entitydb.get(result["handle"])
|
|
assert entity is not None
|
|
assert entity.closed is True
|
|
assert set(_points(backend, result["handle"])) == set(SQUARE)
|
|
|
|
|
|
async def test_the_layer_filter_restricts_the_candidate_edges(backend):
|
|
"""A construction line through the square halves it.
|
|
|
|
Unfiltered, the nearest enclosing loop around (10, 20) is the left half
|
|
(area 800). Scoped to GEOMETRY, the divider is not an edge and the loop is
|
|
the whole square (1600). If `layer` were ignored both answers would be 800.
|
|
"""
|
|
await _square_of_lines(backend, layer="GEOMETRY")
|
|
await backend.entity_create_line(20, 0, 20, 40, layer="CONSTRUCTION")
|
|
|
|
unfiltered = await backend.boundary_trace(10.0, 20.0)
|
|
filtered = await backend.boundary_trace(10.0, 20.0, layer="GEOMETRY")
|
|
|
|
assert unfiltered["area"] == pytest.approx(800.0)
|
|
assert filtered["area"] == pytest.approx(1600.0)
|
|
|
|
|
|
# ── boundary_from_entities: the happy path ──────────────────────────────────
|
|
|
|
|
|
async def test_named_entities_chain_into_one_closed_polyline(backend):
|
|
"""The handles arrive in drawing order, not chain order; chaining is the
|
|
tool's whole job."""
|
|
handles = await _square_of_lines(backend)
|
|
shuffled = [handles[2], handles[0], handles[3], handles[1]]
|
|
|
|
result = await backend.boundary_from_entities(shuffled)
|
|
|
|
assert result["ok"] is True
|
|
assert result["closed"] is True
|
|
assert result["vertices"] == 4
|
|
assert result["area"] == pytest.approx(1600.0)
|
|
assert set(_points(backend, result["handle"])) == set(SQUARE)
|
|
|
|
|
|
async def test_the_chained_polyline_is_closed_without_a_duplicate_vertex(backend):
|
|
handles = await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_from_entities(handles)
|
|
|
|
entity = backend._doc.entitydb.get(result["handle"])
|
|
assert entity.closed is True
|
|
stored = _points(backend, result["handle"])
|
|
assert len(stored) == 4 and stored[0] != stored[-1]
|
|
|
|
|
|
async def test_a_chained_polyline_round_trips(backend, tmp_path):
|
|
import ezdxf
|
|
|
|
handles = await _square_of_lines(backend)
|
|
result = await backend.boundary_from_entities(handles)
|
|
|
|
path = tmp_path / "chained.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
|
|
reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"])
|
|
assert reloaded.closed is True
|
|
assert len(list(reloaded.get_points())) == 4
|
|
|
|
|
|
async def test_two_arcs_close_into_a_circle_whose_area_counts_the_bulges(backend):
|
|
"""`loop_area` returns 0.0 for this exact chain (measured).
|
|
|
|
Two semicircles enclose 314.16 mm^2; their two *vertices* enclose nothing.
|
|
An area taken from the vertex polygon reports ~0 for every arc-bounded
|
|
pocket in the drawing while still saying ok.
|
|
"""
|
|
upper = await backend.entity_create_arc(0, 0, 10, 0, 180)
|
|
lower = await backend.entity_create_arc(0, 0, 10, 180, 360)
|
|
|
|
result = await backend.boundary_from_entities([upper.handle, lower.handle])
|
|
|
|
assert result["ok"] is True
|
|
assert result["closed"] is True
|
|
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
|
|
assert all(abs(b) > 0.1 for b in _bulges(backend, result["handle"])), (
|
|
"a circle stored without bulges is a straight line between two points"
|
|
)
|
|
|
|
|
|
async def test_the_reported_area_matches_measuring_the_polyline_it_just_made(backend):
|
|
"""Two tools, one shape, one number.
|
|
|
|
`boundary_*` used to report a curve flattened at a fixed 0.01 sag while
|
|
`entity_measure` read the stored bulges analytically, so the same figure
|
|
came back as 139.2177 from one tool and 139.2699 from the other with
|
|
nothing in either payload to say which was the approximation. The polyline
|
|
carries exact bulges either way — there was never a reason to answer from
|
|
a coarser copy of it.
|
|
"""
|
|
# 10 x 10 square whose top edge is a semicircle: 100 + pi*25/2.
|
|
await backend.entity_create_line(0, 0, 10, 0)
|
|
await backend.entity_create_line(10, 0, 10, 10)
|
|
await backend.entity_create_line(0, 10, 0, 0)
|
|
await backend.entity_create_arc(5, 10, 5, 0, 180)
|
|
|
|
traced = await backend.boundary_trace(5.0, 5.0)
|
|
measured = await backend.entity_measure(traced["handle"])
|
|
|
|
true_area = 100.0 + math.pi * 25.0 / 2.0
|
|
# rel=1e-8, not tighter: both tools round to six decimals on the way out.
|
|
assert measured["area"] == pytest.approx(true_area, rel=1e-8)
|
|
assert traced["area"] == pytest.approx(measured["area"], rel=1e-9), (
|
|
"the boundary tool and the measure tool must not disagree about the "
|
|
"shape the boundary tool just drew"
|
|
)
|
|
|
|
|
|
async def test_boundary_from_entities_reports_the_same_exact_area(backend):
|
|
upper = await backend.entity_create_arc(0, 0, 10, 0, 180)
|
|
lower = await backend.entity_create_arc(0, 0, 10, 180, 360)
|
|
|
|
result = await backend.boundary_from_entities([upper.handle, lower.handle])
|
|
measured = await backend.entity_measure(result["handle"])
|
|
|
|
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-8)
|
|
assert result["area"] == pytest.approx(measured["area"], rel=1e-12), "same six decimals"
|
|
|
|
|
|
# ── boundary_from_entities: the refusals ────────────────────────────────────
|
|
|
|
|
|
async def test_an_open_chain_is_refused_and_names_the_dangling_endpoint(backend):
|
|
"""Three sides of a rectangle: the ends at (0, 0) and (7, 25) never meet."""
|
|
first = await backend.entity_create_line(0, 0, 40, 0)
|
|
second = await backend.entity_create_line(40, 0, 40, 25)
|
|
third = await backend.entity_create_line(40, 25, 7, 25)
|
|
|
|
result = await backend.boundary_from_entities([first.handle, second.handle, third.handle])
|
|
|
|
assert result["ok"] is False
|
|
reported = _numbers(result["error"])
|
|
assert 7.0 in reported and 25.0 in reported, (
|
|
"'not closed' without the loose end leaves the caller measuring by hand"
|
|
)
|
|
|
|
|
|
async def test_a_chain_broken_in_the_middle_is_refused(backend):
|
|
"""Four segments, all four endpoints paired — except two of them are 6 mm
|
|
apart. The count is right and the chain is not."""
|
|
first = await backend.entity_create_line(0, 0, 40, 0)
|
|
second = await backend.entity_create_line(40, 0, 40, 40)
|
|
third = await backend.entity_create_line(34, 40, 0, 40) # starts 6 mm inboard
|
|
fourth = await backend.entity_create_line(0, 40, 0, 0)
|
|
|
|
result = await backend.boundary_from_entities(
|
|
[first.handle, second.handle, third.handle, fourth.handle]
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert 34.0 in _numbers(result["error"])
|
|
|
|
|
|
@pytest.mark.parametrize("count", [0, 1])
|
|
async def test_fewer_than_two_handles_is_refused(backend, count):
|
|
"""One segment cannot enclose anything; zero is a no-op reported as work."""
|
|
handles = await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_from_entities(handles[:count])
|
|
|
|
assert result["ok"] is False
|
|
|
|
|
|
async def test_a_non_linear_entity_is_refused_by_type(backend):
|
|
"""TEXT has no endpoints. Skipping it quietly would chain three of four
|
|
sides and then blame the geometry for not closing."""
|
|
handles = await _square_of_lines(backend)
|
|
text = await backend.entity_create_text("NOTE", 20, 20)
|
|
|
|
result = await backend.boundary_from_entities(handles + [text.handle])
|
|
|
|
assert result["ok"] is False
|
|
assert "TEXT" in result["error"]
|
|
assert text.handle in result["error"]
|
|
|
|
|
|
async def test_an_unresolvable_handle_is_refused_by_name(backend):
|
|
handles = await _square_of_lines(backend)
|
|
|
|
result = await backend.boundary_from_entities(handles + ["NOSUCH"])
|
|
|
|
assert result["ok"] is False
|
|
assert "NOSUCH" in result["error"]
|