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>
This commit is contained in:
Umut CAN
2026-08-06 09:45:20 +03:00
co-authored by Claude Opus 5
parent d5a0ff5109
commit b03a7615b1
107 changed files with 32640 additions and 4810 deletions
+53 -3
View File
@@ -1,8 +1,37 @@
# AutoCAD MCP Pro — Environment Variables
# Rendering (view_screenshot / drawing_export_pdf) is an install-time choice,
# not an env var — the core install cannot render at all. ezdxf's drawing
# add-on imports Pillow unconditionally, so every render path needs it:
# pip install "autocad-mcp-pro[pdf]" ezdxf backend, matplotlib renderer
# (also pulls Pillow in transitively)
# pip install "autocad-mcp-pro[com]" live AutoCAD Win32 window capture
# pip install "autocad-mcp-pro[full]" both
# With only [com] the ezdxf backend still cannot render (no matplotlib).
# Backend selection: auto | com | ezdxf
AUTOCAD_MCP_BACKEND=auto
# COM ProgID the live backend attaches to. Escape hatch for AutoCAD-compatible
# applications that expose the same ActiveX object model.
#
# CAD_PROGID only changes which COM application the backend attaches to — every
# tool beyond the connection itself is developed and tested against AutoCAD
# alone, and AutoCAD-clone compatibility is unverified, so treat a non-default
# ProgID as experimental. An unrecognised ProgID fails loudly rather than
# falling back to AutoCAD, because the fallback would launch the very
# application you said you were not using.
#
# Candidates below are UNVERIFIED — none was tested against a running seat.
# List what is actually registered on your machine with:
# Get-ChildItem Registry::HKEY_CLASSES_ROOT |
# Where-Object { $_.PSChildName -like '*.Application*' } |
# Select-Object -Expand PSChildName
# CAD_PROGID=BricscadApp.AcadApplication # UNVERIFIED
# CAD_PROGID=ZWCAD.Application # UNVERIFIED
# CAD_PROGID=GstarCAD.Application # UNVERIFIED
CAD_PROGID=AutoCAD.Application
# Comma-separated list of allowed directories for file operations.
# Leave empty to allow all paths (not recommended for production).
# Example: ALLOWED_PATHS=C:\Drawings,D:\Projects\CAD
@@ -18,10 +47,31 @@ LOG_LEVEL=INFO
# Set to true only if you trust all MCP clients
DANGEROUS_COMMANDS_ENABLED=false
# Exposed tool profile: full (default) | core | lean
# core hides raw escape hatches and long-tail tools; lean is a curated
# ~46-tool drafting/inspection core for clients with tight tool caps.
# Exposed tool profile: full (default) | lean
# lean is a curated ~47-tool drafting/inspection set for clients with tight
# tool caps. The "core" profile was removed in v1.5.0 — the discovery layer
# below solves the crowded-surface problem it existed for. TOOL_PROFILE=core
# still boots, falling back to full with a warning.
TOOL_PROFILE=full
# Tool discovery: off (default) | search
# "search" replaces the advertised catalog with search_tools + call_tool, so a
# client sees two tools instead of ~126 and searches by AutoCAD command name
# ("QSELECT") or plain English ("round off this corner").
DISCOVERY_MODE=off
# Per-call backend timeouts in seconds; 0 disables the check.
# A hung call would otherwise hold the backend lock forever.
COM_CALL_TIMEOUT=60
EZDXF_CALL_TIMEOUT=120
# Undo/redo history for the headless backend, in steps. 0 disables it.
# Not free: ezdxf has no journal, so each step is a full DXF snapshot.
# Measured 37x slower entity creation with it on (0.18 -> 6.65 ms/call),
# and a snapshot of a 5000-entity drawing costs 130 ms / 658 KB. For a
# single checkpoint use transaction_begin/rollback instead.
# The live COM backend uses AutoCAD's own undo and ignores this.
EZDXF_UNDO_DEPTH=0
# Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported)
ENABLE_3D=false
+13 -3
View File
@@ -10,6 +10,10 @@ on:
schedule:
- cron: "17 5 * * 1"
env:
# Keep in step with ci.yml; all three workflows read the same uv.lock.
LOCKED_UV_VERSION: "0.12.1"
jobs:
headless-matrix:
runs-on: ubuntu-latest
@@ -23,13 +27,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.12"
- name: Install
run: pip install -e ".[pdf]"
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked harness environment
# This lane publishes evidence, so the harness must not float: an ezdxf
# release would otherwise move our own numbers between runs and the
# diff would be unattributable. Competitors keep their own pinned
# checkouts and venvs (benchmarks/competitors_env.py) either way.
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
- name: Run benchmark (ezdxf lane)
run: |
python -m benchmarks.run_competitors \
uv run --no-sync python -m benchmarks.run_competitors \
--server "${{ matrix.server }}" \
--backend ezdxf \
--artifact-dir "benchmarks/results/ci/${{ matrix.server }}" \
+43 -13
View File
@@ -11,6 +11,25 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Two deliberately different installation paths run in this file:
#
# lint / test-* install from uv.lock with `--locked`. Every version, down to
# the transitive graph, is the one the suite was last run
# against; `--locked` *fails* rather than re-resolving if
# uv.lock has drifted from pyproject.toml. A release upstream
# can no longer turn main red on its own schedule.
# package installs the built wheel with plain pip, from the dependency
# ranges in pyproject.toml — on purpose. That is the path a
# user gets from PyPI, and it is the only job that exercises
# it, so it must not be locked.
#
# Locked CI is blind to upstream drift by construction; deps-floating.yml is the
# deliberate counterweight that resolves the ranges fresh on a schedule.
env:
# Pinned for the same reason ruff is: the tool that reads the lockfile is part
# of the reproducible environment. Bump this and re-run `uv lock` together.
LOCKED_UV_VERSION: "0.12.1"
jobs:
lint:
runs-on: ubuntu-latest
@@ -19,9 +38,14 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install ruff
- run: ruff check .
- run: ruff format --check .
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
# Dev group only: ruff comes from the lock, the project is not built.
# The ruff version lives in [dependency-groups] dev in pyproject.toml and
# nowhere else — the formatter's output changes between minors, so a
# floating ruff fails `ruff format --check .` on unchanged code.
- run: uv sync --locked --only-group dev
- run: uv run --no-sync ruff check .
- run: uv run --no-sync ruff format --check .
test-linux:
runs-on: ubuntu-latest
@@ -32,14 +56,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[pdf]"
pip install pytest pytest-asyncio pytest-cov
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked environment
# uv.lock is a universal resolution over requires-python >=3.11, so the
# same file serves both matrix legs; --python keeps uv on the
# interpreter setup-python just provisioned instead of picking its own.
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
- name: Run tests
run: pytest --cov=. --cov-report=xml --cov-report=term-missing -v
run: uv run --no-sync pytest --cov=. --cov-report=xml --cov-report=term-missing -v
- name: Upload coverage
if: matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
@@ -54,14 +81,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -e ".[full]"
pip install pytest pytest-asyncio pytest-cov
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked environment
run: uv sync --locked --extra full --python "${{ steps.python.outputs.python-path }}"
- name: Run tests
run: pytest -v
run: uv run --no-sync pytest -v
package:
runs-on: ubuntu-latest
@@ -75,6 +102,9 @@ jobs:
pip install build twine
python -m build
twine check dist/*
# Unlocked on purpose: this resolves the published dependency ranges the
# way a user's `pip install autocad-mcp-pro` does. Locking it would mean
# nothing in CI ever proves those ranges are installable.
- name: Install wheel in a clean venv and smoke test
run: |
python -m venv /tmp/smoke
+47
View File
@@ -0,0 +1,47 @@
name: Floating deps
# The counterweight to a locked CI.
#
# ci.yml installs from uv.lock, so a PR can never be broken by an upstream
# release. That is the point — and it is also a blind spot: a new fastmcp,
# ezdxf or pydantic inside the ranges declared in pyproject.toml would go
# unnoticed here until a user hit it, because the only unlocked job in CI
# (`package`) merely imports the wheel and never runs the suite.
#
# This job is the deliberate opposite: resolve the declared ranges fresh and
# run the full suite against whatever the index serves today. It runs on a
# schedule and on demand only, so it never gates a pull request — a red run
# here means "the ceiling needs a look", not "your change is broken".
#
# Lint is intentionally absent: ruff's formatter output moves between minors,
# so a floating ruff would fail on unchanged source and say nothing about the
# runtime dependencies this job exists to watch.
on:
schedule:
# Mondays, 06:17 UTC. Off the hour so it does not queue behind the
# every-cron-on-the-hour crowd.
- cron: "17 6 * * 1"
workflow_dispatch:
jobs:
floating:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install from the declared ranges (uv.lock deliberately ignored)
run: |
pip install -e ".[pdf]"
pip install pytest pytest-asyncio
- name: Record what actually resolved
# Printed unconditionally: when this job fails, the version list is the
# first thing needed to tell which upstream moved.
run: pip list
- run: pytest -q
+15 -6
View File
@@ -13,20 +13,29 @@ on:
push:
tags: ["v*"]
env:
# Keep in step with ci.yml; both read the same uv.lock.
LOCKED_UV_VERSION: "0.12.1"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -e ".[pdf]"
pip install pytest pytest-asyncio ruff
- run: ruff check .
- run: pytest -q
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked environment
# `--locked` refuses to re-resolve, so the artifact that goes to PyPI is
# gated by the exact dependency set the suite was run against — not by
# whatever the index happens to serve at tag time. ruff's version comes
# from [dependency-groups] dev via the lock; it is not restated here.
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
- run: uv run --no-sync ruff check .
- run: uv run --no-sync ruff format --check .
- run: uv run --no-sync pytest -q
build:
needs: test
+4 -2
View File
@@ -53,5 +53,7 @@ benchmarks/.competitors/
# Local editor/agent state
.cursor/
# Stale/unvetted lockfile (regenerate deliberately with 'uv lock' if adopting uv)
uv.lock
# NOTE: uv.lock is deliberately *not* ignored. It used to be — back when the
# committed one was stale and unvetted — but CI now installs from it with
# `uv sync --locked`, so ignoring it would silently disarm every locked lane.
# Regenerate with `uv lock` whenever pyproject.toml dependencies change.
+727 -4
View File
@@ -5,10 +5,264 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.5.0] — 2026-08-06
### Added
- **Selection filters — 3 tools.** `selection_window` (AutoCAD's ssget
window/crossing), `selection_polygon` and `selection_filter` (QSELECT).
Window returns only entities wholly inside; crossing also returns the ones
straddling the edge — the modes are reported back and `window` is the
default, because the over-selecting mode must never be the implicit one. A
zero-area box is refused rather than answered with an empty list, which would
read as "nothing there".
`selection_filter` takes named parameters rather than a query string
deliberately: ezdxf's query language answers an unknown attribute with an
empty result, so `LINE[nosuchattr=='x']` is indistinguishable from "no
matches". Here a typo is a `TypeError` at the boundary, and `filtered_by`
reports which filters actually ran. `min_area` excludes shapes that have no
area at all rather than treating them as zero, and a negative `min_area` is
refused — no area is negative.
- **Boundary tracing — 2 tools, and five deliberately not built.**
`boundary_trace` is AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest
enclosing closed polyline out. It never calls `edgeminer.find_all_loops` —
measured at **63.9 s for 40 edges and a TimeoutError at 60**, because the
docstring's O(n!) is real — and walks one loop per seed-adjacent edge
instead, which is 0.0057 s at the same size. Straight edges are split where
they cross, so a construction line drawn across a shape actually divides it;
without that the line shares no endpoint with anything and is invisible to
the edge graph. Curved edges are not split, and the capability says so.
Two things the obvious implementation gets silently wrong, both now tested:
`edgesmith.lwpolyline_from_chain` emits n+1 points with the last duplicating
the first and `closed` left False — a polyline that draws like a square and
behaves like an open path to hatch association, area queries and
`entity_offset`; and `edgesmith.loop_area` works off edge endpoints, so two
semicircular arcs closing into a circle measure **0.0** against a true
314.159. The area now comes from the flattened curve and each curved edge
keeps its bulge.
**Not built: every REGION and 2D-boolean tool the plan listed.** ezdxf cannot
author a REGION — `add_region()` returns one with zero ACIS bytes — and the
substitute, `greiner_hormann`, takes flat vertex lists. Measured on a square
with one edge bulged into a semicircle, routing it through that boolean loses
**28.2% of the area and 12.5% of the perimeter**. That is the same magnitude
as the `analysis_measure_area` bug this release fixed, and shipping it would
have re-introduced the class.
- **Hatch depth — 3 tools.** `hatch_set_gradient`, `hatch_edit` and
`hatch_add_boundary`. `hatch_edit` leaves omitted parameters alone — a
partial edit that resets the rest is data loss — and `changed` reports the
attributes that actually *moved*, so re-setting a value to what it already
was comes back empty rather than as a false positive. `hatch_add_boundary`
takes typed edges (line/arc/ellipse) because a boundary API that only accepts
vertex lists silently straightens every curve it is given; every edge is
validated before any is written, so a malformed list refuses instead of
leaving a half-built path. On COM it refuses with
`capability: "hatch_edge_paths"`: ActiveX appends boundary loops as existing
objects, not as typed edges, so the fidelity this tool exists for cannot be
expressed there.
- **`analysis_list_properties` — AutoCAD's LIST.** The raw DXF attribute set
for one handle, which `entity_get` deliberately does not carry. It cannot be
handed over as-is for two reasons, both fixed here: `dxfattribs()` holds
`Vec3` values and is not JSON-serialisable, and its coordinates are in the
entity's own frame, so for anything with an extrusion they would contradict
every other coordinate this server reports.
- **Annotation objects — 4 tools.** `entity_create_wipeout` (mask what is
behind a closed polygon), `entity_create_revcloud`, `text_set_background`
(MTEXT background mask) and `text_find_replace`. Each carries a refusal that
was measured rather than assumed. A wipeout of fewer than three points
encloses no area, so it is refused instead of returning a handle to a mask
that masks nothing. A revision cloud *is* its arcs — `ezdxf.revcloud`'s own
helper stamps the `RevcloudProps` xdata and takes the bulge sign from the
winding direction, so the result satisfies `is_revcloud()` — and a
`segment_length` longer than the shortest edge produces no arcs at all, which
would be a plain rectangle reported as a cloud; that case is refused.
`text_set_background` refuses TEXT (only MTEXT has a background-fill
attribute, so setting one on TEXT would report success and change nothing)
and refuses a scale below 1.0 (a box smaller than its text is a stripe
through the text).
`text_find_replace` searches TEXT, MTEXT, ATTRIB **and** ATTDEF, including
inside block definitions — leaving the ATTDEF behind would fix the drawing
and reintroduce the old text on the next insert. It returns
`searched_types`, because "no matches" and "that type was never searched" are
different answers and a caller cannot otherwise tell them apart. DIMENSION
text is deliberately out of scope: its text field holds the `<>` override
placeholder rather than the measurement, so editing it would either do
nothing or detach the dimension from what it measures — the response says so.
On COM, `entity_create_revcloud` refuses with `capability: "revcloud"`:
ActiveX has no revision-cloud member and driving the command blind against a
live drawing is unverified.
- **The rest of the paper-space lifecycle — 8 tools.** v1.4 could make a sheet
and never unmake it. Added `layout_delete`, `layout_rename`, `layout_copy`,
`viewport_list`, `viewport_set_scale`, `viewport_lock`, `viewport_delete` and
`entity_change_space` (CHSPACE). Verified headless (ezdxf); the COM paths for
the first seven are implemented against the ActiveX reference but **never
executed against a live AutoCAD**, and each is marked so in the source.
Most of the work went into refusals, because ezdxf's own guards stop short of
where a tool boundary has to: `layouts.delete("")` deletes the *first* sheet
rather than refusing, `layouts.get("")` resolves to whichever is active, and
`layouts.rename()` skips the name validation `new()` performs, so `Bad/Name`
and even `""` land in the table and the file still audits clean. Deleting or
renaming the current tab used to leave three different answers to "where does
my next line go?" — `layout_list` said one thing, `$TILEMODE` another, and
`_msp()` quietly wrote to model space; all three now agree. A handle from a
deleted sheet refuses instead of dying with `AttributeError` (`entitydb.get`
is documented not to filter destroyed entities, and `_get_entity` believed
it).
`layout_copy` is a construction — ezdxf has no layout-copy API and
`xref.load_paperspace` is cross-document only — and every way it can go wrong
is invisible to the Auditor, which reports zero errors on a duplicated tab
order, two main viewports, and a hatch whose boundary still points into the
layout it was copied from and dangles the moment that layout is deleted. All
three are prevented structurally; `skipped` and `associativity_dropped` report
what could not be carried over rather than letting a bare `ok: true` imply a
complete copy.
`viewport_list` reports the layout's own main viewport with `is_main: true`
rather than hiding it — it is what remains after every drafting viewport is
deleted — and refuses to report a scale on R12, where `view_height` and
`flags` are dropped on export and `get_scale()` then returns a fabricated
number (150.0 measured for a viewport storing no scale at all). The two
writers refuse on R12 outright rather than reporting a success the file
cannot keep, and `viewport_lock` read-modify-writes the flags bitfield, which
also carries the UCS-icon and grid bits a bare assignment would silently drop.
- **`entity_change_space` (CHSPACE), headless only.** AutoCAD moves objects
*through* a viewport, rescaling them so they look identical on screen.
ezdxf's `move_to_layout` applies no geometric change whatsoever, so the
obvious implementation reports success, turns a 100 mm feature into 100 mm of
paper inside a 1:2 viewport, and writes a perfectly clean file. The transform
is therefore the tool, and it uses `viewport.get_transformation_matrix()`
rather than a hand-rolled one: the hand-rolled version ignores
`view_target_point` and misplaced geometry by 25 mm in testing while
`is_top_view` was still true and the twist angle still zero — past every guard
one would naturally write. A regression test pins the result against what
ezdxf's own rendering pipeline projects.
It refuses rather than guessing: non-plan and twisted viewports (ezdxf returns
a meaningless matrix for the first and rotates about the paper origin for the
second), dimensions unless `freeze_dimensions=true` bakes the measurement in
first (`transform` halves `get_measurement()` while the baked block text keeps
the old value, so one of the two is always lying), ACIS bodies (`transform`
is accepted and moves nothing), tables and proxies (`transform` raises, and
`hasattr(e, "transform")` is `True` for all of them), viewports themselves,
and entities already in the target space. Geometry that lands outside the
viewport or off the sheet is moved and **flagged** (`inside_viewport`,
`on_sheet`), not refused — AutoCAD allows it too.
On the COM backend this refuses with `capability: "chspace"`: ActiveX has no
change-space member, so the alternatives are `CopyObjects` + delete or driving
the command blind, and neither could be verified against a live seat.
- **`CAD_PROGID` — the COM ProgID is configurable.** `"AutoCAD.Application"`
was hardcoded at both connection sites, so AutoCAD-compatible applications
(BricsCAD, ZWCAD, GstarCAD) were unreachable even though they expose the same
ActiveX object model. The setting is honoured on *both* paths deliberately:
`Dispatch` is not a passive probe — it COM-launches the application and sets
`Visible = True` — so honouring the setting on `GetActiveObject` and then
falling back would start the very AutoCAD the operator said they were not
using. An unrecognised ProgID fails loudly for the same reason, and the error
names both the ProgID it tried and the variable to change. `system_status`
now reports `cad_progid`, without which a non-AutoCAD operator reads an
`autocad_version` key naming a product they do not own.
**Caveat, stated everywhere it appears:** `CAD_PROGID` only changes which COM
application the backend attaches to — every tool beyond the connection itself
is developed and tested against AutoCAD alone, and clone compatibility is
unverified, so treat a non-default ProgID as experimental. The candidate
ProgIDs in `.env.example` are commented out and marked UNVERIFIED: none has
been tested against a running seat.
- **`analysis_measure_entity` — measure the drawing, not your memory of it.**
Neither backend could answer "what is the area of the polyline I just drew":
`analysis_measure_area` shoelaced points the *caller* typed in, and the live
COM backend did the same despite having the document, a `HandleToObject`
lookup it already used fourteen times, and an ActiveX `Area` property. The
only available workflow was also silently wrong — `entity_get` read LWPOLYLINE
vertices through the default format and kept x and y, dropping the **bulge**
that makes an edge an arc. Measured on a 100×100 square with one edge bulged
into a semicircle: 10000 reported against a true 13926.9908, **28.2% low**,
perimeter **12.5% low**, nothing in the response saying so. And
`props["length"] = ent.length()` was dead code — `LWPolyline` has no
`length()`, so the key never appeared at all.
Bulge geometry is always circular, so the new `engineering/measure.py` is
exact rather than approximate: shoelace plus each arc's signed circular
segment, and arc length for perimeter. `entity_get` now reports `area` and
`length` from the same maths. The payload states its own accuracy — `exact`,
`flatten_tolerance`, `assumed_closed` (an open boundary is closed the way
AutoCAD's AREA does), and `self_intersecting`, because the shoelace *cancels*
crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse with
`capability: "measure_area_acis"` naming the live backend; LINE/TEXT/INSERT
raise a plain error, since no engine can give them an area and a capability
tag would falsely suggest switching helps. `AREA`/`MEASUREGEOM` now route here
rather than at the points-based tool — a drafter typing AREA picks objects,
they do not retype coordinates.
- **Handle grounding: `view_screenshot(overlay_handles=true)`.** A screenshot
showed the model what the drawing looks like and nothing it could act on —
every modify tool takes a handle, and nothing connected "the circle at the
top-left" to a hex string. Each entity is now labelled with its handle at its
own bounding-box centre (taken from the renderer's extents, so the label lands
where the entity is *drawn* even in a rotated frame). Off by default, because
labels are ink on the drawing; past 40 entities the render is capped and the
image itself says "handles shown for N of M" rather than letting a labelled
subset read as the whole sheet. The live backend captures the AutoCAD window
rather than rendering, so there is nothing to label there — it refuses with
`capability: "handle_overlay"` instead of quietly returning an unlabelled
image, since producing the overlay would mean creating and deleting entities
in the user's open drawing.
- **`block_create_from_entities` works on the live backend.** It refused
outright and pointed at `system_run_command` with `_BLOCK`, pushing callers
through the free-text escape hatch for something ActiveX does directly
(`Blocks.Add` + `Document.CopyObjects`). The tool's own docstring said the
opposite — "works by using AutoCAD's BLOCK command (COM backend only)" — so
the one engine that could not do it was advertised as the one that could.
Both engines now also report `skipped` handles instead of swallowing them, and
a call where nothing resolves fails rather than leaving an empty definition
behind. COM path unit-tested against a fake ActiveX object, not
hardware-verified.
- **Undo and redo on the headless backend (`EZDXF_UNDO_DEPTH`, default 0).**
The plan called this "add redo". The larger half was that **undo never worked
either**: `_undo_stack` was declared, read and cleaned up, but nothing ever
appended to it, so `drawing_undo` could only answer "Nothing to undo" — while
the tool summary said "Step back one operation" and the alias corpus routed
`UNDO` and "take that back" straight at it. The old test asserted
`isinstance(result, dict)`, which a permanent failure satisfies.
It is opt-in because it is not free. ezdxf keeps no journal, so a history step
is a whole DXF snapshot: measured 3.1 ms / 19 KB at 10 entities but 130 ms /
658 KB at 5000, and switching it on costs **37×** on entity creation (0.18 →
6.65 ms per call) even on a small drawing. Off, it costs nothing; on, you get
real multi-step undo/redo. Both tools refuse with
`capability: "undo_history"` naming the setting rather than pretending. The
live COM backend uses AutoCAD's own undo and ignores it. Drawing after an undo
discards the redo branch, as in AutoCAD — otherwise redo would restore a state
that never existed, with geometry you had removed reappearing beside geometry
you drew afterwards.
- **BREAKING (behavior): geometry now goes into the layout you selected.**
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current":
"A3-Sheet"}` and then every entity created afterwards landed in **model
space** anyway, on both engines — `_msp()` returned model space
unconditionally, so the tool reported a state change that changed nothing.
Proven: a line drawn straight after selecting a layout was in modelspace and
not in the layout. That was also why a title block could not go on a sheet, so
`titleblock_apply_iso_a3` gains a `layout` parameter — where a title block
belongs, since the border frames the printed sheet rather than the model — and
restores your current space afterwards rather than leaving you on the sheet.
Existing model-space workflows are unaffected: a fresh drawing starts in model
space, and a refused layout switch leaves the current space where it was.
- **ISO 286 transition and interference holes (K, M, N, P).** `fit="P7"` used to
raise by name. They are now derived by the standard's delta rule,
`ES = -ei + Δ` with `Δ = IT(n) − IT(n−1)` — a *rule* over the shaft and IT
tables already in the module, not new table data, which is why it can be
pinned against published values (K7/M7/N7/P7 and K6/N6/P6 cross-checked by
hand) instead of merely trusted. Unlocks the hole-basis pairs K7/h6, M7/h6,
N7/h6, P7/h6.
- **ISO 286 interference shafts (r, s, t, u).** `fit="s6"` and `fit="u6"` — the
standard press and drive fits — used to raise. Unlike K/M/N/P these *are* new
table data, sub-stepped more finely than the main size steps (ISO 286 splits
18–30 into 18–24/24–30, 30–50 into 30–40/40–50, and every step above 50 mm in
two or three; flattening that is a silent error of tens of microns at exactly
the sizes press fits are used at). Because it is data rather than a rule, its
**provenance is tested**: every value is cross-checked against ISO 286-1's own
derivation formulae, an independent source. `r` reproduces as the geometric
mean of `p` and `s` on **24/24** steps — with `p` pinned in this module before
`r` existed — and `s`/`t`/`u` track their IT7-based formulae within 1 µm on
every step from 18 mm up. The five places where the standard's rounding puts a
published value 2–3 µm off its own formula are listed by name in the tests, so
a reader holding ISO 286 checks five numbers rather than ninety-five. Shaft `t`
is undefined below 24 mm in the standard and refuses there rather than being
extrapolated. Unlocks H7/r6, H7/s6, H7/t6 and H7/u6.
- **Headless performance lane** (`benchmarks/perf_suite.py` +
`render_perf_chart.py`): fixed wall-time workloads (2k individual creates,
10k build/export/reopen roundtrip, 10k region query, full premium quality
@@ -17,18 +271,487 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Task-matrix chart** (`benchmarks/render_matrix_chart.py`): tasks × servers
status heatmap rendered from the published live-run reports, so coverage
gaps are visible per capability instead of one folded score.
- **Tool discovery** (`DISCOVERY_MODE=search`, default `off`): replaces the
advertised catalog with `search_tools` + `call_tool`, so a client sees two
tools instead of 127. Ranking layers an AutoCAD command / synonym corpus
(`discovery/aliases.py`, 132 tools · 148 command tokens · 583 synonyms) over
fastmcp's BM25 index, and hits are returned as one compact line per tool
rather than full JSON schemas. Measured on a 66-query golden set with a
15-query holdout: **top-1 62/66 vs 23/66** for stock BM25, top-3 64/66 vs
33/66 (holdout top-3 14/15 vs 9/15); stock returns *nothing at all* for 21 of
the 66, and this transform for none of them — that gap is the corpus, not the
ranking, and no BM25 parameter closes it. (Both sides moved slightly during
the release as tool docstrings were rewritten: better prose feeds *both*
indexes, which is why the headline is the top-1 gap rather than a ratio
between two scores over a shared corpus.) A `risk` ceiling
keeps destructive tools out of read-only answers and says when it hid one.
Off by default — flipping it changes what every connected client sees on
`list_tools`, which needs its own migration note.
- **`@cad_tool` — a discovery-metadata channel next to the tool contract.**
A decorator written *above* an `mcp.tool` registration attaches a `cad` key
to that tool's MCP `meta`: a one-line `summary` for a search hit's preview,
a `cost` from a closed set (`read`, `safe`, `mutate`, `destructive`,
`escape`) for clients that want to gate or colour-code the surface before
calling, and the tool's AutoCAD command names and synonyms — imported from
`discovery/aliases.py`, never restated, so the corpus stays the one edit
site. `tags=` and `annotations=` deliberately stay on the `mcp.tool` call, so
the channel cannot perturb `_tool_groups()` or the registration-counting
release gate. Misuse fails at import rather than silently no-opping: an
unknown cost raises, and a decorator written below the registration (or on a
bare function) raises with the ordering rule in the message. Applied to the
three `transaction_*` tools so far; every other tool still falls back to the
first line of its docstring in search results, which is why the rollout can
be section-by-section instead of a 131-tool patch.
- **`EZDXF_CALL_TIMEOUT`** (default `120`s, `0` disables): per-call timeout for
the headless backend, mirroring `COM_CALL_TIMEOUT`. Every ezdxf method runs
under one document lock, so a single hung call previously deadlocked every
later tool call.
- **`uv.lock` — the dependency graph CI actually installs.** 95 packages,
resolved universally over `requires-python >=3.11`, so one file serves the
Linux 3.11/3.12 matrix and the Windows leg (platform-only packages like
pywin32 carry markers). Every lint and test lane now installs with
`uv sync --locked`, which *fails* rather than re-resolving when the lock and
`pyproject.toml` disagree — a fresh CI run can no longer pick up an upstream
release nobody chose, and the locked set is the set the suite is green on.
The `package` job stays unlocked on purpose: it is the only job that proves
the published dependency *ranges* still resolve for a plain
`pip install autocad-mcp-pro`. The `uv.lock` line in `.gitignore` (added when
the committed lockfile was stale) is gone with it — a lockfile git ignores is
not a lockfile.
- **Scheduled floating-dependency job** (`.github/workflows/deps-floating.yml`):
weekly and on demand, it ignores the lock, resolves the declared ranges fresh
on 3.11/3.12 and re-runs the suite. Locked lanes cannot be broken by an
upstream release, which also means they cannot *notice* one; this is where a
range ceiling that needs moving shows up. It never gates a pull request.
- **Benchmark matrix v3 — five tasks that can fail.** The v2 matrix scored this
server 10/10, which was not evidence of anything: every task in it exercised
something the server was built around, so full marks were the only available
outcome. `benchmarks/tasks_v3.py` keeps the v2 ten byte-for-byte (their ids
and weights key the published v1.4 reports) and adds `tool_discovery`,
`token_budget`, `hatch_islands`, `selection_filter` and
`measure_from_handle`. Each verifies against a value derived independently of
the code under test — a closed-form area, entities placed on purpose, a token
ceiling fixed before the measurement. **Three of the five failed while being
written**, and the defects they found are in *Fixed* below.
Two are renamed from the release plan, and the renames are the point rather
than bookkeeping. `tool_discovery_bilingual` lost its "bilingual": the
Turkish query normalizer was written and then removed, because this
repository is English throughout and the Turkish was inferred from the
author's chat language, not required by the product — a task named
"bilingual" would benchmark a capability that does not exist.
`measurement_massprops` became `measure_from_handle`: no centroid or
moment-of-inertia tool ships, since F2 was cut to `entity_measure`.
The competitor reports are **not** re-scored. Those servers were pinned and
run against v2 at v1.4 and have never been asked these five questions; the
matrix chart renders the five rows blank with a `not run` legend entry rather
than zero, because an invented zero is indistinguishable from a measured one.
`run_competitors` grows `--matrix v2|v3` (v2 stays addressable so a v1.4
report can be reproduced rather than only described), records which matrix
ran in the report, and grows `--publish`, which writes the artifact-path-free
file that `results/published/` holds — that sanitisation used to be a manual
edit.
- A capability refusal in the benchmark runner is now recorded `unsupported`
instead of `fail`. The result enum has carried `unsupported` since v2 for
exactly this, but a refusal is still an exception and was being caught by the
generic handler — "your engine cannot reach this" and "your server got this
wrong" are different findings about a competitor.
- Three measurement checks added to `correctness_suite.py` (24, was 21). They
are new capability rather than repaired regressions — v1.4.0 misses them by
not having the method — and they are there because all three broke during
this release and nothing outside their own unit tests would have noticed.
### Changed
- **The backend contract is 19 modules instead of one 1854-line file, and
optional methods are declarative.** `backends/base.py` held a single ABC with
112 abstract methods; adding one made *both* backends fail at instantiation
until both were written, and two test modules construct `ComBackend` at
import time, so a half-finished contract edit took the entire suite red. The
interface now lives in `backends/contracts/`, one module per domain, split
along the section markers that were already in the file — the seams were not
re-decided. `AutoCADBackend` is the composition of all 19 and stays the
import surface, so `from backends.base import AutoCADBackend` and every
`isinstance` check are unchanged, and the abstract-method count is identical
before and after.
The split surfaced something the file's name hid: roughly 740 of those lines
are shared *concrete* implementation (premium meta-tools, GD&T, the settings
facade), not interface. Those slices are mixins and are labelled as such
rather than presented as pure contracts.
New `backends/capability.py` carries `@capability(key, reason=...)`, which
marks a contract method optional and supplies a default. The default
**raises** `UnsupportedCapabilityError` rather than returning a quiet
`{"ok": False}` — a method nobody implemented has to be indistinguishable
from a deliberate refusal, not from a soft failure, which is the distinction
this release spent a milestone restoring. A key used by the decorator must be
declared in *both* capability maps or `tests/test_capability_contract.py`
fails; without that gate the decorator would just be a way to ship refusals
`system_capabilities` never mentions, which is worse than the build break it
replaces. Its first user is COM's `entity_change_space`, which now inherits
the refusal instead of hand-rolling it. The refusal vocabulary moved into
this module as well (the contracts need it and `base` imports the contracts);
`backends.base` re-exports it, so there is still exactly one exception class
object in play.
- **BREAKING (env): the `core` tool profile was removed.** `TOOL_PROFILE` is
now `lean` or `full`; the discovery layer above solves the crowded-surface
problem `core` existed for. `TOOL_PROFILE=core` still boots — it falls back
to `full` with a warning naming the removal, and `system_about` reports the
requested profile alongside the applied one.
- **BREAKING (install): `ezdxf>=1.4` is now the floor** (was `>=1.3`). The 1.4
line is the only one this release is developed and tested against; 1.3 is no
longer exercised by CI and is unsupported. Upgrade with
`pip install -U "ezdxf>=1.4"`.
- **Runtime pins narrowed.** A published library cannot pin exactly and stay
co-installable, so what a user's `pip install` gets is still a range — and a
range is the only guard that install has. They are therefore deliberately
narrow. (`uv.lock`, above, is the exact guard on *our* side; the two are not
substitutes for each other.)
- `fastmcp>=3.0` → `fastmcp>=3.4.5,<3.5`. `fastmcp` is now a thin
meta-package (`Requires-Dist: fastmcp-slim[client,server]`) and 3.x moves
fast enough that a new minor can reshape the API the server is built on.
Tested against 3.4.5; the ceiling moves only after the suite is re-run.
- `ezdxf>=1.3` → `ezdxf>=1.4,<2` (see the BREAKING note above).
- `pydantic>=2.0` → `pydantic>=2.0,<3`, so a future pydantic 3 cannot be
pulled into an install of this release.
- **Rendering requires an extra — documented, not changed.**
`ezdxf.addons.drawing.__init__` imports `.frontend`, which does an
unconditional top-level `import PIL.Image`. Pillow is therefore a hard
requirement of *every* ezdxf render path (PNG screenshot and PDF export
alike), not just of the COM window-capture path it is listed under. A bare
`pip install autocad-mcp-pro` resolves 77 packages with neither Pillow nor
matplotlib, so it cannot render at all; `[pdf]` (or `[full]`) is required,
and satisfies Pillow transitively via matplotlib. Note that `[com]` alone
installs Pillow but not matplotlib, so the ezdxf backend still cannot render
under it. Pillow deliberately stays out of the core dependencies for now.
- **CI lint pinned to `ruff==0.16.1`** (was an unpinned `pip install ruff` /
`ruff>=0.8`). The version is now written in exactly one place —
`[dependency-groups] dev` — and frozen there by `uv.lock`; the workflows
install ruff *from* the lock instead of restating the pin, so the three
copies that had to be bumped together no longer exist.
- **Dev toolchain moved to a PEP 735 dependency group.** `[tool.uv]
dev-dependencies`, which uv now warns is deprecated, became
`[dependency-groups] dev` — same four tools, byte-identical resolution. The
standard spelling is also understood by pip >=25.1, so `pip install --group
dev` gives contributors who do not use uv the same list without a second
copy of it.
- **CI and the release gate install from the lock.** `lint` (dev group only —
ruff without building the project), `test-linux`, `test-windows` and the
`release.yml` test job now run `uv sync --locked` + `uv run --no-sync`, with
uv itself pinned in the workflow env for the same reason ruff is. `package`,
`docker` and `registry-validate` are unchanged. `benchmark.yml` is locked
too: that lane publishes evidence, and an ezdxf release moving our own
numbers between runs would make the diff unattributable.
- README rebuilt: PyPI-first install, a four-lane benchmark section with
Python-generated visuals (scores, task matrix, performance, source review),
v1.4 feature table, updated client/config/roadmap sections.
- `render_live_chart` now skips non-task-matrix reports so multiple lanes can
share `benchmarks/results/published/`.
_Next (1.5): screenshot overlay + handle grounding v1, ezdxf redo (forward
snapshots), COM `block_create_from_entities`, ISO 286 transition/interference
hole letters (delta rule), titleblock on paper-space layouts._
### Fixed
- **The COM paths were run against a live AutoCAD 2026, and four of them were
broken.** Every COM implementation added in this release was written against
the ActiveX reference and shipped labelled unverified. Executing them against
a real seat — a fresh instance, a throwaway drawing, nothing saved — found
four defects that no amount of reading would have caught, one of them
pre-existing:
* **`AcadPViewport` has no `ViewCenter` member.** `viewport_list` died on it
outright. Worse, `viewport_create` has carried the same line since v1.4
wrapped in a bare `except: pass`, so it raised on every single call and
said nothing — every COM viewport has been looking at the model origin
instead of the `view_center_x/y` the caller asked for. The member is
`Target`, and the failure is no longer swallowed.
* **`ModelSpace.AddWipeout` does not exist.** ActiveX has no wipeout
constructor at all; WIPEOUT is a command. Now a typed refusal
(`capability: "wipeout"`) instead of an `AttributeError`.
* **`AcDbMText.BackgroundFillColor` does not exist** — it raises on read as
well as on write, with an int and with an `AcCmColor` object alike.
`BackgroundFill` (the on/off flag) does work, so `text_set_background`
enables and disables the mask on COM and refuses a *colour* request with
`capability: "mtext_background_color"`, rather than switching the fill on
in the wrong colour and reporting success.
* `analysis_list_properties` called a helper that does not exist on the class.
After the fixes, all 25 verification calls behave correctly: 18 succeed, 1
refuses per-argument (deleting model space), and 6 refuse with the capability
keys they declare. The remaining COM refusals
(`chspace`, `revcloud`, `boundary_trace`, `hatch_edge_paths`) were confirmed
to hold rather than leak a result. The source comments now say VERIFIED with
the date instead of UNVERIFIED.
- **The headless renderer *does* project model content through viewports.
v1.4 said it did not.** `viewport_render` was declared unsupported on ezdxf,
every `drawing_export_pdf(layout=...)` response carried the note "projecting
model content through viewports is COM-only", and the README table agreed —
so a caller with no AutoCAD was told to go get one for something that already
worked. Measured on ezdxf 1.4.4: a sheet holding *nothing but a viewport*
renders the model geometry that viewport looks at, two viewports at 1:1 and
1:2 render the same circle at two sizes in the right places, and geometry
outside a viewport's window is correctly left out. Understating a capability
is the same class of untruth as overstating one, and it is the more expensive
one for the user. What is genuinely missing is the viewport *border*, which
the headless renderer does not draw — the capability now says exactly that
(`reason: viewport_borders_not_drawn`), and the old note's claim that
"viewport frames are rendered" was wrong in the other direction. Two tests
that pinned the false claim were corrected rather than deleted, and the
replacement proves the behaviour by subtraction: the sheet renders less ink
once the model geometry is gone.
- **Layout names are matched case-insensitively, the way the engine underneath
already matched them.** ezdxf resolves layout names case-insensitively in
`get`, `new`, `rename`, `delete` and `set_active_layout` — but `names()`
returns the *stored* spelling, so every guard written as
`name in doc.layouts.names()` disagreed with the code it was guarding, in both
directions. `layout_create("SHEET")` next to an existing `Sheet` walked past
its own duplicate check and died on a raw `DXFValueError` from inside ezdxf;
`layout_set_current("SHEET")` reported "Layout not found" for a tab that was
right there. Both shipped in v1.4. One resolver now backs
create/set_current/delete/rename/copy and `viewport_list` on both engines, and
the current space is stored in the canonical spelling. Re-spelling a layout's
own name (`Sheet` → `SHEET`) is a rename onto itself rather than a collision;
ezdxf's `rename` refuses that outright, so it goes through a staging name.
- **`entity_change_space` refuses an entity that lives on another sheet instead
of half-applying.** `move_to_layout` raises `DXFValueError` from deep inside
ezdxf when the entity is not in the source layout — and it raises *after* the
transform has already been applied, so the failed call left the line rescaled
and translated by a viewport it never moved through, still sitting on the
wrong sheet. Membership is now checked before anything is written, and the
refusal names the space the entity is actually in. One bad handle in a batch
no longer aborts the good ones.
- **BREAKING (behavior): coordinates crossing the boundary are now WCS. They were
not.** DXF stores CIRCLE, ARC, LWPOLYLINE, TEXT, INSERT and a dimension's text
position in a per-entity frame derived from the entity's `extrusion` vector —
and the string "extrusion" appeared *nowhere* in this repository, so every such
read returned a raw attribute rather than a world coordinate. Mirroring flips
extrusion to `(0,0,-1)` and leaves the stored x unnegated, so after the
server's own `entity_mirror` across the Y axis a circle drawn at (30, 20)
reported `center [30.0, 20.0]` while the **same response's** `bounding_box`
said `min [-35, 15] / max [-25, 25]`: the payload contradicted itself by 60 mm,
silently, with no error anywhere. `point_from_snap` returned points on the
wrong side of the axis, `entity_select_smart` found the entity where it wasn't,
and `entity_offset` placed the new circle 60 mm away *and* grew it when asked
to shrink. Reads are now normalised at the single `_entity_info_dxf` funnel, so
all 38 call sites and every consumer of `EntityInfo.properties` are fixed with
them. The write paths (`entity_edit_geometry`, `entity_offset`) got the
matching inverse — mandatory, not cosmetic: read and write were *equally*
wrong, which made a read-then-write round trip an accidental no-op, and fixing
reads alone would have turned that no-op into a real 60 mm move.
- **Not touched, deliberately:** LINE, MTEXT, ELLIPSE, SPLINE and POINT carry
an `extrusion` attribute too and were always correct (ezdxf makes their
`ocs()` a pass-through); `entity_rotate` was never affected — it preserves
extrusion `(0,0,1)`. The trigger was `entity_mirror` and foreign DXF files.
A dimension's `defpoint` is DXF group code 10 and WCS already; only
`text_midpoint` (code 11) is in the entity frame.
- **Tilted planes.** An entity whose plane is not parallel to WCS XY cannot be
described by an xy pair — a tilted circle of radius 5.0 has a 5.0 × 3.99
footprint. `entity_get` reports the WCS centre plus `plane_normal` and
**omits** `radius` / `start_angle` / `end_angle` rather than reporting a
number that is not true in the frame the rest of the dict uses, and
`entity_edit_geometry` refuses with `capability: "ocs_tilted_plane"`,
pointing at `entity_move` (which transforms the whole entity and preserves
its plane). New capability keys `ocs_normalized` and `ocs_tilted_plane` on
both backends make the boundary pre-checkable via `system_capabilities`.
- **Named residuals** (in the `ocs_normalized` reason rather than left silent):
the OCS z component is not reported, HATCH boundaries are not reported at all
(silence, not a wrong number), and TEXT `rotation` stays in the entity frame
because a mirrored TEXT is mirror-imaged and no single scalar angle expresses
that — measured 29.999 reported against a true WCS 150.0.
- **COM:** ActiveX returns WCS natively for every point property *except*
`AcDbLWPolyline.Coordinates`, which is OCS; that one read is now translated.
Verified only against a fake COM object — this machine has a live AutoCAD and
connecting to it during development is forbidden, so the path ships
unit-tested, not hardware-verified. `AcDb2dPolyline` is deliberately out of
scope: its `Coordinates` may be 3D triples, and changing the stride on a
documentation reading alone would risk a path that works today.
- **`validate_path` handed out working handles to `C:\Windows`.** Windows names
the same file through several anchors that compare unequal. The backslash
spellings were rejected, but `pathlib` normalises the *forward-slash* forms
into them only after `.resolve()` — so `//?/C:/Windows/win.ini` slipped past
the pattern check and then failed to match `C:/Windows` in the
system-directory loop. Proven: it returned a handle that `os.path.samefile`'d
the real file. Non-local anchors are now rejected in every spelling. This
removes no capability anyone had: the equivalent backslash paths were already
refused, so it only makes one policy consistent across spellings.
- **The AutoLISP guard read drawing text as code.** The symbol denylist scanned
inside double-quoted literals, so an engineering server refused its own
vocabulary: `(setq desc "Max load 500 kg")` matched the `load` pattern and
`(setq layer "C:CENTER")` matched the custom-command pattern. Literals are now
blanked before scanning. Every known-dangerous vector still blocks, including
`(load "malicious.lsp")` and `(vl-catch-all-apply (quote command) ...)`, whose
dangerous token is outside the quotes.
- **The command denylist matched characters, not commands.** A word-boundary
regex over the whole string refused `MTEXT 0,0 10,10 Do not erase this part`
— annotation text — while missing spellings AutoCAD accepts. SendCommand takes
a *macro*: segments split on newline or `;`, and only a segment's first token
is a verb. Matching is now verb-scoped with decorators (`_`, `.`, `-`, `'`)
stripped, the set gained the load/run channels it was missing (`ARX`,
`VBASTMT`, `VBAIDE`, `CUILOAD`, `SCR`, `SAVE`, `EXPORT`, `ETRANSMIT`, …), and
the refusal now names the offending verb — previously a user whose annotation
was refused had no way to tell why. Single-letter aliases stay out on purpose:
`E` is the ERASE alias but also ZOOM's Extents option, so blocking it would
refuse `_ZOOM\nE\n`, the canonical zoom-extents macro.
- **README no longer claims the denylist is a security boundary.** It said
"blocked by default, with regression tests for known bypass patterns"; the
bypasses above had no tests, and no denylist over a command language that
loadable modules extend can be complete. It now says what the guard is — a
guardrail against an agent accidentally issuing `ERASE ALL` — and names the
actual boundaries: `ALLOWED_PATHS`, not exposing the server over the network,
and the typed tools. The same sentence is in the two tool docstrings, so it
reaches the model that calls them. *Not* changed: blocking `INSERT`/`SAVEAS`
as command strings while `block_insert`/`drawing_save_as` exist was flagged as
incoherent and is not — the policy is scoped to the free-text channel, and the
typed tools take validated arguments where the raw commands take file paths.
The reasoning is recorded in `security.py` so it is not re-opened.
- **`config.settings.backend` was a control that controlled nothing.**
`_make_backend` read `os.environ["AUTOCAD_MCP_BACKEND"]` directly, so the
setting had no production reader at all and
`monkeypatch.setattr(config.settings, "backend", "ezdxf")` — which two tests
used — read like a safety guard while doing nothing. On a developer machine
with AutoCAD open that meant `auto`, i.e. attaching to the operator's live
unsaved drawing and creating entities in it. `Settings.backend` is now a
read-only property that reads the environment live, `_make_backend` goes
through it, and the test suite pins the headless backend for every test via an
autouse fixture (opt out with `@pytest.mark.live_backend`). Assigning to
`config.settings.backend` now raises `AttributeError` instead of silently
protecting nothing. `monkeypatch.setenv` — what the correct call sites already
used — is unaffected.
- **A capability refusal now survives the trip to the client.** The backends
always knew exactly why they could not do a thing — they raise
`UnsupportedCapabilityError(capability, message)`, where `capability` is a key
of the capability map, so `system_capabilities` answers the same question
up-front. None of that reached anyone: `FastMCP.call_tool` rewraps a
non-`FastMCPError` as `ToolError(f"Error calling tool {name!r}: {e}") from e`,
and while the `from e` keeps the cause alive in-process (which is why
`cad_batch` could classify refusals), `__cause__` does not serialise. A remote
client got an English sentence, and telling "this backend cannot" from "your
arguments were wrong" meant substring-matching prose. A refusal now returns
`is_error: true` with
`{"ok": false, "kind": "unsupported", "capability", "error", "tool",
"backend"}` in `structured_content`, produced by one new
`CapabilityRefusalMiddleware` — no tool signature changed. *Not* done by
rebasing the exception on `FastMCPError`: that was measured and changes
nothing a client can observe, since neither the payload nor the error flag
travels with the exception class. Non-capability errors are byte-for-byte
unchanged. `UnsupportedCapabilityError` moved to `backends/base.py` (still
importable from `backends.ezdxf_backend`, and the *same* class object) so both
backends raise one type, and the five headless `solid_*` methods now raise it
instead of returning `{"ok": false}` — same payload, but `is_error` stops
being quietly `false` on a call that did nothing.
- **BREAKING (behavior): `cad_batch` counted a step that declined *by value* as
a success.** A tool that returns `{"ok": false, ...}` rather than raising was
recorded `status: "ok"` and folded into `succeeded`, so a batch could report
`succeeded: 12` over work that never happened — the same class of lie as the
DWG bytes and the unverified rollback. Such a step is now `status: "error"`
with kind `unsupported` (when it names a capability) or `refused`, counts
toward `failed`, and honours `on_error` like any other failure. Batches that
relied on, say, a no-op `drawing_redo` passing silently will now stop or roll
back; that is the point. **Read-only tools are excluded on purpose**:
`validation_check` / `drawing_critique` / `drawing_preflight` answer
`{"ok": len(issues) == 0}`, so their `ok: false` means *I found something*,
not *I could not run* — reading the two the same way would let a read-only
check trigger a rollback that destroys the batch's own geometry. The
discriminator is the `readOnlyHint` annotation each tool already publishes.
- **BREAKING (behavior): `drawing_audit` said "0 errors" about documents it had
just silently rewritten — and then threw the repairs away.** `Drawing.audit()`
is not a read: it fixes every fixable problem in place, so `auditor.fixes` is
a log of mutations already applied. The tool discarded that list entirely,
never marked the document dirty — so `drawing_close(save=True)` saw a "clean"
document, skipped the save, and the repairs it had just made were lost — and
rendered the errors it *did* report through `str()` on an `ErrorEntry` that
defines no `__str__`, i.e. as `<ezdxf.audit.ErrorEntry object at 0x...>`.
The tool now returns `repaired` / `fixes` / `fix_count` alongside
`errors` / `error_count`, both as `{code, name, message, handle}` dicts, and
marks the document dirty when it repaired something. **The behavioral break:
an audit that repairs now leaves unsaved changes, so a following
`drawing_close(save=True)` rewrites the file where it previously did not.**
That is the repair finally persisting; the tool was already annotated
`readOnlyHint: false` / `cost: mutate`. **Audit can delete entities** — an
entity whose owner handle points nowhere is removed, not patched — and with
the dirty flag fixed, that deletion now reaches disk on the next save where
before it was accidentally discarded. ezdxf reports those particular fixes
without the entity attached, so `handle` is `null` for them; the message names
the type and handle in prose. Read `fixes` before saving. On the COM backend `_AUDIT Y` likewise
repairs, but AutoCAD returns nothing machine-readable over `SendCommand`, so
the counts come back as `null` with `detail: "unavailable"` and
`capability: "audit_detail"` — never `0`, which would claim nothing was wrong
— plus the path of the `.adt` log. That backend also no longer leaves the
user's `AUDITCTL` permanently set to 1.
- **BREAKING (behavior): the ezdxf backend no longer writes DXF bytes into a
`.dwg` name.** `drawing_save("part.dwg")` previously called `doc.saveas()`
and produced a file whose contents did not match its extension — AutoCAD
cannot open it, and nothing in the response said so. Both save paths now
refuse, driven by the extension, so `drawing_save_as("x.dwg", fmt="dxf")` is
refused too. Refusals carry a machine-readable
`{"ok": false, "capability": "dwg"}`, and `dwg` is now a declared capability
in both backends (ezdxf: unsupported; COM: native). Save as `.dxf`, or use
the COM backend for DWG.
- **`drawing_open` no longer pretends it can read DWG.** ezdxf detects format
by content, so a DXF renamed `.dwg` (exactly what the bug above produced)
opened "successfully" as a document that does not exist in that format,
while a genuine DWG died with a raw `OSError` blaming the file rather than
the backend. The tool now refuses `.dwg` unless the active backend declares
the capability, and its parameter text no longer advertises DWG
unconditionally. Workflows holding files produced by the old `drawing_save`
bug must rename them to `.dxf`; the error message says so.
- **`TOOL_PROFILE` was a silent no-op whenever a tool transform was attached.**
`_registered_tools()` read the post-transform tool list, so with a transform
in place it saw 2 tools instead of 131 — the lean profile computed 0
disabled tools and `system_about` reported a collapsed inventory. It now
reads the underlying registry.
- **`ruff format --check .` failed on unmodified `main`.** Ruff 0.16 began
formatting Python code blocks inside Markdown, so the committed
`docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md`
started failing the format gate with no source change. Reformatted the file
and pinned the CI ruff version so a future release cannot re-break the gate
the same way.
- `release.yml` now runs `ruff format --check .` alongside `ruff check .`,
matching the CI lint job — the release gate previously skipped formatting.
- **A HATCH could not be measured, and the refusal listed HATCH as
measurable.** `entity_measure` had no HATCH branch, so a hatch fell through
to the generic error — whose own text named HATCH among the measurable types.
Whichever half was right, the pair could not both be. It now walks the
boundary paths and reports the area the hatch actually *fills*: outer loops
minus the islands inside them, 300 rather than 400 on a 20×20 square with a
10×10 island. Nesting depth is computed by containment rather than read off
the boundary flags, because DXF marks a path external or outermost and never
"two levels down" — and depth is exactly where AutoCAD's three island styles
stop agreeing. `hatch_style` comes back with the number so it can be read
against the drawing's own setting. On a curved edge `flatten_tolerance` is
not the accuracy knob it looks like: ezdxf hands boundaries over as cubic
Beziers, so tightening it converges on the Bezier's area, ~0.028% above the
circle it stands for, which is why `exact` goes False and stays False.
- **`boundary_trace` and `entity_measure` disagreed about the same shape.**
The boundary tools reported an area taken from a copy of the loop flattened
at a fixed 0.01 sag while `entity_measure` read the stored bulges
analytically: 139.2177 against 139.2699 for one figure, with nothing in
either payload marking one as the approximation. The polyline the boundary
tools store carries exact bulges, so the exact answer was always available;
both now read it and round to the same six decimals. The flattened figure
survives only where it is harmless — ranking candidate loops, where a 0.01%
error cannot change which is smallest.
- **A circle stored as two bulged vertices measured 0.0.** Two semicircular
arcs joined into a circle store as a *two*-vertex closed LWPOLYLINE with both
bulges 1.0 — what AutoCAD writes for a JOINed pair of arcs, and what
`boundary_from_entities` builds from any two-arc loop. `polygon_area_perimeter`
treated fewer than three vertices as degenerate and returned zero, when the
shoelace over two points is genuinely 0 and the two circular segments are the
entire shape. Only zero or one vertex bounds nothing now.
- **The v1.4 → v1.5 performance comparison was measured, and it is not
flattering.** The published perf report was recorded on CPython 3.14 and the
new one on 3.11.15, which alone accounts for nearly all of an apparent 2–6×
"speedup". Re-running v1.4.0 in a worktree on the same interpreter with the
same suite file gives the real picture: entity creation is **28% slower**
(245 → 315 ms for 2,000 lines) and the 10,000-line roundtrip **22% slower**.
Fully attributed — `EZDXF_CALL_TIMEOUT=0` returns v1.5.0 to 244.8 ms and
1,679 ms, v1.4.0's numbers exactly. The whole cost is the per-call
`asyncio.wait_for` this release wrapped around every headless call so that
one hung ezdxf call can no longer wedge a server whose document lock is a
single `asyncio.Lock`. Deliberate trade, documented knob, and on the 1.6
roadmap to win back.
_The five items carried into this release from 1.4 are all done: screenshot
overlay + handle grounding, ezdxf undo/redo, COM `block_create_from_entities`,
ISO 286 transition/interference holes, and titleblock on paper-space layouts.
ISO 286 shafts r/s/t/u are deliberately **not** done — they need table data
nothing here can derive, and the refusal says so rather than guessing._
## [1.4.0] — 2026-07-23
+19 -5
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~131 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~154 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
## Running the Server
@@ -53,7 +53,9 @@ Uses `pytest-asyncio` for async test support.
The server uses a strategy pattern with an abstract base:
- `backends/base.py` — `AutoCADBackend` ABC defining the full interface + shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`)
- `backends/contracts/` — the interface, one module per domain (`drawing.py`, `layouts.py`, `entity_creation.py`, …). Split in v1.5.0 out of a single 1854-line ABC; some slices (premium, gdt, settings) also carry shared *concrete* implementations, so they are mixins rather than pure interfaces.
- `backends/capability.py` — the refusal vocabulary (`UnsupportedCapabilityError`) plus `@capability(key, reason=...)`, which marks a contract method optional and gives it a default that **raises** that refusal. Use it only for genuine engine boundaries, never for an unfinished method; `@abstractmethod` stays the default. Every key used must be declared in *both* capability maps — `tests/test_capability_contract.py` fails otherwise, which is what stops the decorator becoming a way to ship undeclared holes.
- `backends/base.py` — shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`, `CapabilityMap`) and the `AutoCADBackend` composition of every contract. Import surface is unchanged: `from backends.base import AutoCADBackend, UnsupportedCapabilityError` still works.
- `backends/ezdxf_backend.py` — `EzdxfBackend`: file-based DXF operations using the `ezdxf` library. All sync ezdxf calls are wrapped with `asyncio.to_thread` via `_async()`. Transactions are implemented as full DXF snapshots on an `_undo_stack`.
- `backends/com_backend.py` — `ComBackend`: live AutoCAD control via `pywin32` COM. All COM calls are routed through a `ThreadPoolExecutor` with a single thread to satisfy AutoCAD's STA (Single-Threaded Apartment) COM requirement.
@@ -84,10 +86,12 @@ Tools are organized into sections (counts are indicative — `system_about` is a
12. Engineering / Deterministic CAD (8 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
13. Premium meta-tools (12): `drawing_preflight`, `drawing_plan`, `drawing_critique`, `drawing_refine`, `drawing_deliver`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus
15. Layouts & Paper Space (4 tools): `layout_list/create/set_current`, `viewport_create` (scaled model viewports); `drawing_export_pdf` takes an optional `layout` param. Viewport model-content projection is COM-only (`viewport_render` capability).
15. Layouts & Paper Space (12 tools): tab lifecycle `layout_list/create/set_current/delete/rename/copy`, viewports `viewport_create/list/set_scale/lock/delete`, and `entity_change_space` (CHSPACE — tagged `layout` *and* `modify`, and `layout` wins the group priority, so it files here). `drawing_export_pdf` takes an optional `layout` param and **does** project model content through viewports on both engines (`viewport_render` — v1.4 declared this COM-only and was wrong; the headless renderer only omits the viewport borders). `entity_change_space` is the mirror case — ezdxf-only (`chspace`), because ActiveX exposes no change-space member and the COM route is unverified.
16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS).
**Tool profiles:** `TOOL_PROFILE=lean|core|full` (default `full`) controls the advertised surface — `lean` ≈ 46 curated drafting tools, `core` hides raw escape hatches (`system_run_command/lisp`, low-level variables, long-tail tools). Applied in the lifespan; reported by `system_about`.
**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` ≈ 47 curated drafting tools (including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
**Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index.
**ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text.
@@ -95,9 +99,13 @@ Tools are organized into sections (counts are indicative — `system_about` is a
**Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric.
**Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so.
**Benchmark matrix:** `benchmarks/tasks_v3.py` is the current set (15 tasks; `--matrix v2` reproduces the v1.4 ten). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (24 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.4.0`.
### Adding a New Tool
1. Add the abstract method to `AutoCADBackend` in `backends/base.py`
1. Add the method to the matching module in `backends/contracts/` — `@abstractmethod` if both engines must have it, `@capability("key", reason=...)` only if it is a real engine boundary (and then declare `key` in both capability maps, or the gate fails)
2. Implement it in `EzdxfBackend` (`backends/ezdxf_backend.py`) using the `_async(func)` wrapper
3. Implement it in `ComBackend` (`backends/com_backend.py`) using `self._com(func)`
4. Register the tool in `server.py` with `@mcp.tool(...)` calling `_backend(ctx).your_method(...)`
@@ -107,6 +115,12 @@ Tools are organized into sections (counts are indicative — `system_about` is a
- **Entity handles**: hex strings (e.g. `"1A2B"`). Always returned from create/copy operations; required by all modify/query operations.
- **Coordinates**: drawing units (mm by default); angles in degrees, counter-clockwise from X axis.
**Every coordinate in or out of a tool is WCS on both engines.** DXF stores
CIRCLE/ARC/LWPOLYLINE/TEXT/INSERT geometry in a per-entity frame (`extrusion`);
`backends/ocs.py` translates at the backend boundary, so OCS never reaches a
tool. An entity in a plane tilted out of WCS XY reports `plane_normal` and omits
the fields xy cannot express; writing an xy into such an entity is refused with
`capability: "ocs_tilted_plane"` — use `entity_move` instead.
- **ACI colors**: 1–255 for specific colors, 256=ByLayer, 0=ByBlock.
- **COM backend only**: `system_run_command`, `system_run_lisp`, and `view_zoom_extents/window` (view ops are no-ops in ezdxf).
- **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib.
+1
View File
@@ -8,6 +8,7 @@ COPY pyproject.toml README.md LICENSE ./
COPY server.py config.py security.py version.py ./
COPY backends/ ./backends/
COPY engineering/ ./engineering/
COPY discovery/ ./discovery/
RUN pip install --no-cache-dir -e .
+204 -53
View File
@@ -15,8 +15,8 @@ query exact geometry, apply engineering standards (ISO 128/129/286/1101),
refine quality issues inside transactions, and deliver hashed artifacts with
validation evidence.
> **v1.4 release snapshot:** 131 tools · 6 resources · 5 prompt templates ·
> 474 collected tests. Runtime discovery through `system_about` is authoritative.
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates ·
> 1242 collected tests. Runtime discovery through `system_about` is authoritative.
## Install in 10 seconds
@@ -68,6 +68,12 @@ locally executed evidence, **B** = deep source review without a full live run,
**C** = documented tool surface only. This is a source review, not a shared
live run — the lanes below are the runtime evidence.
**The table above is not re-scored per release.** It reads the dated review,
and this repository's row is still the one written against v1.4.0. Raising our
own number for v1.5.0 would mean re-reviewing eight other projects on the same
day to keep the comparison fair, and that has not been done — so the score
stays where the evidence is.
```bash
python -m benchmarks.render_chart
```
@@ -76,23 +82,32 @@ python -m benchmarks.render_chart
![Live benchmark](docs/assets/autocad-mcp-livebench.svg)
| Server | Pinned commit | Score | Pass | Coverage |
|---|---|---:|---:|---:|
| **autocad-mcp-pro** (this repo) | working tree | **100.0** | 10/10 | 100% |
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | `11f7c47` | 50.0 | 5/10 | 50% |
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | `95476a3` | 45.0 | 4/10 | 50% |
| Server | Matrix | Pinned commit | Score | Pass | Coverage |
|---|---|---|---:|---:|---:|
| **autocad-mcp-pro** (this repo) | v3 (15 tasks) | working tree | **100.0** | 15/15 | 100% |
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | v2 (10 tasks) | `11f7c47` | 50.0 | 5/10 | 50% |
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | v2 (10 tasks) | `95476a3` | 45.0 | 4/10 | 50% |
Ten fixed tasks, one runner, headless ezdxf lane. Competitors are driven
**black-box over MCP stdio** using their own documented tool contracts at the
pinned commit, and every geometry claim is verified by re-opening the exported
DXF with ezdxf inside the harness — no self-reporting. `unsupported` scores
zero in the fixed matrix; per-task reasons are committed under
**Read the matrix column before the score column.** The five v3 tasks are new
in v1.5.0; the competitor runs are pinned at v1.4 and were never put to them.
Their scores are over the ten tasks they actually ran, and the five new rows
are left blank in the chart rather than scored zero — an invented zero is
indistinguishable from a measured one.
One runner, headless ezdxf lane. Competitors are driven **black-box over MCP
stdio** using their own documented tool contracts at the pinned commit, and
every geometry claim is verified by re-opening the exported DXF with ezdxf
inside the harness — no self-reporting. `unsupported` scores zero within a
server's own matrix; a capability refusal is recorded as `unsupported` rather
than `fail`, because "cannot reach this" and "got this wrong" are different
findings. Per-task reasons are committed under
[`benchmarks/results/published/`](benchmarks/results/published/). File-IPC
(live AutoCAD) lanes run locally, not in CI.
```bash
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --list --matrix v3
python -m benchmarks.run_competitors --server autocad-mcp-pro --matrix v3 \
--publish benchmarks/results/published/autocad-mcp-pro.json
python -m benchmarks.render_live_chart
```
@@ -103,7 +118,34 @@ python -m benchmarks.render_live_chart
The single score hides *which* capabilities exist, so the matrix shows every
task against every server: green = verified pass, amber = partial, gray = the
server documents no equivalent (planning, refinement, TABLE/MLEADER, hashed
delivery), red = attempted but failed verification.
delivery), red = attempted but failed verification, blank = never run against
that server.
The v2 matrix scored this repository 10/10, and that was not a good sign —
every task in it exercised something the server was built around, so full
marks were the only possible outcome. **v3 adds five tasks chosen because they
can fail**, and three of them did fail while being written: a hatch could not
be measured at all, the boundary tools disagreed with the measurement tool
about the same shape, and a circle stored as two bulged vertices measured zero.
| v3 task | What it pins | Verified against |
|---|---|---|
| `tool_discovery` | six AutoCAD command names resolve to the right tool | all six rank #1 of a 154-tool catalog |
| `token_budget` | idle catalog cost in discovery mode | 40,305 → 356 tokens, ceiling 2,000 |
| `hatch_islands` | a hatch reports the area it *fills* | 300 with the island, 400 ignoring it |
| `selection_filter` | window ≠ crossing, polygon ≠ bounding box | 1 / 2 / 3 / 1 on placed geometry |
| `measure_from_handle` | curved boundaries measured by handle | 139.2699 against a 100.0 vertex shoelace |
Token figures come from the offline ratio estimator, not a tokenizer — they are
estimates and the report labels them so. `benchmarks/token_suite.py
--tokenizer anthropic` counts for real.
Two tasks are renamed from the release plan, and the renames are the point.
`tool_discovery_bilingual` lost its "bilingual": a Turkish query normalizer was
written and then removed, because this repository is English throughout and the
Turkish was inferred from the author's chat language rather than required by
the product. `measurement_massprops` became `measure_from_handle`: no centroid
or moment-of-inertia tool ships.
```bash
python -m benchmarks.render_matrix_chart
@@ -115,10 +157,31 @@ python -m benchmarks.render_matrix_chart
| Workload | Wall time | Throughput / result |
|---|---:|---|
| Create 2,000 lines (individual calls) | 0.64 s | ~3,100 entities/s |
| 10,000 lines: build + DXF export + reopen | 13.9 s | ~700 entities/s end-to-end |
| Region query over 10,000 entities | 3.4 s | 2,500 matched |
| Premium pass (layers + part + dims + full critique) | 0.32 s | 0 issues |
| Create 2,000 lines (individual calls) | 0.32 s | ~6,300 entities/s |
| 10,000 lines: build + DXF export + reopen | 2.16 s | ~4,600 entities/s end-to-end |
| Region query over 10,000 entities | 0.89 s | 2,500 matched |
| Premium pass (layers + part + dims + full critique) | 0.18 s | 0 issues |
These are roughly 2–6× the numbers the v1.4 README printed, and **almost none
of that is this release**: the old report was recorded on CPython 3.14 and this
one on 3.11.15. Interpreter, not code. Re-measuring v1.4.0 in a worktree on the
*same* interpreter with the *same* suite file gives the honest comparison, and
it does not flatter us — median of three runs each:
| Workload | v1.4.0 | v1.5.0 | Change |
|---|---:|---:|---|
| Create 2,000 lines | 245 ms | 315 ms | **28% slower** |
| 10,000-line roundtrip | 1,765 ms | 2,146 ms | **22% slower** |
| Region query over 10,000 | 1,090 ms | 1,039 ms | within run-to-run spread |
| Premium pass | 375 ms | 44 ms | faster, but the v1.4 spread (279–772 ms) is too wide to put a number on |
The regression is fully attributed: setting `EZDXF_CALL_TIMEOUT=0` returns
v1.5.0 to **244.8 ms** and **1,679 ms** — v1.4.0's numbers exactly. The whole
cost is the per-call `asyncio.wait_for` this release wrapped around every
headless call, so that one hung ezdxf call can no longer wedge a server whose
document lock is a single `asyncio.Lock`. That is a deliberate trade of ~25% of
creation throughput for a deadlock that used to be unrecoverable, and the knob
to take it back is documented rather than hidden.
Workloads call the same backend methods the MCP tools call, so server-side
overhead is included. Self-measurement only — competitor servers would pay an
@@ -133,21 +196,27 @@ python -m benchmarks.render_perf_chart
### 4 · Version A/B — every release re-proves correctness
`compare_versions.py` runs the same 21 deterministic, headless correctness
`compare_versions.py` runs the same 24 deterministic, headless correctness
checks against the previous release tag and the current tree — each check in
its own subprocess, so a hard crash counts as a miss instead of killing the
run.
**v1.4.0 release gate** (baseline `v1.3.0`, report:
[`ab-v1.3.0-vs-v1.4.0.json`](benchmarks/results/published/ab-v1.3.0-vs-v1.4.0.json)):
**v1.5.0 release gate** (baseline `v1.4.0`, report:
[`ab-v1.4.0-vs-v1.5.0.json`](benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)):
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---|---:|---:|---:|---:|
| v1.3.0 (baseline) | 21 / 21 | 100 % | — | — |
| **v1.4.0** (this release) | **21 / 21** | **100 %** | 0 | **0** |
| v1.4.0 (baseline) | 21 / 24 | 87.5 % | — | — |
| **v1.5.0** (this release) | **24 / 24** | **100 %** | 3 | **0** |
The three the baseline misses are *new capability, not repaired regressions*:
v1.4.0 has no `entity_measure` and no boundary tracing, so it misses them by
not having the method. They are in the suite because all three broke during
v1.5.0 development and nothing outside their own unit tests would have caught
it. The 21 pre-existing checks are **unchanged and all still pass** — the whole
of the discovery layer, the contract split, the layout/viewport family and Wave
A landed with **zero correctness regressions**.
v1.4.0 added CI, packaging, benchmarks, tool profiles, paper space, ISO 286
fits and opt-in 3D solids **without breaking a single correctness check**.
For contrast, the same suite caught the v1.0.0 → v1.1.0 jump:
| Version | Checks passing | Pass rate |
@@ -156,25 +225,40 @@ For contrast, the same suite caught the v1.0.0 → v1.1.0 jump:
| v1.1.0 | 21 / 21 | 100 % |
```bash
python benchmarks/compare_versions.py v1.3.0 --json ab.json
python benchmarks/compare_versions.py v1.4.0 --json ab.json
```
Full rubric, caveats, method and boundaries for every lane:
[`benchmarks/README.md`](benchmarks/README.md).
## What ships in v1.4
## What ships in v1.5
| Area | Tools |
|---|---|
| Drawing lifecycle | create, open, save, export DXF/PDF, audit, purge, undo/redo |
| Drawing lifecycle | create, open, save, export DXF/PDF, audit **(now repairs)**, purge, undo/redo |
| Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits |
| Annotation | ISO 129 toleranced dimensions, **ISO 286 fits** (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) |
| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) |
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 titleblock |
| **Paper space** *(new)* | `layout_list/create/set_current`, scaled `viewport_create`, `drawing_export_pdf(layout=...)` |
| **3D solids** *(new, opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) |
| **Discovery** *(new)* | `DISCOVERY_MODE=search` replaces the catalog with `search_tools` + `call_tool`, ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK` score nothing against a stock index because the words are simply not in the data |
| **Batching** *(new)* | `cad_batch` runs a whole step list in one round trip; `fields=` projection trims result-heavy responses |
| **Paper space** *(new)* | full tab lifecycle (`layout_list/create/set_current/delete/rename/copy`), viewports (`viewport_create/list/set_scale/lock/delete`), `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=...)` |
| **Selection** *(new)* | `selection_window` / `selection_polygon` / `selection_filter` — window vs crossing stated back to the caller, and a polygon tested against its own shape rather than its bounding box |
| **Boundaries** *(new)* | `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities` chain loose edges into one closed polyline, arcs kept as bulges |
| **Measurement** *(new)* | `analysis_measure_entity` measures what is *in* the drawing by handle — reading vertices back and shoelacing them loses 28.2% of the area on a semicircular edge |
| **Hatch depth** *(new)* | gradients, in-place edits, typed edge boundaries, island styles |
| **Annotation objects** *(new)* | WIPEOUT, REVCLOUD, MTEXT background masks, text find/replace |
| **3D solids** *(opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) |
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0-100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
| Discovery | **tool profiles** *(new)*: `TOOL_PROFILE=lean` (~46 tools) / `core` / `full` for clients with tight tool caps |
| Profiles | `TOOL_PROFILE=lean` (~47 curated tools) for clients with tight tool caps; `core` was removed — the discovery layer replaced it |
**Every coordinate in and out of a tool is WCS, on both engines.** DXF stores
CIRCLE / ARC / LWPOLYLINE / TEXT / INSERT geometry in a per-entity frame, and
before v1.5.0 the word `extrusion` did not appear anywhere in this codebase —
so a mirrored or rotated entity reported coordinates that were quietly wrong.
`backends/ocs.py` now translates at the backend boundary; an entity in a plane
tilted out of WCS XY reports `plane_normal` and omits the fields xy cannot
express, rather than answering with a number that looks fine.
The tool surface evolves — ask `system_about` for the live grouped inventory
and `system_capabilities` for per-backend support modes instead of copying
@@ -196,7 +280,9 @@ flowchart LR
```
- `server.py` — FastMCP surface, lifespan, middleware, resources, prompts.
- `backends/base.py` — shared typed contract + capability model.
- `backends/contracts/` — the typed contract, one module per domain.
- `backends/capability.py` — capability model, typed refusals, `@capability`.
- `backends/base.py` — shared data models + the contract composition.
- `backends/com_backend.py` — single-STA-thread COM executor with per-call
timeouts.
- `backends/ezdxf_backend.py` — `asyncio.to_thread`-wrapped DXF engine with
@@ -214,7 +300,14 @@ flowchart LR
| Cross-platform execution | — | ✓ |
| Transactions and rollback | ✓ | ✓ |
| Paper-space layouts + viewports | ✓ | ✓ |
| Viewport model-content rendering | ✓ | — |
| Viewport model-content rendering | ✓ | ✓ (no viewport borders) |
| CHSPACE (`entity_change_space`) | — (no ActiveX member) | ✓ |
| WIPEOUT / REVCLOUD | — (no ActiveX member) | ✓ |
| Boundary tracing (BPOLY) | — (no ActiveX member) | ✓ |
| Selection window / crossing / polygon | ✓ | ✓ |
| Entity area by handle | ActiveX `.Area` | Analytic, bulges included |
| HATCH filled area (islands subtracted) | AutoCAD's own number | Loops walked, `hatch_style` reported |
| REGION / 3DSOLID area | ✓ | — (ACIS is opaque to ezdxf) |
| TABLE and MLEADER semantics | Native | Portable composite |
| 3D solids (opt-in) | ✓ | — (no headless ACIS) |
| Screenshots | AutoCAD window | Matplotlib render |
@@ -270,25 +363,49 @@ explicitly enabled **and** a bearer token is configured.
| Variable | Default | Purpose |
|---|---|---|
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
| `TOOL_PROFILE` | `full` | `lean` (~46 curated tools), `core` (hides escape hatches), `full` |
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to (see below) |
| `TOOL_PROFILE` | `full` | `lean` (~47 curated tools) or `full` |
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM backend) |
| `LOG_LEVEL` | `INFO` | Python logging level |
| `ALLOWED_PATHS` | empty | Comma-separated absolute paths the server may access |
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
| `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables |
| `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes |
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds) |
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds); `0` disables |
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless ezdxf timeout (seconds); `0` disables |
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP; reported as unsafe mode |
| `ALLOW_REMOTE_HTTP` | `false` | Permit a non-loopback HTTP bind |
| `MCP_AUTH_TOKEN` | empty | Bearer token required for remote HTTP |
`CAD_PROGID` only changes which COM application the backend attaches to — every
tool beyond the connection itself is developed and tested against AutoCAD alone,
and AutoCAD-clone compatibility is unverified, so treat a non-default ProgID as
experimental. Candidates such as `BricscadApp.AcadApplication`,
`ZWCAD.Application` and `GstarCAD.Application` are listed in `.env.example`
commented out and marked UNVERIFIED: none has been tested against a seat. An
unrecognised ProgID fails loudly rather than falling back, because the fallback
path COM-launches the application and would start the very AutoCAD you said you
were not using.
## Security model
Tool input is treated as untrusted:
- every path passes traversal + allowed-root validation;
- dangerous AutoCAD commands and AutoLISP channels are blocked by default,
with regression tests for known bypass patterns;
- every path passes traversal + allowed-root validation, and non-local anchors
(`\\?\`, UNC, `\\localhost\C$`) are rejected in every spelling — the
forward-slash forms used to normalise past the pattern check and then compare
unequal to the system-directory list;
- `system_run_command` / `system_run_lisp` are free-text escape hatches into the
live AutoCAD command line. A **verb** denylist (the first token of each macro
segment, decorators stripped) refuses the obviously destructive ones by
default, and quoted AutoLISP string literals are treated as data so drawing
text is not mistaken for code. Treat this as a guardrail against an agent
accidentally issuing `ERASE ALL` — **not** as a boundary against a hostile
client. AutoCAD accepts hundreds of commands and any loaded ARX/LISP adds
more, so the list cannot be complete. The real boundaries are `ALLOWED_PATHS`,
not exposing the server over the network, and preferring the typed tools
(which validate every argument) over raw commands;
- remote HTTP requires explicit opt-in plus a bearer token;
- DXF size, response sizes, undo history and COM call duration are bounded;
- audit middleware records tool timing and failures;
@@ -300,18 +417,35 @@ rather than public issues.
## Development
`uv.lock` pins the whole transitive graph — runtime *and* dev tools — to the
set the suite was last run against, so this reproduces the CI environment
exactly rather than approximately:
```bash
pip install -e ".[full]"
pip install pytest pytest-asyncio pytest-cov ruff build
python -m pytest # 474 tests
python -m ruff check . && python -m ruff format --check .
python -m build
uv sync --locked --all-extras
uv run pytest
uv run ruff check . && uv run ruff format --check .
```
CI runs the same gates on Linux (3.11/3.12), Windows (mocked-COM suite),
plus package, Docker and MCP-registry-schema jobs. Releases are tag-driven:
`git tag vX.Y.Z && git push origin vX.Y.Z` builds, publishes to PyPI and cuts
the GitHub Release. See
Without uv, from the declared dependency ranges (needs pip ≥ 25.1 for
`--group`; resolves fresh, so it can differ from CI):
```bash
pip install -e ".[full]"
pip install --group dev build
python -m pytest && python -m build
```
CI installs with `uv sync --locked`, which fails rather than re-resolving, so a
fresh run cannot silently pick up an untested upstream — run `uv lock` after
changing dependencies or the lint job will reject the drift. The gates run on
Linux (3.11/3.12), Windows (mocked-COM suite), plus package, Docker and
MCP-registry-schema jobs; the package job deliberately stays *unlocked*,
because it is the one that proves a plain `pip install autocad-mcp-pro` still
resolves. A scheduled `deps-floating` workflow resolves the ranges fresh and
re-runs the suite, so a locked CI does not become blindness to upstream drift.
Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z` builds,
publishes to PyPI and cuts the GitHub Release. See
[`docs/RELEASE-DISTRIBUTION.md`](docs/RELEASE-DISTRIBUTION.md).
Repository map:
@@ -321,20 +455,37 @@ server.py FastMCP surface and orchestration
config.py Environment-driven settings
security.py Path, command, and AutoLISP guards
version.py Canonical package version
uv.lock Exact dependency graph CI installs from
backends/ COM and ezdxf implementations
engineering/ Standards, generators, critique, fits, delivery
benchmarks/ Runners, adapters, published reports, chart renderers
tests/ Headless, mocked-COM, contract, and security tests
```
## Roadmap (1.5)
## Roadmap (1.6)
Screenshot overlay + handle grounding, ezdxf redo (forward snapshots), COM
`block_create_from_entities`, ISO 286 transition/interference hole letters
(delta rule), titleblock on paper-space layouts. Features are published when
**Give back the 25% creation throughput** the per-call ezdxf timeout costs
(measured and attributed above) — arm it only for calls that can actually block,
or find a deadline cheaper than an `asyncio.wait_for` per call. ISO 286
interference **shafts** r/s/t/u (they need sub-stepped table data nothing in the
module can derive, and guessing them would put wrong tolerances on a production
drawing — so 1.5 refuses them by name instead). HATCH boundary *geometry* in
`entity_get` — 1.5 measures a hatch's filled area but still does not hand back
its loops. REGION/3DSOLID area on the headless engine, which needs a modelling
kernel ezdxf does not have. An *allowlist* of permitted AutoLISP heads,
replacing the denylist — enumerating dangerous symbols does not terminate.
`ezdxf.recover` as a fallback on `drawing_open`. Features are published when
their contracts and limitations are testable — not when they make a longer
checklist.
Cut from 1.5 on evidence, not on time: Wave A shipped 13 of a planned 39 tools.
The 2D-boolean and REGION family went because `add_region()` produces a REGION
with zero ACIS bytes and the `greiner_hormann` substitute loses **28.2% of the
area** on a square with one semicircular edge; most of the planned measurement
family was already covered by the eight `analysis_*` tools that existed. The
full cut list and its measurements are in
[`CHANGELOG.md`](CHANGELOG.md).
## Contributing
Pull requests are welcome — especially reproducible competitor adapters,
+70 -1748
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
"""Optional contract methods: declared once, refused loudly, gated by a test.
The backend contract is large (112 abstract methods before this file existed,
and the plan adds ~39 more). Every one of them is an `@abstractmethod`, which
means adding a single method to the contract makes **both** backends fail at
instantiation until both are written — and two test modules construct
`ComBackend` at import time, so the whole suite goes red on a half-finished
edit. That is a real cost paid on every contract change.
The obvious fix is to give new methods a default body. The obvious default is
wrong: returning ``{"ok": False, "capability": ...}`` from a method nobody
implemented produces exactly the quiet "this didn't work and nothing told you"
that the v1.5.0 honesty pass spent a milestone deleting. A default that lies
quietly is worse than a build break.
So `@capability` gives a default that **raises**
`UnsupportedCapabilityError` — the same typed refusal the deliberate
boundaries raise, carrying the same `capability` key, surfaced by the same
`CapabilityRefusalMiddleware`, and indistinguishable to a client from a
hand-written refusal. Three properties follow:
* An unimplemented method refuses instead of pretending. It cannot silently
succeed and it cannot silently do nothing.
* The refusal is *declared*: `test_capability.py` fails if a key used here is
missing from either backend's `CapabilityMap`, so `system_capabilities` can
never disagree with what a call actually does. This is the part that keeps
the escape hatch honest — without it, `@capability` would just be a way to
ship undeclared holes.
* Overriding is ordinary subclassing. A backend that implements the method
simply defines it; nothing needs to be un-registered.
`@abstractmethod` remains the default for anything both backends must have.
Reach for `@capability` only when a method is genuinely optional — an engine
boundary (no ACIS kernel headlessly) rather than an unfinished chore.
"""
from __future__ import annotations
import functools
from collections.abc import Callable
from typing import Any
__all__ = [
"UnsupportedCapabilityError",
"capability",
"capability_keys",
"is_capability_default",
]
# ---------------------------------------------------------------------------
# The refusal vocabulary
#
# This lives here rather than in ``backends.base`` because the contracts import
# the decorator and ``base`` imports the contracts — putting the exception in
# ``base`` makes that a cycle. ``backends.base`` re-exports both names, so
# ``from backends.base import UnsupportedCapabilityError`` is unchanged for the
# rest of the repo.
#
# There is exactly one authoring rule: a backend that cannot do a thing RAISES
# ``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
# key of ``capabilities().features`` so a client can pre-check the same boundary
# through ``system_capabilities`` instead of parsing English. Tools never catch
# it and never hand-build refusal payloads — one middleware turns it into the
# wire payload, and ``cad_batch`` turns it into a step row. One class object,
# defined once: two look-alike types would silently break every ``isinstance``
# classifier.
#
# The ``{"ok": False, "error": ...}`` returned-dict shape stays legal only for
# soft failures that are not capability boundaries ("Layout not found", "No
# active transaction").
# ---------------------------------------------------------------------------
def _unsupported(error: str, capability_key: str) -> dict:
"""Build a backend's machine-readable "this backend can't" payload."""
return {"ok": False, "error": error, "capability": capability_key}
class UnsupportedCapabilityError(RuntimeError):
"""Raised when a call needs a capability this backend does not have.
Stays a plain ``RuntimeError``: rebasing it on ``FastMCPError`` was measured
and changes nothing a client can observe, because the refusal is carried by
``ToolResult(structured_content=..., is_error=True)`` rather than by the
exception class. ``to_dict()`` keeps the ``{ok, error, capability}`` payload
attached to the raise so the boundary survives the ``__cause__`` chain.
"""
def __init__(self, capability: str, error: str):
super().__init__(error)
self.capability = capability
def to_dict(self) -> dict:
return _unsupported(str(self), self.capability)
#: ``{capability_key: (method_name, ...)}`` for every method declared optional.
#: Populated at import by the decorator and read by the parity gate.
_REGISTRY: dict[str, set[str]] = {}
def capability(key: str, *, reason: str = "") -> Callable[[Callable], Callable]:
"""Mark a contract method optional, defaulting to a typed refusal.
Args:
key: capability name; must appear in **both** backends' capability maps.
reason: appended to the refusal message. Say what the caller can do
instead, not merely that they cannot do this.
"""
def decorate(func: Callable) -> Callable:
@functools.wraps(func)
async def default(self, *args: Any, **kwargs: Any):
message = f"{func.__name__}: not supported by the {self.name} backend"
raise UnsupportedCapabilityError(key, f"{message}. {reason}".rstrip())
default.__capability__ = key
default.__is_capability_default__ = True
_REGISTRY.setdefault(key, set()).add(func.__name__)
return default
return decorate
def capability_keys() -> dict[str, set[str]]:
"""Every optional-capability key and the methods declared under it."""
return {key: set(names) for key, names in _REGISTRY.items()}
def is_capability_default(owner: type, method_name: str) -> bool:
"""True when ``owner`` inherited the refusing default rather than implementing it."""
attribute = getattr(owner, method_name, None)
return bool(getattr(attribute, "__is_capability_default__", False))
+1180 -22
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
"""Per-domain slices of the backend contract.
One 1854-line ABC with 112 abstract methods made every contract change a
whole-file edit, and made a half-finished change break both backends at
instantiation at once. The seams here are the section markers that were
already in the file, so the split moved code without re-deciding anything.
"""
from __future__ import annotations
from backends.contracts.analysis import AnalysisContract
from backends.contracts.blocks import BlockContract
from backends.contracts.corner_ops import CornerOpsContract
from backends.contracts.dimensions import DimensionContract
from backends.contracts.drawing import DrawingContract
from backends.contracts.entity_creation import EntityCreationContract
from backends.contracts.entity_modification import EntityModificationContract
from backends.contracts.entity_query import EntityQueryContract
from backends.contracts.gdt import GdtContract
from backends.contracts.identity import IdentityContract
from backends.contracts.layers import LayerContract
from backends.contracts.layouts import LayoutContract
from backends.contracts.linetypes import LinetypeContract
from backends.contracts.premium import PremiumContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
from backends.contracts.system import SystemContract
from backends.contracts.transactions import TransactionContract
from backends.contracts.view import ViewContract
__all__ = [
"AnalysisContract",
"BlockContract",
"CornerOpsContract",
"DimensionContract",
"DrawingContract",
"EntityCreationContract",
"EntityModificationContract",
"EntityQueryContract",
"GdtContract",
"IdentityContract",
"LayerContract",
"LayoutContract",
"LinetypeContract",
"PremiumContract",
"SettingsContract",
"SolidContract",
"SystemContract",
"TransactionContract",
"ViewContract",
]
+164
View File
@@ -0,0 +1,164 @@
"""Measurement and analysis.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from backends.capability import capability
if TYPE_CHECKING:
from backends.base import EntityInfo
class AnalysisContract(ABC):
# ── boundary tracing (M8 / F3) ───────────────────────────────────────────
@capability( # noqa: B027 — @capability supplies the body
"boundary_trace",
reason=(
"ActiveX exposes no BOUNDARY member; the live path would have to drive the "
"-BOUNDARY command blind, which has not been verified against a live AutoCAD. "
"Use the headless backend (AUTOCAD_MCP_BACKEND=ezdxf), or run BOUNDARY in "
"AutoCAD directly."
),
)
async def boundary_trace(
self, x: float, y: float, layer: str | None = None, tolerance: float = 1e-9
) -> dict:
"""AutoCAD's BOUNDARY/BPOLY: trace the closed loop enclosing a seed point.
Returns ``{ok, handle, vertices, area, closed, source_handles}``.
``vertices`` is a count, ``area`` is unsigned.
Implementations must not enumerate every loop in the drawing:
``edgeminer.find_all_loops`` is O(n!) and was measured at 63.9 s for 40
edges and a timeout at 60. The seed point picks a starting edge and one
loop is walked from it.
A seed with no enclosing loop is refused. Returning an empty boundary
would be a boundary that bounds nothing, reported as success.
"""
...
@capability( # noqa: B027 — @capability supplies the body
"boundary_trace",
reason=(
"Chaining arbitrary entities into a loop needs the same edge graph BOUNDARY "
"does, which ActiveX does not expose. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf)."
),
)
async def boundary_from_entities(self, handles: list[str], tolerance: float = 1e-9) -> dict:
"""Chain the given entities into one closed polyline.
Returns ``{ok, handle, vertices, area, closed}``. Refuses a chain that
does not close, naming the coordinates of the endpoint that dangles —
"these do not form a loop" is not actionable without the gap.
"""
...
@abstractmethod
async def analysis_list_properties(self, handle: str) -> dict:
"""AutoCAD's LIST: the full DXF attribute set for one handle.
Returns ``{ok, handle, type, layer, properties, dxf_attributes}``.
``properties`` mirrors what ``entity_get`` reports; ``dxf_attributes``
is the raw attribute set that ``entity_get`` deliberately does not
carry.
Two things the raw dump cannot be handed over as-is. It holds ``Vec3``
values, so it is not JSON-serialisable and would die at the wire
boundary; and its coordinates are stored in the entity's own frame, so
for anything with an extrusion they would contradict every other
coordinate this server reports. Both are converted here, and
``extrusion`` stays in the dump so the original frame is still visible.
"""
...
@abstractmethod
async def analysis_stats(self) -> dict: ...
@abstractmethod
async def analysis_entities_in_region(
self,
x1: float,
y1: float,
x2: float,
y2: float,
) -> list[EntityInfo]: ...
@abstractmethod
async def analysis_measure_distance(
self,
x1: float,
y1: float,
x2: float,
y2: float,
) -> float: ...
@abstractmethod
async def analysis_measure_area(self, points: list[list[float]]) -> float: ...
@abstractmethod
async def entity_measure(self, handle: str, flatten_tolerance: float = 0.001) -> dict:
"""Area and perimeter of ONE existing entity, addressed by handle.
Returns ``{"handle", "type", "area", "perimeter", "closed",
"assumed_closed", "method", "exact", "flatten_tolerance", "backend",
"self_intersecting", "perimeter_exact", "loop_count"}``.
A HATCH is measured as the area it *fills* — outer loops minus the
islands inside them — and the headless engine adds ``hatch_style``
(``normal``/``outer``/``ignore``) so the number can be read against the
drawing's own island setting. The live backend does not carry that key:
it asks AutoCAD for ``.Area``, which has already applied the style, so
there is no house rule left to disclose.
``exact`` and
``flatten_tolerance`` are the accuracy disclosure: when one engine can
answer in closed form and the other has to flatten, the payload says so
rather than leaving two numbers to quietly disagree.
Two different refusals, and the difference matters. A type that bounds no
area at all (LINE, TEXT, a 3D polyline) raises a plain ``RuntimeError``:
no engine can measure it, so tagging a capability would falsely suggest
switching backends helps. A type this *engine* cannot evaluate (ACIS
solids headlessly) raises ``UnsupportedCapabilityError``, which names the
engine that can.
"""
...
@abstractmethod
async def analysis_bounding_box(self) -> dict: ...
@abstractmethod
async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ...
@abstractmethod
async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ...
@abstractmethod
async def selection_get(self) -> dict:
"""Read the live viewport's implied ("pickfirst") selection set.
COM-only / meaningful only with live AutoCAD. Returns a dict::
{
"ok": bool, # True on COM even for an empty pick
"count": int,
"handles": list[str], # entity handles to act on
"entities": list[EntityInfo], # _dc()-converted by the server
"pickfirst": bool | None, # state of the PICKFIRST sysvar
"message" / "error": str, # optional guidance / failure reason
}
The ezdxf headless backend has no viewport, so it returns ``ok=False``
with an empty ``handles`` list (same shape, never raises).
"""
...
+50
View File
@@ -0,0 +1,50 @@
"""Blocks and block references.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import BlockInfo, EntityInfo
class BlockContract(ABC):
@abstractmethod
async def block_list(self) -> list[BlockInfo]: ...
@abstractmethod
async def block_insert(
self,
name: str,
x: float,
y: float,
scale_x: float = 1.0,
scale_y: float = 1.0,
rotation: float = 0.0,
attributes: dict | None = None,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def block_explode(self, handle: str) -> dict: ...
@abstractmethod
async def block_get_attributes(self, handle: str) -> dict: ...
@abstractmethod
async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ...
@abstractmethod
async def block_create_from_entities(
self,
name: str,
handles: list[str],
base_x: float = 0.0,
base_y: float = 0.0,
) -> dict: ...
+66
View File
@@ -0,0 +1,66 @@
"""Trim, extend, fillet, chamfer.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class CornerOpsContract(ABC):
@abstractmethod
async def entity_trim(
self,
target_handle: str,
cutter_handle: str,
keep_x: float,
keep_y: float,
) -> EntityInfo:
"""Trim `target` against `cutter`; keep the segment containing
(keep_x, keep_y). Raises ToolError if no intersection."""
...
@abstractmethod
async def entity_extend(
self,
target_handle: str,
boundary_handle: str,
end_x: float | None = None,
end_y: float | None = None,
) -> EntityInfo:
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
target endpoint nearest the boundary is auto-selected."""
...
@abstractmethod
async def entity_fillet(
self,
handle1: str,
handle2: str,
radius: float,
trim: bool = True,
) -> EntityInfo:
"""Fillet two entities with the given radius. Returns the new ARC.
When `trim` is True (default), the source entities are shortened
to the tangent points (AutoCAD default behaviour)."""
...
@abstractmethod
async def entity_chamfer(
self,
handle1: str,
handle2: str,
dist1: float,
dist2: float | None = None,
trim: bool = True,
) -> EntityInfo:
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
(symmetric chamfer). Returns the new chamfer LINE."""
...
+89
View File
@@ -0,0 +1,89 @@
"""ISO 129 dimensions.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class DimensionContract(ABC):
@abstractmethod
async def dimension_linear(
self,
x1: float,
y1: float,
x2: float,
y2: float,
dim_x: float,
dim_y: float,
rotation: float = 0.0,
layer: str | None = None,
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_aligned(
self,
x1: float,
y1: float,
x2: float,
y2: float,
dim_x: float,
dim_y: float,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_angular(
self,
vx: float,
vy: float,
x1: float,
y1: float,
x2: float,
y2: float,
tx: float,
ty: float,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_radius(
self,
cx: float,
cy: float,
chord_x: float,
chord_y: float,
leader_length: float = 10.0,
layer: str | None = None,
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_diameter(
self,
x1: float,
y1: float,
x2: float,
y2: float,
leader_length: float = 10.0,
layer: str | None = None,
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> EntityInfo: ...
+40
View File
@@ -0,0 +1,40 @@
"""Document lifecycle.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import DrawingInfo
class DrawingContract(ABC):
@abstractmethod
async def drawing_info(self) -> DrawingInfo: ...
@abstractmethod
async def drawing_new(self, template: str | None = None) -> dict: ...
@abstractmethod
async def drawing_open(self, path: str) -> dict: ...
@abstractmethod
async def drawing_save(self, path: str | None = None) -> dict: ...
@abstractmethod
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ...
@abstractmethod
async def drawing_export_dxf(self, path: str) -> dict: ...
@abstractmethod
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ...
@abstractmethod
async def drawing_purge(self) -> dict: ...
+255
View File
@@ -0,0 +1,255 @@
"""Creating geometry.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from backends.capability import capability
if TYPE_CHECKING:
from backends.base import EntityInfo
class EntityCreationContract(ABC):
@abstractmethod
async def entity_create_line(
self,
x1: float,
y1: float,
x2: float,
y2: float,
z1: float = 0.0,
z2: float = 0.0,
layer: str | None = None,
color: int | None = None,
linetype: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_circle(
self,
cx: float,
cy: float,
radius: float,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_arc(
self,
cx: float,
cy: float,
radius: float,
start_angle: float,
end_angle: float,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_polyline(
self,
points: list[list[float]],
closed: bool = False,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_text(
self,
text: str,
x: float,
y: float,
height: float = 2.5,
rotation: float = 0.0,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_mtext(
self,
text: str,
x: float,
y: float,
width: float = 100.0,
height: float = 2.5,
rotation: float = 0.0,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_table(
self,
x: float,
y: float,
rows: list[list[str]],
headers: list[str] | None = None,
column_widths: list[float] | None = None,
row_height: float = 7.0,
text_height: float = 2.5,
title: str | None = None,
layer: str = "TEXT",
) -> EntityInfo: ...
@abstractmethod
async def leader_create_mleader(
self,
points: list[list[float]],
text: str,
text_height: float = 2.5,
landing_gap: float = 1.0,
arrow_size: float = 2.5,
layer: str = "DIM",
) -> EntityInfo: ...
@abstractmethod
async def entity_create_hatch(
self,
pattern: str,
boundary_points: list[list[float]],
scale: float = 1.0,
angle: float = 0.0,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_spline(
self,
fit_points: list[list[float]],
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_ellipse(
self,
cx: float,
cy: float,
major_x: float,
major_y: float,
ratio: float = 0.5,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_point(
self,
x: float,
y: float,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_block_ref(
self,
name: str,
x: float,
y: float,
scale_x: float = 1.0,
scale_y: float = 1.0,
rotation: float = 0.0,
layer: str | None = None,
) -> EntityInfo: ...
# ── hatch depth (M8 / F4) ────────────────────────────────────────────────
@abstractmethod
async def hatch_set_gradient(
self,
handle: str,
color1: list[int],
color2: list[int],
rotation: float = 0.0,
centered: float = 0.0,
one_color: bool = False,
tint: float = 0.0,
name: str = "LINEAR",
) -> dict:
"""Replace a hatch's fill with a two-colour gradient. ``{ok, handle, gradient}``."""
...
@abstractmethod
async def hatch_edit(
self,
handle: str,
pattern: str = "",
scale: float | None = None,
angle: float | None = None,
color: int | None = None,
style: str = "",
) -> dict:
"""Edit an existing hatch in place. Returns ``{ok, handle, changed}``.
``changed`` names exactly the attributes that moved: an omitted
parameter must leave its attribute alone, because a partial edit that
quietly resets the rest is data loss. ``style`` is the island style —
``normal``, ``outer`` or ``ignore``.
"""
...
@abstractmethod
async def hatch_add_boundary(self, handle: str, edges: list[dict]) -> dict:
"""Add one boundary path built from typed edges.
Returns ``{ok, handle, path_count, edge_types}``. Edges are
``{"type": "line"|"arc"|"ellipse", ...}``. Typed edges exist because a
boundary API that only accepts vertex lists silently straightens every
curve it is given.
"""
...
# ── annotation objects (M8 / F15) ────────────────────────────────────────
@capability( # noqa: B027 — @capability supplies the body
"wipeout",
reason=(
"ActiveX has no wipeout constructor — ModelSpace.AddWipeout does not exist "
"(verified against AutoCAD 2026); WIPEOUT is a command only. Use the headless "
"backend (AUTOCAD_MCP_BACKEND=ezdxf), or run WIPEOUT in AutoCAD directly."
),
)
async def entity_create_wipeout(
self, points: list[list[float]], layer: str | None = None
) -> dict:
"""Mask the drawing behind a closed polygon. Returns ``{ok, handle, points}``.
Refuses fewer than three points: a zero-area mask masks nothing while
reporting success.
"""
...
@capability( # noqa: B027 — @capability supplies the body
"revcloud",
reason=(
"ActiveX exposes no revision-cloud member and driving the REVCLOUD command "
"blind against a live drawing is unverified. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf), or run REVCLOUD in AutoCAD directly."
),
)
async def entity_create_revcloud(
self,
points: list[list[float]],
segment_length: float,
layer: str | None = None,
closed: bool = True,
) -> dict:
"""Draw a revision cloud along a path. Returns ``{ok, handle, segments}``.
A revision cloud *is* a polyline whose every segment carries an arc
bulge; a segment length longer than the path yields no arcs at all, so
that case is refused rather than returned as a rectangle.
"""
...
+125
View File
@@ -0,0 +1,125 @@
"""Moving, copying and editing geometry.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class EntityModificationContract(ABC):
@abstractmethod
async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ...
@abstractmethod
async def entity_copy(
self, handle: str, dx: float, dy: float, dz: float = 0.0
) -> EntityInfo: ...
@abstractmethod
async def entity_rotate(
self,
handle: str,
base_x: float,
base_y: float,
angle_deg: float,
) -> dict: ...
@abstractmethod
async def entity_scale(
self,
handle: str,
base_x: float,
base_y: float,
factor: float,
) -> dict: ...
@abstractmethod
async def entity_mirror(
self,
handle: str,
x1: float,
y1: float,
x2: float,
y2: float,
delete_original: bool = False,
) -> EntityInfo: ...
@abstractmethod
async def entity_offset(
self,
handle: str,
distance: float,
side_x: float | None = None,
side_y: float | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_delete(self, handle: str) -> dict: ...
@abstractmethod
async def entity_array_rectangular(
self,
handle: str,
rows: int,
cols: int,
row_spacing: float,
col_spacing: float,
) -> list[EntityInfo]: ...
@abstractmethod
async def entity_array_polar(
self,
handle: str,
count: int,
fill_angle: float,
center_x: float,
center_y: float,
) -> list[EntityInfo]: ...
# ── text annotation (M8 / F15) ───────────────────────────────────────────
@abstractmethod
async def text_set_background(
self,
handle: str,
enabled: bool = True,
color: int | None = None,
scale: float = 1.5,
) -> dict:
"""Put an opaque background box behind an MTEXT. ``{ok, handle, enabled}``.
MTEXT only — TEXT has no background-fill attribute, so setting one on
it would report success and change nothing. ``scale`` is a multiple of
the text box and must be >= 1: a box smaller than its text is a stripe
through the text, not a mask.
"""
...
@abstractmethod
async def text_find_replace(
self,
find: str,
replace: str,
layer: str | None = None,
match_case: bool = True,
dry_run: bool = False,
) -> dict:
"""Replace text across the drawing.
Returns ``{ok, replaced, entities, searched_types, note, dry_run}``.
``searched_types`` is reported rather than assumed: "no matches" and
"that type was never searched" are different answers, and an editor
that silently skips block attributes is the classic version of this
bug. DIMENSION text is deliberately out of scope — its ``text`` field
holds the ``<>`` override placeholder rather than the measurement, so
editing it would either do nothing or break the association.
"""
...
+158
View File
@@ -0,0 +1,158 @@
"""Reading entities back.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class EntityQueryContract(ABC):
# ── selection filters (M8 / F1) ──────────────────────────────────────────
@abstractmethod
async def selection_window(
self,
x1: float,
y1: float,
x2: float,
y2: float,
mode: str = "window",
entity_type: str = "",
layer: str = "",
) -> dict:
"""AutoCAD's ssget window/crossing. ``{ok, handles, count, mode}``.
``window`` returns only entities wholly inside the box; ``crossing``
also returns the ones that straddle its edge. Getting those backwards
is silent and wrong, so ``mode`` is reported back and defaults to the
narrower one. A zero-area box is refused rather than answered with an
empty list, which would read as "nothing there".
Selection is by *drawn* position: an entity in a mirrored frame is
found where ``entity_get`` reports it, not where the file stores it.
"""
...
@abstractmethod
async def selection_polygon(
self,
points: list[list[float]],
mode: str = "window",
entity_type: str = "",
layer: str = "",
) -> dict:
"""Window/crossing selection against an arbitrary polygon.
A polygon is not its bounding box — the difference is the whole point
of the tool, so the implementation must test against the shape itself.
"""
...
@abstractmethod
async def selection_filter(
self,
entity_type: str = "",
layer: str = "",
color: int | None = None,
linetype: str = "",
min_area: float | None = None,
) -> dict:
"""Property filter. Returns ``{ok, handles, count, filtered_by}``.
Named parameters rather than a query string, deliberately: ezdxf's
query language answers an unknown attribute with an empty result, so a
typo is indistinguishable from "no matches". Here a typo is a
``TypeError`` at the boundary, and ``filtered_by`` tells the caller
which filters actually ran — "I filtered by layer and found none" and
"I never filtered by layer" are different answers.
"""
...
@abstractmethod
async def entity_get(self, handle: str) -> EntityInfo: ...
@abstractmethod
async def entity_set_properties(
self,
handle: str,
layer: str | None = None,
color: int | None = None,
linetype: str | None = None,
lineweight: float | None = None,
visible: bool | None = None,
) -> dict: ...
@abstractmethod
async def entity_edit_text(
self,
handle: str,
text: str | None = None,
height: float | None = None,
rotation: float | None = None,
) -> EntityInfo:
"""Edit an existing TEXT or MTEXT entity in place. Any argument left
None is unchanged. Returns the updated EntityInfo. Raises if the handle
is not a TEXT/MTEXT entity."""
...
@abstractmethod
async def entity_edit_geometry(
self,
handle: str,
cx: float | None = None,
cy: float | None = None,
radius: float | None = None,
x1: float | None = None,
y1: float | None = None,
x2: float | None = None,
y2: float | None = None,
start_angle: float | None = None,
end_angle: float | None = None,
) -> EntityInfo:
"""Edit the defining geometry of an existing entity in place.
- CIRCLE: cx / cy / radius
- LINE: x1 / y1 (start), x2 / y2 (end)
- ARC: cx / cy / radius / start_angle / end_angle (degrees)
Any argument left None is unchanged. Raises for unsupported types."""
...
@abstractmethod
async def entity_list(
self,
type_filter: str | None = None,
layer_filter: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[EntityInfo]: ...
@abstractmethod
async def entity_count(
self,
type_filter: str | None = None,
layer_filter: str | None = None,
) -> int:
"""How many entities ``entity_list`` would match, ignoring limit/offset.
Exists so a *paged* listing can report an honest total without paying
for the rows it is not going to return. ``entity_list`` stops building
``EntityInfo`` objects the moment it has ``limit`` of them, so it can
never tell a caller whether anything followed the page — and a
truncated list read as complete is a correctness bug, not a token one.
Two obligations, both load-bearing:
* apply **exactly** the filter semantics of ``entity_list`` (same
case-insensitive type/layer comparison), or ``truncated`` would be
computed against a different set than the one that was paged;
* do not build ``EntityInfo`` rows. A count implemented by listing
would cost more than the listing it is meant to make cheap.
"""
...
+157
View File
@@ -0,0 +1,157 @@
"""Geometric tolerancing.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
class GdtContract:
# Composed from LINE + TEXT primitives so the exact same frame renders on
# both COM and ezdxf (ezdxf's native TOLERANCE entity renders blank through
# the matplotlib frontend). Deterministic layout math lives in engineering/
# gdt.py; datum letters are tracked so the `gdt` critique focus can verify
# every referenced datum is actually established on the part.
async def _place_boxed_text(
self,
text: str,
cx: float,
cy: float,
text_h: float,
layer: str,
) -> str:
"""Create a TEXT entity roughly centred on (cx, cy). Returns its handle.
entity_create_text has no alignment arg, so approximate the centring with
the same width heuristic the title block uses."""
approx_w = len(text) * text_h * 0.7
t = await self.entity_create_text(
text,
cx - approx_w / 2.0,
cy - text_h / 2.0,
height=text_h,
layer=layer,
)
return t.handle
async def draw_feature_control_frame(
self,
symbol: str,
tolerance: str | float,
x: float,
y: float,
datums: list[str] | None = None,
height: float = 5.0,
diameter: bool = False,
modifier: str | None = None,
layer: str | None = None,
) -> dict:
"""Draw an ISO 1101 feature control frame with bottom-left corner at (x, y).
symbol: one of the 14 geometric characteristics (straightness, flatness,
circularity, cylindricity, profile_line, profile_surface, angularity,
perpendicularity, parallelism, position, concentricity, symmetry,
circular_runout, total_runout). `datums` is the ordered datum reference
list (e.g. ["A", "B"]); `diameter=True` prefixes ⌀ for a cylindrical zone;
`modifier` ∈ {M, L, S} appends the material-condition symbol.
Orientation/location/runout characteristics require at least one datum
(raises otherwise). The referenced datums are recorded for the `gdt`
critique focus.
"""
from engineering.gdt import fcf_compartments, fcf_layout
comps = fcf_compartments(
symbol,
tolerance,
datums,
diameter=diameter,
modifier=modifier,
)
layer = layer or self._role_layer("dim")
await self._ensure_layer(layer, color=2)
lay = fcf_layout(comps, float(x), float(y), float(height))
handles: list[str] = []
box = lay["box"]
box_poly = await self.entity_create_polyline(
[[bx, by] for bx, by in box],
closed=False,
layer=layer,
)
handles.append(box_poly.handle)
for (sx, sy), (ex, ey) in lay["dividers"]:
ln = await self.entity_create_line(sx, sy, ex, ey, layer=layer)
handles.append(ln.handle)
th = lay["text_height"]
for text, tcx, tcy in lay["labels"]:
handles.append(await self._place_boxed_text(text, tcx, tcy, th, layer))
refs = getattr(self, "_gdt_datums_referenced", None)
if refs is None:
refs = set()
self._gdt_datums_referenced = refs
for d in datums or []:
d = str(d).strip().upper()
if d:
refs.add(d)
return {
"ok": True,
"symbol": symbol,
"compartments": comps,
"handles": handles,
"width": lay["width"],
"height": lay["height"],
"layer": layer,
}
async def draw_datum_feature(
self,
letter: str,
x: float,
y: float,
size: float = 5.0,
layer: str | None = None,
) -> dict:
"""Place an ISO 1101 datum feature symbol (filled triangle + boxed letter)
with its apex at (x, y). Records the datum letter so a feature control
frame that references it passes the `gdt` critique focus."""
from engineering.gdt import datum_triangle, fcf_layout
letter = str(letter).strip().upper()
if not letter:
raise RuntimeError("draw_datum_feature: a datum letter is required.")
layer = layer or self._role_layer("dim")
await self._ensure_layer(layer, color=2)
size = float(size)
handles: list[str] = []
tri = datum_triangle(float(x), float(y), size, down=True)
tri_poly = await self.entity_create_polyline(
[[px, py] for px, py in tri],
closed=True,
layer=layer,
)
handles.append(tri_poly.handle)
box_h = size * 1.4
lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h)
box_poly = await self.entity_create_polyline(
[[bx, by] for bx, by in lay["box"]],
closed=False,
layer=layer,
)
handles.append(box_poly.handle)
for text, tcx, tcy in lay["labels"]:
handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer))
defined = getattr(self, "_gdt_datums_defined", None)
if defined is None:
defined = set()
self._gdt_datums_defined = defined
defined.add(letter)
return {"ok": True, "datum": letter, "handles": handles, "layer": layer}
+33
View File
@@ -0,0 +1,33 @@
"""Connecting and describing the backend.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import CapabilityMap
class IdentityContract(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@property
@abstractmethod
def is_connected(self) -> bool: ...
@abstractmethod
async def connect(self) -> None: ...
@abstractmethod
async def disconnect(self) -> None: ...
@abstractmethod
def capabilities(self) -> CapabilityMap: ...
+61
View File
@@ -0,0 +1,61 @@
"""Layers.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import LayerInfo
class LayerContract(ABC):
@abstractmethod
async def layer_list(self) -> list[LayerInfo]: ...
@abstractmethod
async def layer_create(
self,
name: str,
color: int = 7,
linetype: str = "Continuous",
lineweight: float = -3,
) -> LayerInfo: ...
@abstractmethod
async def layer_delete(self, name: str) -> dict: ...
@abstractmethod
async def layer_set_current(self, name: str) -> dict: ...
@abstractmethod
async def layer_modify(
self,
name: str,
color: int | None = None,
linetype: str | None = None,
lineweight: float | None = None,
) -> LayerInfo: ...
@abstractmethod
async def layer_freeze(self, name: str) -> dict: ...
@abstractmethod
async def layer_thaw(self, name: str) -> dict: ...
@abstractmethod
async def layer_lock(self, name: str) -> dict: ...
@abstractmethod
async def layer_unlock(self, name: str) -> dict: ...
@abstractmethod
async def layer_hide(self, name: str) -> dict: ...
@abstractmethod
async def layer_show(self, name: str) -> dict: ...
+170
View File
@@ -0,0 +1,170 @@
"""Layouts, viewports and change-space.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from backends.capability import capability
class LayoutContract(ABC):
@abstractmethod
async def layout_list(self) -> dict:
"""Return {ok, layouts: [names], current: name} incl. 'Model'."""
...
@abstractmethod
async def layout_create(self, name: str) -> dict:
"""Create a new paper-space layout."""
...
@abstractmethod
async def layout_set_current(self, name: str) -> dict:
"""Activate a layout tab ('Model' or a paper-space layout name)."""
...
@abstractmethod
async def layout_delete(self, name: str) -> dict:
"""Delete a paper-space layout and everything drawn on it.
Returns ``{ok, deleted, entities_destroyed, current}``. ``current`` is
the space geometry goes into afterwards, which the implementation must
keep consistent with ``layout_list()`` — deleting the active tab
otherwise leaves the reported current layout, the DXF tile mode and the
space entities actually land in disagreeing with each other.
Refuses ``'Model'``, a blank name (which ezdxf resolves to the *first*
paper-space layout), a name that does not exist, and the last remaining
paper-space layout.
"""
...
@abstractmethod
async def layout_rename(self, old_name: str, new_name: str) -> dict:
"""Rename a paper-space layout, keeping its entities and their handles.
Returns ``{ok, old, new, current}``. Refuses ``'Model'`` on either side,
a blank or otherwise invalid new name, and a name already in use.
"""
...
@abstractmethod
async def layout_copy(self, source: str, new_name: str) -> dict:
"""Copy a paper-space layout, its page setup and its geometry.
Returns ``{ok, source, layout, entities_copied, skipped,
associativity_remapped, associativity_dropped}``. ``skipped`` names the
DXF types that could not be cloned — a copy missing geometry must never
report a bare success. Associative boundaries (hatches) are re-pointed
at the cloned entities where possible and cleared otherwise, because a
boundary still referencing the source layout dangles as soon as that
layout is deleted and no auditor reports it.
"""
...
@abstractmethod
async def viewport_create(
self,
layout: str,
center_x: float,
center_y: float,
width: float,
height: float,
view_center_x: float,
view_center_y: float,
scale: float = 1.0,
) -> dict:
"""Place a scaled model-space viewport on a paper-space layout.
``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows
the model window centered at (view_center_x, view_center_y) with
view height = height / scale.
"""
...
@abstractmethod
async def viewport_list(self, layout: str | None = None) -> dict:
"""List the viewports on one paper-space layout, or on all of them.
Returns ``{ok, viewports: [...], count}``. Each row carries ``handle``,
``layout``, ``center``, ``width``, ``height``, ``view_center``,
``view_height``, ``scale``, ``locked``, ``status``, ``id`` and
``is_main``.
The layout's own main viewport is reported with ``is_main: True`` rather
than filtered out — it is what remains after deleting every drafting
viewport, and a caller who cannot see it cannot explain that. ``scale``
and ``locked`` are ``None`` on documents that cannot store them (R12),
never a fabricated number. Viewports are addressed by handle; ``id`` is
metadata only.
"""
...
@abstractmethod
async def viewport_set_scale(self, handle: str, scale: float) -> dict:
"""Set a viewport's paper:model scale. Returns ``{ok, handle, scale,
view_height}`` with ``scale`` read back from the entity.
Refuses the main viewport (whose view height is the tab's own pan/zoom
state, not a drafting scale) and documents that cannot persist it.
Geometric scale only: annotative text and dimensions do not resize.
"""
...
@abstractmethod
async def viewport_lock(self, handle: str, locked: bool = True) -> dict:
"""Lock or unlock a viewport's display scale. Returns ``{ok, handle,
locked}`` with ``locked`` read back from the entity."""
...
# B027 (empty method, no @abstractmethod) is exactly what this pattern
# looks like from the outside: the decorator replaces the `...` body with
# the refusing default, so the source really is empty on purpose.
@capability( # noqa: B027
"chspace",
reason=(
"ActiveX exposes no change-space member, so a live backend would have to recreate "
"geometry through CopyObjects or drive the CHSPACE command blind — neither has been "
"verified against a live AutoCAD. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf), or run CHSPACE in AutoCAD directly."
),
)
async def entity_change_space(
self,
handles: list[str],
viewport_handle: str,
direction: str = "to_paper",
freeze_dimensions: bool = False,
) -> dict:
"""Move entities between model space and a sheet, *through* a viewport.
Returns ``{ok, moved, refused, scale, viewport}``. Each ``moved`` row
carries ``handle``, ``from``, ``to``, ``inside_viewport`` and
``on_sheet``; each ``refused`` row carries ``handle`` and ``reason``.
The geometric transform is the whole point: a move without it leaves a
100 mm feature as 100 mm of paper inside a 1:2 viewport, produces a file
that audits clean, and is indistinguishable from success. Backends that
cannot apply the viewport transform must refuse rather than move.
Refuses per entity for dimensions (unless ``freeze_dimensions`` bakes
the pre-transform measurement into the text first), ACIS bodies, tables
and proxies, viewports, and entities already in the target space; and
refuses the whole call for a twisted or non-top-view viewport.
"""
...
@abstractmethod
async def viewport_delete(self, handle: str, force: bool = False) -> dict:
"""Delete a viewport. Returns ``{ok, handle, layout, was_main}``.
The layout's main viewport needs ``force=True`` and the implementation
must repair the layout's current-viewport pointer afterwards; a dangling
pointer is invisible to every headless audit and shows up only in CAD.
"""
...
+22
View File
@@ -0,0 +1,22 @@
"""Linetypes.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class LinetypeContract(ABC):
@abstractmethod
async def linetype_list(self) -> list[str]: ...
@abstractmethod
async def linetype_load(
self,
name: str,
file: str | None = None,
) -> dict: ...
+763
View File
@@ -0,0 +1,763 @@
"""Planning, critique, snapping and construction geometry.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from backends.base import EntityInfo
from engineering.plan_spec import (
CritiqueFocus,
DimStyle,
Issue,
LayerSetId,
PlanSpec,
SheetSize,
SnapType,
)
class PremiumContract(ABC):
# These are CONCRETE and backend-agnostic: they orchestrate the abstract
# primitives above plus the shared EntityInfo.properties contract (start/
# end/center/radius/start_angle/end_angle — populated identically by both
# backends), so the ezdxf and COM backends inherit identical premium
# behaviour. The only backend-specific primitive is `_create_xline` — XLINE
# has no cross-backend equivalent among the standard entity_create_* tools.
_plan_spec: PlanSpec | None = None
_preflight_result: Any = None
def _reset_document_state(self) -> None:
"""Clear metadata that is valid only for the current drawing."""
self._plan_spec = None
self._preflight_result = None
self._gdt_datums_defined = set()
self._gdt_datums_referenced = set()
async def _ensure_document_state(self) -> None:
"""Allow live backends to detect an out-of-band document switch."""
return None
async def drawing_preflight(
self,
intent: str,
requirements: dict | None = None,
sheet_size: SheetSize = "A3",
scale: float = 1.0,
layer_set_id: LayerSetId = "mech",
view_count: int = 1,
dim_style: DimStyle = "chain",
allow_assumptions: bool = False,
):
from engineering.preflight import preflight_drawing
await self._ensure_document_state()
result = preflight_drawing(
intent,
requirements,
sheet_size,
scale,
layer_set_id,
view_count,
dim_style,
allow_assumptions=allow_assumptions,
)
self._preflight_result = result
return result
async def drawing_plan(
self,
intent: str,
sheet_size: SheetSize = "A3",
scale: float = 1.0,
layer_set_id: LayerSetId = "mech",
view_count: int = 1,
dim_style: DimStyle = "chain",
notes: list[str] | None = None,
requirements: dict | None = None,
spec_hash: str | None = None,
) -> PlanSpec:
"""Commit a drawing intent before any geometry is created.
Returns a PlanSpec and stores it on the backend; read it back with
`get_plan_spec()`. (N8: the previous claim that `drawing_critique`
replays this plan was untrue — critique runs geometric checks, not a
plan diff.)"""
from engineering.plan_spec import PlanSpec
await self._ensure_document_state()
preflight_status = "skipped"
normalized_plan: dict[str, Any] | None = None
if spec_hash is not None:
from engineering.preflight import preflight_drawing
preflight = getattr(self, "_preflight_result", None)
if preflight is None or preflight.spec_hash != spec_hash:
raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.")
if not preflight.ready:
raise RuntimeError(
"drawing_plan: preflight is not ready; resolve questions/conflicts first."
)
candidate = preflight_drawing(
intent,
requirements
if requirements is not None
else preflight.normalized_spec["requirements"],
sheet_size,
scale,
layer_set_id,
view_count,
dim_style,
)
if candidate.spec_hash != spec_hash:
raise RuntimeError(
"drawing_plan: supplied fields do not match the hashed preflight spec."
)
normalized_plan = preflight.normalized_spec
preflight_status = "ready"
if normalized_plan is not None:
intent = normalized_plan["intent"]
sheet_size = normalized_plan["sheet_size"]
scale = normalized_plan["scale"]
layer_set_id = normalized_plan["layer_set_id"]
view_count = normalized_plan["view_count"]
dim_style = normalized_plan["dim_style"]
requirements = normalized_plan["requirements"]
plan = PlanSpec(
intent=str(intent),
sheet_size=sheet_size,
scale=float(scale),
layer_set_id=layer_set_id,
view_count=int(view_count),
dim_style=dim_style,
notes=list(notes) if notes else [],
requirements=dict(requirements or {}),
spec_hash=spec_hash,
preflight_status=preflight_status,
)
self._plan_spec = plan
return plan
async def drawing_critique(
self,
focus: list[CritiqueFocus] | None = None,
) -> list[Issue]:
"""Run premium-quality geometric checks; return zero issues before
`drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT
replay the committed PlanSpec — it inspects the live drawing geometry.)"""
from engineering.critique import run_critique
await self._ensure_document_state()
return await run_critique(self, focus)
def get_plan_spec(self) -> dict | None:
"""Return the PlanSpec committed by `drawing_plan` as a dict, or None
if no plan has been committed. (N8: makes the stored `_plan_spec`
actually readable instead of being write-only dead state.)"""
plan = getattr(self, "_plan_spec", None)
return plan.to_dict() if plan is not None else None
async def point_from_snap(
self,
handle: str,
snap: SnapType,
ref_x: float | None = None,
ref_y: float | None = None,
) -> tuple[float, float]:
"""Return a deterministic snap point on `handle`.
snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`,
`ref_x/y` is the reference point. Sources geometry from the shared
EntityInfo.properties contract — works on every backend."""
ent = await self.entity_get(handle)
et = ent.type
p = ent.properties
ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None
def _line_pts():
s = p.get("start")
e = p.get("end")
if s is None or e is None:
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.")
return (float(s[0]), float(s[1])), (float(e[0]), float(e[1]))
def _circle_geom():
c = p.get("center")
r = p.get("radius")
if c is None or r is None:
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.")
return float(c[0]), float(c[1]), float(r)
def _arc_angles():
# N7: use .get() + descriptive RuntimeError (matching _line_pts /
# _circle_geom) instead of bracket indexing that raises an opaque
# KeyError when start_angle/end_angle are missing.
sa = p.get("start_angle")
ea = p.get("end_angle")
if sa is None or ea is None:
raise RuntimeError(
f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle."
)
return math.radians(float(sa)), math.radians(float(ea))
if snap == "end":
if et == "LINE":
s, e = _line_pts()
if ref is None:
return s
ds = (s[0] - ref[0]) ** 2 + (s[1] - ref[1]) ** 2
de = (e[0] - ref[0]) ** 2 + (e[1] - ref[1]) ** 2
return s if ds <= de else e
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_angles()
sp = (cx + r * math.cos(sa), cy + r * math.sin(sa))
ep = (cx + r * math.cos(ea), cy + r * math.sin(ea))
if ref is None:
return sp
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
return sp if ds <= de else ep
raise RuntimeError(f"point_from_snap: 'end' snap not supported on {et}")
if snap == "mid":
if et == "LINE":
s, e = _line_pts()
return ((s[0] + e[0]) / 2.0, (s[1] + e[1]) / 2.0)
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_angles()
if ea < sa:
ea += 2 * math.pi
ma = (sa + ea) / 2.0
return (cx + r * math.cos(ma), cy + r * math.sin(ma))
raise RuntimeError(f"point_from_snap: 'mid' snap not supported on {et}")
if snap == "center":
if et in ("CIRCLE", "ARC", "ELLIPSE"):
c = p.get("center")
if c is None:
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.")
return (float(c[0]), float(c[1]))
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
# NEW-snap-quad-arc / NEW-snap-near-arc: an ARC only covers part of the
# full circle, so quad/near candidates derived from the circle may fall
# outside the [start_angle, end_angle] sweep. Restrict (quad) / clamp
# (near) the projected angle to the sweep, with 2π wraparound.
def _arc_sweep():
sa, ea = _arc_angles()
if ea < sa:
ea += 2 * math.pi
return sa, ea
def _angle_in_sweep(theta: float, sa: float, ea: float) -> bool:
# Normalise theta into [sa, sa+2π) and test against [sa, ea].
t = sa + ((theta - sa) % (2 * math.pi))
return t <= ea + 1e-9
def _arc_endpoints(cx: float, cy: float, r: float, sa: float, ea: float):
return (
(cx + r * math.cos(sa), cy + r * math.sin(sa)),
(cx + r * math.cos(ea), cy + r * math.sin(ea)),
)
if snap == "quad":
if et == "CIRCLE":
cx, cy, r = _circle_geom()
pts = [(cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r)]
if ref is None:
return pts[0]
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_sweep()
quad_angles = (0.0, math.pi / 2.0, math.pi, 3 * math.pi / 2.0)
pts = [
(cx + r * math.cos(a), cy + r * math.sin(a))
for a in quad_angles
if _angle_in_sweep(a, sa, ea)
]
if not pts:
# No quadrant lies on the sweep — fall back to arc endpoints.
pts = list(_arc_endpoints(cx, cy, r, sa, ea))
if ref is None:
return pts[0]
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}")
if snap == "perp":
if ref is None:
raise RuntimeError("point_from_snap: 'perp' requires ref_x/ref_y.")
if et == "LINE":
s, e = _line_pts()
dx = e[0] - s[0]
dy = e[1] - s[1]
L2 = dx * dx + dy * dy
if L2 < 1e-18:
raise RuntimeError("point_from_snap: line has zero length.")
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
return (s[0] + t * dx, s[1] + t * dy)
raise RuntimeError(f"point_from_snap: 'perp' snap not supported on {et}")
if snap == "near":
if ref is None:
raise RuntimeError("point_from_snap: 'near' requires ref_x/ref_y.")
if et == "LINE":
s, e = _line_pts()
dx = e[0] - s[0]
dy = e[1] - s[1]
L2 = dx * dx + dy * dy
if L2 < 1e-18:
return s
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
t = max(0.0, min(1.0, t))
return (s[0] + t * dx, s[1] + t * dy)
if et == "CIRCLE":
cx, cy, r = _circle_geom()
vx, vy = ref[0] - cx, ref[1] - cy
L = math.hypot(vx, vy)
if L < 1e-18:
return (cx + r, cy)
return (cx + r * vx / L, cy + r * vy / L)
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_sweep()
vx, vy = ref[0] - cx, ref[1] - cy
L = math.hypot(vx, vy)
if L < 1e-18:
# Degenerate ref at center: return the start endpoint.
return (cx + r * math.cos(sa), cy + r * math.sin(sa))
theta = math.atan2(vy, vx)
if _angle_in_sweep(theta, sa, ea):
return (cx + r * math.cos(theta), cy + r * math.sin(theta))
# Projected angle is off the sweep — fall back to the nearer endpoint.
sp, ep = _arc_endpoints(cx, cy, r, sa, ea)
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
return sp if ds <= de else ep
raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}")
if snap == "int":
raise RuntimeError(
"point_from_snap: 'int' (intersection) requires a 2nd entity; "
"compute via two endpoints. V2 will accept a 2nd handle."
)
raise RuntimeError(
f"point_from_snap: unknown snap type '{snap}'. "
"Use one of: end, mid, center, quad, perp, near."
)
async def point_intersection(
self,
handle1: str,
handle2: str,
ref_x: float | None = None,
ref_y: float | None = None,
) -> tuple[float, float]:
"""Return the intersection point of two geometry entities.
Supports LINE-LINE, LINE-CIRCLE, and CIRCLE-CIRCLE cases. When
multiple intersections exist (e.g. two circles), ``ref_x``/``ref_y``
selects the candidate nearest to that reference point. If omitted the
first candidate is returned.
Raises RuntimeError when no real intersection exists or entity types
are unsupported.
"""
info1 = await self.entity_get(handle1)
info2 = await self.entity_get(handle2)
t1, t2 = info1.type.upper(), info2.type.upper()
props1, props2 = info1.properties, info2.properties
candidates: list[tuple[float, float]] = []
if t1 == "LINE" and t2 == "LINE":
# Line-line intersection via parametric form
x1, y1 = props1["start"][0], props1["start"][1]
x2, y2 = props1["end"][0], props1["end"][1]
x3, y3 = props2["start"][0], props2["start"][1]
x4, y4 = props2["end"][0], props2["end"][1]
dx1, dy1 = x2 - x1, y2 - y1
dx2, dy2 = x4 - x3, y4 - y3
denom = dx1 * dy2 - dy1 * dx2
if abs(denom) < 1e-12:
raise RuntimeError("point_intersection: lines are parallel or coincident.")
t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom
candidates = [(x1 + t * dx1, y1 + t * dy1)]
elif {t1, t2} == {"LINE", "CIRCLE"}:
line_p = props1 if t1 == "LINE" else props2
circ_p = props2 if t1 == "LINE" else props1
lx1, ly1 = line_p["start"][0], line_p["start"][1]
lx2, ly2 = line_p["end"][0], line_p["end"][1]
cx, cy = circ_p["center"][0], circ_p["center"][1]
r = circ_p["radius"]
dx, dy = lx2 - lx1, ly2 - ly1
fx, fy = lx1 - cx, ly1 - cy
a = dx * dx + dy * dy
b = 2.0 * (fx * dx + fy * dy)
c = fx * fx + fy * fy - r * r
disc = b * b - 4.0 * a * c
if disc < 0:
raise RuntimeError("point_intersection: line does not intersect circle.")
sq = math.sqrt(disc)
for sign in (1, -1):
t = (-b + sign * sq) / (2.0 * a)
candidates.append((lx1 + t * dx, ly1 + t * dy))
elif t1 == "CIRCLE" and t2 == "CIRCLE":
cx1, cy1, r1 = props1["center"][0], props1["center"][1], props1["radius"]
cx2, cy2, r2 = props2["center"][0], props2["center"][1], props2["radius"]
dx, dy = cx2 - cx1, cy2 - cy1
d = math.hypot(dx, dy)
if d < 1e-12:
raise RuntimeError("point_intersection: circles are concentric.")
if d > r1 + r2 + 1e-9 or d < abs(r1 - r2) - 1e-9:
raise RuntimeError("point_intersection: circles do not intersect.")
a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d)
h_sq = r1 * r1 - a * a
h = math.sqrt(max(h_sq, 0.0))
mx = cx1 + a * dx / d
my = cy1 + a * dy / d
candidates = [
(mx + h * dy / d, my - h * dx / d),
(mx - h * dy / d, my + h * dx / d),
]
else:
raise RuntimeError(
f"point_intersection: unsupported entity combination {t1}+{t2}. "
"Supported: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE."
)
if len(candidates) == 1:
return candidates[0]
if ref_x is not None and ref_y is not None:
return min(
candidates,
key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2,
)
return candidates[0]
async def point_tangent(
self,
circle_handle: str,
from_x: float,
from_y: float,
ref_x: float | None = None,
ref_y: float | None = None,
) -> tuple[float, float]:
"""Return the tangent point on a circle from an external point.
When two tangent points exist, ``ref_x``/``ref_y`` selects the
nearer one; without it, the first is returned (counter-clockwise from
the from-point—circle-center line).
Raises RuntimeError if the point is inside the circle.
"""
info = await self.entity_get(circle_handle)
if info.type.upper() != "CIRCLE":
raise RuntimeError(
f"point_tangent: handle {circle_handle!r} is {info.type}, expected CIRCLE."
)
cx, cy = info.properties["center"][0], info.properties["center"][1]
r = info.properties["radius"]
fx, fy = float(from_x), float(from_y)
dx, dy = cx - fx, cy - fy
d = math.hypot(dx, dy)
if d < r - 1e-9:
raise RuntimeError(
f"point_tangent: from-point ({fx}, {fy}) is inside the circle "
f"(center ({cx}, {cy}), radius {r})."
)
if d < 1e-12:
raise RuntimeError("point_tangent: from-point coincides with circle center.")
# Tangent point T lies on the circle such that (T-F)⊥(T-C).
# Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two
# circle angles θ at which the tangent touches the perimeter.
alpha = math.atan2(dy, dx) # direction from F to C
# NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the
# narrow band [r-1e-9, r) (past the internal-point guard but with
# |-r/d| marginally > 1 from float error) degenerates gracefully to the
# tangent-at-perimeter case instead of raising a raw ValueError
# ("math domain error"). Genuine internal points (d < r) are already
# rejected by the RuntimeError guard above.
acos_neg = math.acos(max(-1.0, min(1.0, -r / d))) # (π/2, π] for external pt
candidates: list[tuple[float, float]] = [
(cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)),
(cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)),
]
if ref_x is not None and ref_y is not None:
return min(
candidates,
key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2,
)
return candidates[0]
@abstractmethod
async def _create_xline(
self,
x: float,
y: float,
dx: float,
dy: float,
layer: str,
) -> EntityInfo:
"""Create an infinite construction line (XLINE) through (x, y) with
unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no
cross-backend entity_create_* equivalent."""
...
async def _ensure_layer(self, name: str, color: int = 7) -> None:
"""Idempotently make sure `name` exists (no-op if already present)."""
try:
existing = {lyr.name for lyr in await self.layer_list()}
except Exception:
existing = set()
if name not in existing:
try:
await self.layer_create(name, color=color)
except Exception:
pass # already created / racy create — harmless
def _active_layer_set_id(self) -> str:
"""Best-effort active layer set: an explicit `drawing_apply_iso_layers`
wins, else the committed PlanSpec, else 'mech' (the default bootstrap)."""
explicit = getattr(self, "_active_layer_set", None)
if explicit:
return explicit
plan = getattr(self, "_plan_spec", None)
if plan is not None:
return getattr(plan, "layer_set_id", "mech")
return "mech"
def _role_layer(self, role: str) -> str:
"""Resolve the layer name for a role ('dim' | 'construction') in the
active layer set, so dims/scaffolding land on the right layer for
iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION)."""
from engineering.layers import resolve_role_layer
return resolve_role_layer(self._active_layer_set_id(), role)
async def construction_xline(
self,
x: float,
y: float,
angle_deg: float,
layer: str | None = None,
) -> EntityInfo:
"""Create an infinite construction line (XLINE) on the active layer set's
construction layer (auto-created with color 250 = lightest if missing)."""
layer = layer or self._role_layer("construction")
await self._ensure_layer(layer, color=250)
ang = math.radians(float(angle_deg))
return await self._create_xline(
float(x),
float(y),
math.cos(ang),
math.sin(ang),
layer,
)
async def construction_clear(
self,
layer: str | None = None,
) -> dict:
"""Delete every entity on the active layer set's construction layer.
Must be called before `drawing_finalize`."""
layer = layer or self._role_layer("construction")
try:
ents = await self.entity_list(layer_filter=layer, limit=5000)
except Exception:
ents = []
deleted = 0
for ent in ents:
try:
await self.entity_delete(ent.handle)
deleted += 1
except Exception:
continue
return {"ok": True, "layer": layer, "deleted": deleted}
async def drawing_apply_iso_layers(
self,
standard: LayerSetId = "mech",
) -> dict:
"""Bootstrap a full ISO-conformant layer set with correct colors
and lineweights. Idempotent."""
from engineering.layers import apply_layer_set
result = await apply_layer_set(self, standard)
self._active_layer_set = standard
return {"ok": True, "standard": standard, "layers": result}
async def dimension_auto(
self,
handles: list[str],
style: DimStyle = "chain",
offset: float = 10.0,
layer: str | None = None,
) -> list[EntityInfo]:
"""Generate ISO 129 dimensions across `handles` in the chosen style
(chain | baseline | ordinate). `offset` is the dimension-line
distance from the reference geometry (mm). `layer` defaults to the
active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567)."""
if not handles:
return []
dim_layer = layer or self._role_layer("dim")
if style not in ("chain", "baseline", "ordinate"):
raise RuntimeError(
f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'."
)
segs: list[tuple[float, float, float, float]] = []
for h in handles:
ent = await self.entity_get(h)
if ent.type != "LINE":
raise RuntimeError(
f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})."
)
s = ent.properties.get("start")
e = ent.properties.get("end")
if s is None or e is None:
raise RuntimeError(f"dimension_auto: line {h} missing start/end.")
segs.append((float(s[0]), float(s[1]), float(e[0]), float(e[1])))
off = float(offset)
results: list[EntityInfo] = []
if style == "chain":
# Each line gets its own linear (rotated) dimension, offset clear of
# the geometry along its own perpendicular.
for sx, sy, ex, ey in segs:
mx = (sx + ex) / 2.0
my = (sy + ey) / 2.0
dx, dy = ex - sx, ey - sy
L = math.hypot(dx, dy) or 1.0
nx, ny = -dy / L, dx / L
dim = await self.dimension_linear(
sx,
sy,
ex,
ey,
mx + nx * off,
my + ny * off,
rotation=math.degrees(math.atan2(dy, dx)),
layer=dim_layer,
)
results.append(dim)
elif style == "baseline":
sx0, sy0, ex0, ey0 = segs[0]
dx, dy = ex0 - sx0, ey0 - sy0
L0 = math.hypot(dx, dy) or 1.0
ux, uy = dx / L0, dy / L0
base_rot = math.degrees(math.atan2(dy, dx))
for i, (_sx, _sy, ex, ey) in enumerate(segs):
dim = await self.dimension_linear(
sx0,
sy0,
ex,
ey,
sx0 - uy * (off + i * off),
sy0 + ux * (off + i * off),
# Dimension line must run parallel to the (possibly rotated)
# baseline, not horizontal.
rotation=base_rot,
layer=dim_layer,
)
results.append(dim)
else: # ordinate — stacked X/Y distances; each feature offset so dims don't overlap
for i, (sx, sy, ex, ey) in enumerate(segs):
rung = off + i * off # stagger per feature to avoid coincident dims
if abs(ex - sx) > 1e-9:
dim = await self.dimension_linear(
sx,
sy,
ex,
sy,
sx + (ex - sx) / 2.0,
max(sy, ey) + rung,
layer=dim_layer,
)
results.append(dim)
if abs(ey - sy) > 1e-9:
dim = await self.dimension_linear(
ex,
sy,
ex,
ey,
max(sx, ex) + rung,
(sy + ey) / 2.0,
rotation=90.0,
layer=dim_layer,
)
results.append(dim)
return results
async def entity_select_smart(
self,
predicate: dict,
) -> list[EntityInfo]:
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
- type: "LINE" | "CIRCLE" | ...
- layer: layer name
- near: [x, y, radius] — entity reference point within circle
- length_range: [min, max] — applies to LINE/ARC
- color: ACI int
"""
if not isinstance(predicate, dict):
raise RuntimeError("entity_select_smart: predicate must be a dict.")
ptype = predicate.get("type")
player = predicate.get("layer")
pnear = predicate.get("near") # [x, y, radius]
plen = predicate.get("length_range") # [min, max]
pcolor = predicate.get("color")
ents = await self.entity_list(
type_filter=ptype.upper() if isinstance(ptype, str) else None,
layer_filter=player if isinstance(player, str) else None,
limit=5000,
)
def _ok(e: EntityInfo) -> bool:
if pcolor is not None and int(e.color) != int(pcolor):
return False
if pnear is not None:
try:
nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2])
except Exception:
return False
pt = (
e.properties.get("start")
or e.properties.get("center")
or e.properties.get("insertion")
or e.properties.get("insertion_point")
)
if pt is None:
return False
if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr**2:
return False
if plen is not None and e.type in ("LINE", "ARC"):
try:
lmin, lmax = float(plen[0]), float(plen[1])
except Exception:
return False
length = e.properties.get("length")
if length is None or not (lmin <= float(length) <= lmax):
return False
return True
return [e for e in ents if _ok(e)]
+139
View File
@@ -0,0 +1,139 @@
"""Friendly facade over system variables.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from typing import Any
class SettingsContract:
# A user-facing wrapper over system_get_variable / system_set_variable that
# maps memorable names ("units", "dimscale", …) to AutoCAD system variables.
# Concrete + backend-agnostic: both engines accept the bare sysvar name, so
# the same call reads/writes on live COM and headless ezdxf alike.
async def drawing_settings(self, settings: dict | None = None) -> dict:
"""Read (no args) or apply (with args) common drawing settings.
With ``settings=None`` returns a snapshot of every known setting. With a
dict, applies each provided key and returns ``{applied, errors, current}``.
Friendly keys: units, linear_precision, angular_precision, ltscale,
dimscale, text_size, point_mode, point_size, osmode, fillet_radius.
`units` accepts mm/cm/m/inch/feet (mapped to the INSUNITS code)."""
if not settings:
snapshot: dict = {}
for key, (var, kind) in _SETTING_MAP.items():
try:
raw = await self.system_get_variable(var)
except Exception as exc:
snapshot[key] = {"error": str(exc)}
continue
snapshot[key] = _decode_setting(key, kind, raw)
return {"ok": True, "settings": snapshot}
applied: dict = {}
errors: dict = {}
for key, value in settings.items():
spec = _SETTING_MAP.get(key)
if spec is None:
errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
continue
var, kind = spec
try:
encoded = _encode_setting(key, kind, value)
await self.system_set_variable(var, encoded)
applied[key] = value
except Exception as exc:
errors[key] = str(exc)
result = {"ok": not errors, "applied": applied}
if errors:
result["errors"] = errors
return result
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Friendly setting name -> (AutoCAD system variable, value kind).
_SETTING_MAP: dict[str, tuple[str, str]] = {
"units": ("INSUNITS", "units"),
"linear_precision": ("LUPREC", "int"),
"angular_precision": ("AUPREC", "int"),
"ltscale": ("LTSCALE", "float"),
"dimscale": ("DIMSCALE", "float"),
"text_size": ("TEXTSIZE", "float"),
"point_mode": ("PDMODE", "int"),
"point_size": ("PDSIZE", "float"),
"osmode": ("OSMODE", "int"),
"fillet_radius": ("FILLETRAD", "float"),
}
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
_UNIT_TO_CODE: dict[str, int] = {
"unitless": 0,
"inch": 1,
"inches": 1,
"in": 1,
"feet": 2,
"ft": 2,
"foot": 2,
"mm": 4,
"millimeter": 4,
"millimeters": 4,
"cm": 5,
"centimeter": 5,
"centimeters": 5,
"m": 6,
"meter": 6,
"meters": 6,
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
def _encode_setting(key: str, kind: str, value: Any) -> Any:
"""Coerce a friendly value into the raw system-variable value."""
if kind == "units":
if isinstance(value, (int, float)) and not isinstance(value, bool):
return int(value)
code = _UNIT_TO_CODE.get(str(value).strip().lower())
if code is None:
raise ValueError(
f"units: unknown value {value!r}. Use one of "
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
)
return code
if kind == "int":
return int(float(value))
if kind == "float":
return float(value)
return value
def _decode_setting(key: str, kind: str, raw: Any) -> Any:
"""Present a raw system-variable value in friendly form."""
if raw is None:
return None
if kind == "units":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
if kind == "int":
try:
return int(raw)
except (TypeError, ValueError):
return raw
if kind == "float":
try:
return float(raw)
except (TypeError, ValueError):
return raw
return raw
+79
View File
@@ -0,0 +1,79 @@
"""ACIS solids (live AutoCAD only).
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class SolidContract(ABC):
@abstractmethod
async def solid_box(
self, cx: float, cy: float, cz: float, length: float, width: float, height: float
) -> dict:
"""Create a 3D solid box centered at (cx, cy, cz)."""
...
@abstractmethod
async def solid_cylinder(
self, cx: float, cy: float, cz: float, radius: float, height: float
) -> dict:
"""Create a 3D solid cylinder centered at (cx, cy, cz)."""
...
@abstractmethod
async def solid_extrude(
self, profile_handle: str, height: float, taper_angle: float = 0.0
) -> dict:
"""Extrude a closed profile (circle/closed polyline) into a solid."""
...
@abstractmethod
async def solid_revolve(
self,
profile_handle: str,
axis_x1: float,
axis_y1: float,
axis_x2: float,
axis_y2: float,
angle: float = 360.0,
) -> dict:
"""Revolve a closed profile around an axis into a solid."""
...
@abstractmethod
async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict:
"""Boolean two solids: operation is union | subtract | intersect."""
...
@abstractmethod
async def drawing_audit(self) -> dict:
"""Audit the drawing, applying every repair the backend can make.
This MUTATES the drawing. Returns ``{"ok", "repaired", "fixes",
"fix_count", "errors", "error_count"}``, where ``fixes`` are problems
already repaired and ``errors`` are problems that could not be; both are
lists of ``{"code": int, "name": str, "message": str, "handle": str |
None}``.
A backend that repairs but cannot observe its own result reports
``repaired``/``fix_count``/``error_count`` as ``None`` — never ``0``,
which would claim nothing was wrong — plus ``"detail": "unavailable"``
and ``"capability": "audit_detail"`` so a client can pre-check the
boundary. Any implementation that repairs MUST mark the document dirty,
or the repair is discarded the next time the document is closed.
"""
...
@abstractmethod
async def drawing_close(self, save: bool = True) -> dict: ...
@abstractmethod
async def drawing_undo(self) -> dict: ...
@abstractmethod
async def drawing_redo(self) -> dict: ...
+28
View File
@@ -0,0 +1,28 @@
"""System variables and raw commands.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class SystemContract(ABC):
@abstractmethod
async def system_status(self) -> dict: ...
@abstractmethod
async def system_get_variable(self, name: str) -> Any: ...
@abstractmethod
async def system_set_variable(self, name: str, value: Any) -> dict: ...
@abstractmethod
async def system_run_command(self, command: str) -> dict: ...
@abstractmethod
async def system_run_lisp(self, expression: str) -> dict: ...
+21
View File
@@ -0,0 +1,21 @@
"""Transactions.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class TransactionContract(ABC):
@abstractmethod
async def transaction_begin(self) -> dict: ...
@abstractmethod
async def transaction_commit(self) -> dict: ...
@abstractmethod
async def transaction_rollback(self) -> dict: ...
+43
View File
@@ -0,0 +1,43 @@
"""Views and screenshots.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class ViewContract(ABC):
@abstractmethod
async def view_zoom_extents(self) -> dict: ...
@abstractmethod
async def view_zoom_window(
self,
x1: float,
y1: float,
x2: float,
y2: float,
) -> dict: ...
@abstractmethod
async def view_screenshot(self, overlay_handles: bool = False) -> bytes | None: ...
@abstractmethod
async def view_screenshot_grounded(
self,
overlay_handles: bool = True,
max_labels: int = 40,
) -> dict:
"""Screenshot plus the handle-to-position mapping it drew.
Returns ``{"png", "labels", "labelled", "total", "truncated"}``.
``labels`` maps handle -> ``[x, y]`` in drawing units, so the picture and
the handle space a caller can act on are the same thing. ``truncated``
says a crowded drawing was capped rather than letting a labelled subset
read as the whole sheet.
"""
...
+2612 -93
View File
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
"""OCS (Object Coordinate System) to WCS translation, shared by both backends.
DXF does not store 2D geometry in world coordinates. CIRCLE, ARC, LWPOLYLINE,
TEXT, INSERT and a dimension's text position live in a per-entity frame derived
from the entity's ``extrusion`` vector by the Arbitrary Axis Algorithm. Mirroring
flips extrusion to ``(0, 0, -1)`` and leaves the stored x *unnegated*, so a raw
``dxf.center`` read reports a point the entity is not at — by 60 mm in the
measured case, while the same response's bounding box (which ezdxf computes
through the renderer, in WCS) said otherwise.
The rule this module exists to enforce: **everything crossing the MCP boundary is
WCS.** OCS never leaves the backends.
Both engines share this. ``ezdxf`` is a core dependency, not an extra, so the COM
backend may import it too — and should, rather than hand-rolling the Arbitrary
Axis Algorithm a second time. The functions take a raw extrusion/normal triple
rather than an entity, because a COM object exposes ``.Normal`` where an ezdxf
one exposes ``.dxf.extrusion``.
Not every entity that *carries* extrusion is affected: ezdxf makes ``ocs()`` a
pass-through for LINE, MTEXT, ELLIPSE, SPLINE and POINT, which are WCS-native.
Keying any fix off "has an extrusion attribute" breaks those.
"""
from __future__ import annotations
import math
from ezdxf.math import OCS, Vec3
_IDENTITY = (0.0, 0.0, 1.0)
def is_wcs_frame(extrusion) -> bool:
"""True when the entity's frame is the identity — nothing to translate."""
try:
return OCS(Vec3(extrusion)).uz.isclose(_IDENTITY)
except Exception:
return True # an unreadable extrusion is not evidence of a rotated frame
def is_flat_frame(extrusion) -> bool:
"""True when the entity's plane is parallel to WCS XY (extrusion is +/-Z).
Only these frames admit an exact 2D write: given a tilted plane, an ``(x, y)``
pair does not determine a point in it, so writing one would displace the
entity out of its own plane by an amount nobody supplied.
"""
try:
uz = OCS(Vec3(extrusion)).uz
except Exception:
return True
return abs(uz.x) < 1e-12 and abs(uz.y) < 1e-12
def to_wcs_2d(extrusion, x, y, z=0.0) -> list[float]:
"""OCS ``(x, y, z)`` to WCS ``[x, y]``.
``z`` is ``dxf.elevation`` for an LWPOLYLINE (whose vertices carry no z of
their own) and the point's own z otherwise.
"""
v = OCS(Vec3(extrusion)).to_wcs(Vec3(float(x), float(y), float(z)))
return [float(v.x), float(v.y)]
def from_wcs(extrusion, x, y, z=0.0) -> tuple[float, float, float]:
"""WCS ``(x, y, z)`` to OCS ``(x, y, z)`` — the inverse of :func:`to_wcs_2d`.
Needed on every write path. The read and write halves used to be *equally*
wrong, which made a read-then-write round trip an accidental no-op; fixing
reads alone would have converted that no-op into a real move.
"""
v = OCS(Vec3(extrusion)).from_wcs(Vec3(float(x), float(y), float(z)))
return (float(v.x), float(v.y), float(v.z))
def wcs_arc_angles(extrusion, center_ocs, radius, start_deg, end_deg) -> tuple[float, float]:
"""OCS sweep angles to CCW-in-WCS angles about the WCS centre.
Computed by mapping the two endpoints through the frame rather than by
arithmetic on the angles, so it stays right for any frame. A left-handed
frame (``uz.z < 0``, which is what a mirror produces) reverses the direction
of the sweep, so the endpoints swap roles.
"""
ocs = OCS(Vec3(extrusion))
c0 = Vec3(center_ocs)
centre = ocs.to_wcs(c0)
angles = []
for angle in (float(start_deg), float(end_deg)):
point = ocs.to_wcs(Vec3.from_deg_angle(angle, abs(float(radius))) + c0)
angles.append(math.degrees(math.atan2(point.y - centre.y, point.x - centre.x)) % 360.0)
if ocs.uz.z < 0:
angles.reverse()
return angles[0], angles[1]
def plane_normal(extrusion) -> list[float]:
"""The entity's plane normal, for reporting a frame that xy cannot express."""
uz = OCS(Vec3(extrusion)).uz
return [float(uz.x), float(uz.y), float(uz.z)]
+124 -31
View File
@@ -11,7 +11,7 @@ This repository publishes two deliberately separate forms of evidence:
delivery, backend reach, engineering production, tests, and security. The
dated data lives in [`source_review.json`](source_review.json), and the README
graphic is generated—not hand-edited—from that file.
2. **Fixed-task runtime benchmark.** Adapters execute the same ten tasks and
2. **Fixed-task runtime benchmark.** Adapters execute the same task matrix and
return `pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`.
AutoCAD MCP Pro currently has the reference adapter; other projects do not
receive runtime scores until an adapter actually runs their public interface.
@@ -26,38 +26,80 @@ python -m benchmarks.render_chart
The source-review chart is not presented as a shared live AutoCAD run. Review
dates, evidence grades, project URLs, and the boundary are stored with the data.
## Fixed-task runtime runner v2
## Fixed-task runtime runner
The v2 runner separates runtime evidence from the source-review rubric. Every
adapter receives the same ten tasks and produces the same closed result enum:
The runner separates runtime evidence from the source-review rubric. Every
adapter receives the same tasks and produces the same closed result enum:
`pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`. A timeout is
reported per task. Execution is in-process, so adapters that delegate blocking
work must provide cancellation-safe task implementations or be wrapped by an
external process supervisor. Reports include commit SHA, Python/platform
details, backend capability claims, durations, coverage, and hashes for returned
artifacts. Unsupported tasks remain in the fixed-matrix denominator with score
zero, preventing partial implementations from receiving an inflated score.
reported per task, and a capability refusal is recorded `unsupported` rather
than `fail` — "cannot reach this" and "got this wrong" are different findings.
Execution is in-process, so adapters that delegate blocking work must provide
cancellation-safe task implementations or be wrapped by an external process
supervisor. Reports include commit SHA, Python/platform details, backend
capability claims, durations, coverage, and hashes for returned artifacts.
Unsupported tasks remain in the fixed-matrix denominator with score zero,
preventing partial implementations from receiving an inflated score.
```bash
python -m benchmarks.run_competitors --list
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json
python -m benchmarks.run_competitors --task table_mleader --task auditable_delivery --json
python -m benchmarks.run_competitors --list --matrix v3
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v3 --json
python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json
```
The release-machine ezdxf self-check is **10/10 (100.0)**. Repository stars and
raw tool counts do not contribute to the score. Adapter registration lives in
`competitors.yaml`.
The release-machine ezdxf self-check is **15/15 (100.0)** on the v3 matrix.
Repository stars and raw tool counts do not contribute to the score. Adapter
registration lives in `competitors.yaml`.
### Matrix v3 (v1.5.0) — five tasks that can fail
v2 scored this server 10/10, and that number carried no information: every task
in it exercised something the server was designed around. `tasks_v3.py` keeps
the v2 ten unchanged — their ids and weights key the published v1.4 reports —
and adds five chosen because they *can* fail. Three of them did, while being
written:
| Task | Category | Verified against |
|---|---|---|
| `tool_discovery` | discovery | six AutoCAD command names, each ranking #1 of 154 tools |
| `token_budget` | efficiency | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
| `hatch_islands` | hatch | 300 filled with the island, 400 ignoring it |
| `selection_filter` | selection | window 1, crossing 2, bounding box 3, polygon 1 |
| `measure_from_handle` | measurement | 139.2699 against the 100.0 a vertex shoelace gives |
Token counts come from the offline ratio estimator and the report labels them
`ratio/v1 (estimate)`; `token_suite.py --tokenizer anthropic` counts for real.
**Two renames from the release plan, and they are the point.**
`tool_discovery_bilingual` → `tool_discovery`: a Turkish query normalizer was
written and then removed, because this repository is English throughout and the
Turkish was inferred from the author's chat language rather than required by the
product. `measurement_massprops` → `measure_from_handle`: no centroid or
moment-of-inertia tool ships.
**The competitor reports are not re-scored.** Those servers are pinned at v1.4
commits and were run against the v2 matrix; they have never been asked these
five questions. Their scores stand over the ten they ran, and the matrix chart
leaves the five new rows blank (`not run`) instead of scoring them zero — a
zero we invented would be indistinguishable from a zero we measured.
`--matrix v2` stays available so a v1.4 report can be reproduced rather than
only described, and `--publish` writes the artifact-path-free file that
`results/published/` holds.
## Live competitor lane (v1.4)
Two competitor adapters now execute the exact same task matrix, black-box over
MCP stdio, against commits pinned in `competitors.yaml`:
| Server | Pinned | Score | Pass | Coverage |
|---|---|---:|---:|---:|
| autocad-mcp-pro (reference) | working tree | 100.0 | 10/10 | 100% |
| beiming183-cloud/AutoCAD-MCP | `11f7c47e` | 50.0 | 5/10 | 50% |
| puran-water/autocad-mcp | `95476a33` | 45.0 | 4/10 | 50% |
| Server | Matrix | Pinned | Score | Pass | Coverage |
|---|---|---|---:|---:|---:|
| autocad-mcp-pro (reference) | v3 (15) | working tree | 100.0 | 15/15 | 100% |
| beiming183-cloud/AutoCAD-MCP | v2 (10) | `11f7c47e` | 50.0 | 5/10 | 50% |
| puran-water/autocad-mcp | v2 (10) | `95476a33` | 45.0 | 4/10 | 50% |
The competitor rows are the v1.4 runs, unchanged. Read the matrix column
before the score column: the denominators differ because the five v3 tasks
were added after those runs and have not been put to them.
Method and boundaries:
@@ -84,12 +126,16 @@ live under [`results/published/`](results/published/); the README charts are
regenerated from them:
```bash
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf --matrix v2
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf --matrix v2
python -m benchmarks.render_live_chart # score bars
python -m benchmarks.render_matrix_chart # tasks x servers status heatmap
```
`--publish <path>` writes the same report with artifact paths reduced to
filenames, which is how `results/published/` is produced; the sha256 stays,
because that is what makes an artifact checkable.
## Headless performance lane
`perf_suite.py` measures wall time on fixed workloads through the same backend
@@ -107,6 +153,36 @@ python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.js
python -m benchmarks.render_perf_chart
```
### v1.4.0 → v1.5.0, same interpreter
The published v1.4 report was recorded on CPython 3.14 and the v1.5 one on
3.11.15, so comparing the two files directly shows an apparent 2–6× speedup
that is almost entirely the interpreter. The honest comparison re-runs v1.4.0
in a `git worktree` with the *current* suite file — same instrument, different
code under test, one interpreter. Median of three runs each:
| Workload | v1.4.0 | v1.5.0 | Change |
|---|---:|---:|---|
| `create_lines_2k` | 245 ms | 315 ms | **28% slower** |
| `roundtrip_10k` | 1,765 ms | 2,146 ms | **22% slower** |
| `region_query_10k` | 1,090 ms | 1,039 ms | within spread |
| `premium_pass` | 375 ms | 44 ms | faster; the v1.4 spread (279–772 ms) is too wide to quantify |
Attributed rather than reported: `EZDXF_CALL_TIMEOUT=0` puts v1.5.0 back to
244.8 ms and 1,679 ms, which are v1.4.0's numbers. The entire cost is the
per-call `asyncio.wait_for` wrapped around every headless call in this release,
so a single hung ezdxf call can no longer wedge a server whose document lock is
one `asyncio.Lock`. Reproduce:
```bash
git worktree add /tmp/wt-v140 v1.4.0
cp benchmarks/perf_suite.py /tmp/wt-v140/benchmarks/
(cd /tmp/wt-v140 && PYTHONPATH=. python benchmarks/perf_suite.py --out /tmp/v140.json)
python -m benchmarks.perf_suite --out /tmp/v150.json
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/v150-notimeout.json
git worktree remove --force /tmp/wt-v140
```
## Correctness A/B suite
`correctness_suite.py` is a set of deterministic, headless (ezdxf-backend) checks
@@ -123,19 +199,36 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json results.json
```
### Result — v1.4.0 vs v1.3.0 (release gate)
### Result — v1.5.0 vs v1.4.0 (release gate)
21 checks, ezdxf backend, one subprocess per check. Machine-readable report:
24 checks, ezdxf backend, one subprocess per check. Machine-readable report:
[`results/published/ab-v1.4.0-vs-v1.5.0.json`](results/published/ab-v1.4.0-vs-v1.5.0.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| **v1.4.0** (baseline) | 21 / 24 | 87.5 % | — | — |
| **v1.5.0** (this release) | 24 / 24 | 100 % | 3 | **0** |
Read the three "fixed" carefully: they are **new capability, not repaired
regressions**. v1.4.0 has no `entity_measure` and no boundary tracing, so it
misses `hatch_area_subtracts_its_island`, `boundary_area_agrees_with_measure`
and `two_vertex_circle_has_area` by not having the method rather than by
getting it wrong. They are in the suite because all three broke during v1.5.0
development and nothing outside their own unit tests would have caught it.
The 21 pre-existing checks are unchanged and all still pass: the discovery
layer, `cad_batch`, the 19-module contract split, the layout/viewport family
and Wave A landed with **zero correctness regressions**.
### Result — v1.4.0 vs v1.3.0
21 checks. Report:
[`results/published/ab-v1.3.0-vs-v1.4.0.json`](results/published/ab-v1.3.0-vs-v1.4.0.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| **v1.3.0** (baseline) | 21 / 21 | 100 % | — | — |
| **v1.4.0** (this release) | 21 / 21 | 100 % | 0 | **0** |
The v1.4.0 feature and infrastructure work (CI, packaging, competitor lane,
tool profiles, paper space, ISO 286 fits, opt-in solids) introduced **zero
correctness regressions**.
| **v1.3.0** (baseline) | 21 / 21 | 100 % | — | — |
| **v1.4.0** | 21 / 21 | 100 % | 0 | **0** |
### Result — v1.1.0 vs v1.0.0 (`origin/main`, commit 15fa2bc)
+228 -2
View File
@@ -1,15 +1,45 @@
"""Reference v2 adapter for this repository."""
"""Reference adapter for this repository, covering the v3 task matrix.
Every task verifies against a value worked out independently of the code under
test — a closed-form area, a count of entities placed on purpose, a token
ceiling fixed before the measurement. A task that only checks the server agrees
with itself is a task that cannot fail.
"""
from __future__ import annotations
import math
from pathlib import Path
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.tasks_v2 import TaskSpec
from benchmarks.tasks_v3 import TaskSpec
from engineering.delivery import deliver_drawing
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from engineering.refiner import refine_drawing
#: AutoCAD command names a drafter types, none of which appeared in any tool
#: name or description before v1.5.0 — measured `df = 0` against the stock BM25
#: index, so no amount of tuning could have surfaced them.
#: Each command is answered by a *set*, because more than one tool can be a
#: correct answer -- QSELECT is both "filter by properties" and "select
#: similar", and picking a winner between them would test the tie-break rather
#: than the discovery.
DISCOVERY_QUERIES: tuple[tuple[str, frozenset[str]], ...] = (
("FILLET", frozenset({"entity_fillet"})),
("BPOLY", frozenset({"boundary_trace"})),
("QSELECT", frozenset({"selection_filter", "entity_select_smart"})),
("WBLOCK", frozenset({"block_create_from_entities"})),
("OVERKILL", frozenset({"drawing_refine"})),
("CHSPACE", frozenset({"entity_change_space"})),
)
#: How far down the ranking still counts as found. A drafter reads the top few.
DISCOVERY_TOP_N = 3
#: The advertised catalog must stay under this in discovery mode. Fixed here
#: rather than derived from the measurement, so the measurement can miss it.
TOKEN_CEILING = 2_000
class AutoCADMCPProAdapter(BenchmarkAdapter):
name = "autocad-mcp-pro"
@@ -138,6 +168,202 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
await reopened.disconnect()
return count == 1, {"entity_count": count}, [str(path)]
# ── v3 (v1.5.0) ─────────────────────────────────────────────────────────
async def _task_tool_discovery(self):
"""Can a drafter find the tool by typing the command they know?
Ranked through the real transform, not a stub: the corpus is what makes
these commands findable at all, and testing anything else would test
the fixture.
"""
import server
from discovery.transform import CadSearchTransform
tools = list(await server.mcp._list_tools())
transform = CadSearchTransform()
ranks: dict[str, int | None] = {}
for command, accepted in DISCOVERY_QUERIES:
hits = [
tool.name for tool in transform.rank(tools, command, limit=DISCOVERY_TOP_N).hits
]
found = next((index for index, name in enumerate(hits) if name in accepted), None)
ranks[command] = None if found is None else found + 1
passed = all(rank is not None for rank in ranks.values())
return passed, {"ranks": ranks, "top_n": DISCOVERY_TOP_N}, []
async def _task_token_budget(self):
"""What the client pays before it has asked for anything.
The ceiling is a constant, so a regression that doubles the catalog
fails this rather than quietly reporting a bigger number.
"""
import server
from benchmarks.token_suite import (
FORMAT_JSON_SCHEMA,
_client,
_wire_payload,
build_counter,
)
async def _advertised(mode: str) -> tuple[int, int]:
"""Tokens on the wire for `tools/list`, and how many tools that is.
Measured through a real client rather than the registry: the
registry is what the transform hides, so counting it would make
discovery mode look free.
"""
server._apply_discovery_mode(mode)
async with _client() as client:
tools = await client.list_tools()
return counter.count(_wire_payload(tools), FORMAT_JSON_SCHEMA), len(tools)
counter = build_counter("ratio")
try:
full, catalog_count = await _advertised("off")
discovery, advertised_count = await _advertised("search")
finally:
server._apply_discovery_mode("off")
passed = discovery < TOKEN_CEILING and discovery < full
return (
passed,
{
"catalog_tokens": full,
"discovery_tokens": discovery,
"ceiling": TOKEN_CEILING,
"reduction_x": round(full / discovery, 1) if discovery else None,
"catalog_tools": catalog_count,
"advertised_tools": advertised_count,
# Named, because "40,305 tokens" reads like a count and is not
# one. `benchmarks/token_suite.py --tokenizer anthropic` counts
# for real; this lane has to stay offline and deterministic.
"tokenizer": f"{counter.name} (estimate)" if counter.estimated else counter.name,
},
[],
)
async def _task_hatch_islands(self):
"""A section view is mostly holes, so the island is the measurement.
20x20 outer square with a 10x10 island: the filled area is 300, and a
server that records the island without subtracting it reports 400 while
looking entirely correct on screen.
"""
await ensure_engineering_layers(self.backend)
hatch = await self.backend.entity_create_hatch(
"ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]], layer="HATCH"
)
island = [
{"type": "line", "start": [5, 5], "end": [15, 5]},
{"type": "line", "start": [15, 5], "end": [15, 15]},
{"type": "line", "start": [15, 15], "end": [5, 15]},
{"type": "line", "start": [5, 15], "end": [5, 5]},
]
added = await self.backend.hatch_add_boundary(hatch.handle, island)
measured = await self.backend.entity_measure(hatch.handle)
ignored = await self.backend.hatch_edit(hatch.handle, style="ignore")
over_island = await self.backend.entity_measure(hatch.handle)
passed = (
added["path_count"] == 2
and abs(measured["area"] - 300.0) < 1e-6
and measured["hatch_style"] == "outer" # what entity_create_hatch writes
and ignored["changed"] == ["style"]
and abs(over_island["area"] - 400.0) < 1e-6
)
return (
passed,
{
"path_count": added["path_count"],
"filled_area": measured["area"],
"area_ignoring_islands": over_island["area"],
"expected_filled": 300.0,
"hatch_style": measured["hatch_style"],
},
[],
)
async def _task_selection_filter(self):
"""Window is not crossing, and a polygon is not its bounding box.
Three circles placed so each distinction has exactly one right answer:
one wholly inside, one straddling the edge, one out in the corner that
the bounding box catches and the triangle does not.
"""
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_circle(5, 5, 1, layer="GEOMETRY")
await self.backend.entity_create_circle(10, 5, 3, layer="GEOMETRY")
await self.backend.entity_create_circle(18, 18, 0.5, layer="GEOMETRY")
await self.backend.entity_create_circle(40, 40, 1, layer="TEXT")
window = await self.backend.selection_window(0, 0, 10, 10, mode="window")
crossing = await self.backend.selection_window(0, 0, 10, 10, mode="crossing")
bbox = await self.backend.selection_window(0, 0, 20, 20, mode="window")
triangle = await self.backend.selection_polygon([[0, 0], [20, 0], [0, 20]], mode="window")
by_layer = await self.backend.selection_filter(entity_type="CIRCLE", layer="TEXT")
passed = (
window["count"] == 1
and crossing["count"] == 2
and bbox["count"] == 3
and triangle["count"] == 1
and by_layer["count"] == 1
and by_layer["filtered_by"] == ["entity_type", "layer"]
)
return (
passed,
{
"window": window["count"],
"crossing": crossing["count"],
"bounding_box": bbox["count"],
"polygon": triangle["count"],
"by_layer": by_layer["count"],
},
[],
)
async def _task_measure_from_handle(self):
"""The 28.2% that reading coordinates back loses.
A 10x10 square whose top edge is a semicircle encloses 139.2699. Chain
the loose edges into a boundary, then measure that boundary by handle;
shoelacing the four corners the model would have remembered gives 100.
"""
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
await self.backend.entity_create_line(10, 0, 10, 10, layer="GEOMETRY")
await self.backend.entity_create_line(0, 10, 0, 0, layer="GEOMETRY")
await self.backend.entity_create_arc(5, 10, 5, 0, 180, layer="GEOMETRY")
loop = await self.backend.boundary_trace(5.0, 5.0)
measured = await self.backend.entity_measure(loop["handle"])
naive = await self.backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]])
true_area = 100.0 + math.pi * 25.0 / 2.0
lost = (true_area - naive) / true_area
passed = (
loop["ok"] is True
and abs(measured["area"] - true_area) < 1e-5
and abs(loop["area"] - measured["area"]) < 1e-9
and measured["method"] == "analytic_bulge"
and measured["exact"] is True
and 0.28 < lost < 0.29
)
return (
passed,
{
"measured_area": measured["area"],
"true_area": round(true_area, 6),
"vertex_shoelace_area": naive,
"fraction_lost_by_reading_points_back": round(lost, 4),
"boundary_agrees": abs(loop["area"] - measured["area"]) < 1e-9,
},
[],
)
async def _task_auditable_delivery(self):
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
+49
View File
@@ -223,6 +223,52 @@ async def center_linetype_applied():
return info.linetype.upper() == "CENTER"
# ── Measurement (v1.5.0) ───────────────────────────────────────────────────────
#
# These three are NEW capability, not repaired regressions: v1.4.0 has no
# `entity_measure` and no boundary tracing, so it misses them by not having the
# method rather than by getting it wrong. They are here because all three broke
# during v1.5.0 development and nothing outside their own unit tests would have
# noticed.
async def hatch_area_subtracts_its_island():
"""A section view is mostly holes; counting them is a 33% error here."""
b = await _b()
h = await b.entity_create_hatch("ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]])
await b.hatch_add_boundary(
h.handle,
[
{"type": "line", "start": [5, 5], "end": [15, 5]},
{"type": "line", "start": [15, 5], "end": [15, 15]},
{"type": "line", "start": [15, 15], "end": [5, 15]},
{"type": "line", "start": [5, 15], "end": [5, 5]},
],
)
return abs((await b.entity_measure(h.handle))["area"] - 300.0) < 1e-6
async def boundary_area_agrees_with_measure():
"""Two tools, one shape, one number."""
b = await _b()
await b.entity_create_line(0, 0, 10, 0)
await b.entity_create_line(10, 0, 10, 10)
await b.entity_create_line(0, 10, 0, 0)
await b.entity_create_arc(5, 10, 5, 0, 180)
loop = await b.boundary_trace(5.0, 5.0)
measured = await b.entity_measure(loop["handle"])
true_area = 100.0 + math.pi * 25.0 / 2.0
return abs(loop["area"] - measured["area"]) < 1e-9 and abs(measured["area"] - true_area) < 1e-5
async def two_vertex_circle_has_area():
"""DXF's most compact circle. The shoelace over its two points is 0."""
from engineering.measure import polygon_area_perimeter
area, _ = polygon_area_perimeter([(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)], closed=True)
return abs(area - math.pi * 100.0) < 1e-9
CHECKS = {
"core_line_length": (core_line_length, "Core"),
"core_circle_radius": (core_circle_radius, "Core"),
@@ -245,6 +291,9 @@ CHECKS = {
"construction_left_iso_caught": (construction_left_iso_caught, "Quality gate"),
"gear_no_self_overlap": (gear_no_self_overlap, "Engineering"),
"center_linetype_applied": (center_linetype_applied, "Entities"),
"hatch_area_subtracts_its_island": (hatch_area_subtracts_its_island, "Measurement"),
"boundary_area_agrees_with_measure": (boundary_area_agrees_with_measure, "Measurement"),
"two_vertex_circle_has_area": (two_vertex_circle_has_area, "Measurement"),
}
+9 -4
View File
@@ -13,7 +13,7 @@ import json
from pathlib import Path
from typing import Any
from benchmarks.tasks_v2 import TASKS_V2
from benchmarks.tasks_v3 import TASKS_V3
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
@@ -66,8 +66,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1"
task_ids = [task.task_id for task in TASKS_V2]
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V2]
task_ids = [task.task_id for task in TASKS_V3]
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V3]
servers = [report["adapter"] for report in reports]
status_by_server = {
report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]}
@@ -132,12 +132,17 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
Patch(facecolor=STATUS_COLORS["partial"], label="partial"),
Patch(facecolor=STATUS_COLORS["fail"], label="fail"),
Patch(facecolor=STATUS_COLORS["unsupported"], label="unsupported (no equivalent)"),
# Real rows now, not a theoretical state: the competitor reports were
# pinned and run against the v2 matrix, so the five v3 tasks were
# never put to them. An invented zero would look identical to a
# measured one, so they stay blank and say why.
Patch(facecolor=STATUS_COLORS["not_run"], edgecolor="#30363d", label="not run"),
]
ax.legend(
handles=legend_handles,
loc="upper left",
bbox_to_anchor=(0, -0.02),
ncol=4,
ncol=5,
frameon=False,
fontsize=9,
labelcolor="#e6edf3",
@@ -0,0 +1,158 @@
{
"summary": {
"baseline_ref": "v1.4.0",
"total": 24,
"old_pass": 21,
"new_pass": 24,
"old_pct": 87.5,
"new_pct": 100.0,
"fixed": 3,
"regressed": 0
},
"rows": [
{
"check": "core_line_length",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_circle_radius",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_layer_create",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_polyline_closed",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_linear_dim",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_save_dxf_roundtrip",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "dim_aligned_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "dim_angular_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "array_polar_360_distinct",
"category": "Modify",
"old": "pass",
"new": "pass"
},
{
"check": "point_intersection_line_line",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "point_tangent_external",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "arc_has_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "arc_select_by_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "ezdxf_bounding_box",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "mtext_rotation_roundtrip",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "screenshot_png",
"category": "Render",
"old": "pass",
"new": "pass"
},
{
"check": "dim_overlap_critique_fires",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "iso13567_dim_layer",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "construction_left_iso_caught",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "gear_no_self_overlap",
"category": "Engineering",
"old": "pass",
"new": "pass"
},
{
"check": "center_linetype_applied",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "hatch_area_subtracts_its_island",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "boundary_area_agrees_with_measure",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "two_vertex_circle_has_area",
"category": "Measurement",
"old": "miss",
"new": "pass"
}
]
}
+187 -25
View File
@@ -5,6 +5,21 @@
"capabilities": {
"backend": "ezdxf",
"features": {
"audit_detail": {
"mode": "native",
"reason": null,
"supported": true
},
"boundary_trace": {
"mode": "computed",
"reason": "straight_edges_split_at_intersections;curved_edges_not_split",
"supported": true
},
"chspace": {
"mode": "computed",
"reason": "top_view_untwisted_viewports_only;dimensions_refused_unless_frozen;acis_proxy_table_refused;viewport_clipping_reported_not_applied",
"supported": true
},
"delivery": {
"mode": "shared",
"reason": null,
@@ -15,26 +30,61 @@
"reason": null,
"supported": true
},
"dwg": {
"mode": null,
"reason": "no_dwg_writer_requires_live_autocad",
"supported": false
},
"dxf": {
"mode": "native",
"reason": null,
"supported": true
},
"handle_overlay": {
"mode": "rendered",
"reason": null,
"supported": true
},
"hatch_edge_paths": {
"mode": "native",
"reason": null,
"supported": true
},
"lisp": {
"mode": null,
"reason": "live_com_only",
"supported": false
},
"measure_area_acis": {
"mode": null,
"reason": "acis_evaluation_requires_live_autocad",
"supported": false
},
"mleader": {
"mode": "composite",
"reason": null,
"supported": true
},
"paper_space": {
"mtext_background_color": {
"mode": "native",
"reason": null,
"supported": true
},
"ocs_normalized": {
"mode": "wcs",
"reason": "reported_xy_is_wcs;z_component_not_reported;text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported",
"supported": true
},
"ocs_tilted_plane": {
"mode": null,
"reason": "planned_1.4",
"reason": "2d_xy_cannot_address_a_tilted_plane",
"supported": false
},
"paper_space": {
"mode": "native",
"reason": null,
"supported": true
},
"pdf": {
"mode": "rendered",
"reason": null,
@@ -55,9 +105,14 @@
"reason": null,
"supported": true
},
"revcloud": {
"mode": "native",
"reason": "ezdxf_revcloud_helper",
"supported": true
},
"solid_3d": {
"mode": null,
"reason": "planned_1.5",
"reason": "acis_generation_requires_live_autocad",
"supported": false
},
"table": {
@@ -69,6 +124,21 @@
"mode": "snapshot",
"reason": null,
"supported": true
},
"undo_history": {
"mode": null,
"reason": "set_EZDXF_UNDO_DEPTH_to_enable",
"supported": false
},
"viewport_render": {
"mode": "rendered",
"reason": "viewport_borders_not_drawn",
"supported": true
},
"wipeout": {
"mode": "native",
"reason": null,
"supported": true
}
}
}
@@ -76,15 +146,16 @@
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"generated_at": "2026-07-23T11:39:04.277175+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"generated_at": "2026-08-06T06:27:59.080169+00:00",
"git_sha": "d5a0ff5109ddbd37d88371c10a1ed087201de4b5",
"matrix": "v3",
"results": [
{
"artifacts": [],
"duration_ms": 8.14,
"duration_ms": 2.49,
"message": "",
"metrics": {
"line_length": 5.0
@@ -97,7 +168,7 @@
},
{
"artifacts": [],
"duration_ms": 3.39,
"duration_ms": 1.41,
"message": "",
"metrics": {},
"score": 100.0,
@@ -108,7 +179,7 @@
},
{
"artifacts": [],
"duration_ms": 8.11,
"duration_ms": 3.31,
"message": "",
"metrics": {},
"score": 100.0,
@@ -119,7 +190,7 @@
},
{
"artifacts": [],
"duration_ms": 348.74,
"duration_ms": 17.55,
"message": "",
"metrics": {
"entity_type": "DIMENSION"
@@ -132,7 +203,7 @@
},
{
"artifacts": [],
"duration_ms": 15.74,
"duration_ms": 3.7,
"message": "",
"metrics": {
"representations": [
@@ -148,7 +219,7 @@
},
{
"artifacts": [],
"duration_ms": 28.95,
"duration_ms": 29.09,
"message": "",
"metrics": {
"entity_count": 0
@@ -161,7 +232,7 @@
},
{
"artifacts": [],
"duration_ms": 2.5,
"duration_ms": 1.2,
"message": "",
"metrics": {},
"score": 100.0,
@@ -172,7 +243,7 @@
},
{
"artifacts": [],
"duration_ms": 29.19,
"duration_ms": 8.42,
"message": "",
"metrics": {
"final_score": 100.0,
@@ -215,11 +286,11 @@
"artifacts": [
{
"filename": "roundtrip.dxf",
"sha256": "6413204a5baec19a5fbf551418eb56e30b1488ca5bb755e6a8709ac8e8ab0b22",
"size_bytes": 18442
"sha256": "d11dfce5eddaf849075c62854a8526efaaa3bbd779d374e4c287de77f361caf7",
"size_bytes": 18440
}
],
"duration_ms": 16.66,
"duration_ms": 32.29,
"message": "",
"metrics": {
"entity_count": 1
@@ -234,11 +305,11 @@
"artifacts": [
{
"filename": "manifest.json",
"sha256": "38c2ee819b49b9c66b2311b952e856eff9e3a7565194cce2edea60a3211d233b",
"size_bytes": 3581
"sha256": "a74b2e96dbe7ea373717f26a8d26e58fe079a61370eb5d646f04a09f1b7cca2f",
"size_bytes": 5662
}
],
"duration_ms": 4380.8,
"duration_ms": 305.81,
"message": "",
"metrics": {
"score": {
@@ -255,16 +326,107 @@
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
},
{
"artifacts": [],
"duration_ms": 1149.77,
"message": "",
"metrics": {
"ranks": {
"BPOLY": 1,
"CHSPACE": 1,
"FILLET": 1,
"OVERKILL": 1,
"QSELECT": 1,
"WBLOCK": 1
},
"top_n": 3
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "tool_discovery"
},
{
"artifacts": [],
"duration_ms": 53.97,
"message": "",
"metrics": {
"advertised_tools": 2,
"catalog_tokens": 40305,
"catalog_tools": 149,
"ceiling": 2000,
"discovery_tokens": 356,
"reduction_x": 113.2,
"tokenizer": "ratio/v1 (estimate)"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "token_budget"
},
{
"artifacts": [],
"duration_ms": 10.6,
"message": "",
"metrics": {
"area_ignoring_islands": 400.0,
"expected_filled": 300.0,
"filled_area": 300.0,
"hatch_style": "outer",
"path_count": 2
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "hatch_islands"
},
{
"artifacts": [],
"duration_ms": 8.0,
"message": "",
"metrics": {
"bounding_box": 3,
"by_layer": 1,
"crossing": 2,
"polygon": 1,
"window": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "selection_filter"
},
{
"artifacts": [],
"duration_ms": 5.36,
"message": "",
"metrics": {
"boundary_agrees": true,
"fraction_lost_by_reading_points_back": 0.282,
"measured_area": 139.269908,
"true_area": 139.269908,
"vertex_shoelace_area": 100.0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "measure_from_handle"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"attempted": 15,
"coverage_percent": 100.0,
"passed": 10,
"passed": 15,
"score": 100.0,
"supported": 10,
"supported": 15,
"unsupported": 0
},
"timeout_seconds": 30.0
"timeout_seconds": 180.0
}
+12 -12
View File
@@ -3,8 +3,8 @@
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"schema_version": "perf/1.0",
"workloads": [
@@ -14,22 +14,22 @@
"entity_count": 2000
},
"metric": "entities_per_second",
"metric_value": 3148.0,
"wall_ms": 635.3,
"metric_value": 6343.1,
"wall_ms": 315.3,
"workload_id": "create_lines_2k"
},
{
"description": "Create 10,000 lines, export DXF, reopen and verify",
"detail": {
"build_ms": 10884.9,
"build_ms": 1574.1,
"entity_count": 10000,
"export_ms": 1439.2,
"file_bytes": 1369662,
"reopen_ms": 1607.5
"export_ms": 261.8,
"file_bytes": 1369663,
"reopen_ms": 320.2
},
"metric": "entities_per_second",
"metric_value": 717.8,
"wall_ms": 13931.6,
"metric_value": 4637.9,
"wall_ms": 2156.1,
"workload_id": "roundtrip_10k"
},
{
@@ -39,7 +39,7 @@
},
"metric": "entities_matched",
"metric_value": 2500.0,
"wall_ms": 3434.2,
"wall_ms": 890.8,
"workload_id": "region_query_10k"
},
{
@@ -49,7 +49,7 @@
},
"metric": "critique_issues",
"metric_value": 0.0,
"wall_ms": 315.9,
"wall_ms": 176.5,
"workload_id": "premium_pass"
}
]
+60 -4
View File
@@ -14,8 +14,9 @@ import time
from datetime import UTC, datetime
from pathlib import Path
from backends.capability import UnsupportedCapabilityError
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec, task_by_id
from benchmarks.tasks_v3 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
def _load_registry() -> dict[str, dict]:
@@ -78,6 +79,7 @@ async def run_tasks(
artifact_dir: str | Path,
*,
timeout: float = 30.0,
matrix: str = DEFAULT_MATRIX,
) -> dict:
destination = Path(artifact_dir).resolve()
destination.mkdir(parents=True, exist_ok=True)
@@ -90,6 +92,12 @@ async def run_tasks(
result = await asyncio.wait_for(adapter.run_task(task), timeout=timeout)
except TimeoutError:
result = TaskResult(task.task_id, "timeout", 0.0, f"Exceeded {timeout}s")
except UnsupportedCapabilityError as exc:
# "Your server got this wrong" and "your engine cannot reach
# this" are different findings about a competitor. The enum has
# carried `unsupported` since v2 for exactly this, but a
# refusal is still an exception and used to land as `fail`.
result = TaskResult(task.task_id, "unsupported", 0.0, f"[{exc.capability}] {exc}")
except Exception as exc:
result = TaskResult(task.task_id, "fail", 0.0, str(exc), stderr_summary=str(exc))
result.duration_ms = round((time.perf_counter() - started) * 1000, 2)
@@ -108,7 +116,11 @@ async def run_tasks(
weight_by_id[item.task_id] * item.score for item in results if item.status != "unsupported"
)
return {
# The *report* schema is unchanged — same keys, same enum — so the
# chart renderers still read v1.4's published files. `matrix` says
# which task set ran, which is the thing that actually moved.
"schema_version": "2.0",
"matrix": matrix,
"adapter": adapter.name,
"adapter_metadata": adapter.metadata(),
"git_sha": _git_sha(),
@@ -137,6 +149,12 @@ async def run_tasks(
def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="List the fixed benchmark tasks")
parser.add_argument(
"--matrix",
default=DEFAULT_MATRIX,
choices=sorted(MATRICES),
help="Task set to run. v2 stays addressable so a v1.4 report can be reproduced.",
)
parser.add_argument(
"--server",
default="autocad-mcp-pro",
@@ -148,14 +166,35 @@ def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser:
parser.add_argument("--artifact-dir", default="benchmarks/results/latest")
parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
parser.add_argument(
"--publish",
type=Path,
help=(
"Also write the report here with artifact paths reduced to filenames. "
"How results/published/*.json are produced — it was a manual edit before."
),
)
return parser
def _sanitize_for_publication(report: dict) -> dict:
"""Drop absolute paths from artifacts, keep the hash that identifies them.
A published report should not carry the run machine's directory layout;
the sha256 is what makes the artifact checkable, and that stays.
"""
published = json.loads(json.dumps(report))
for result in published["results"]:
for artifact in result["artifacts"]:
artifact.pop("path", None)
return published
def main() -> None:
registry = _load_registry()
args = _parser(registry).parse_args()
if args.list:
for task in TASKS_V2:
for task in MATRICES[args.matrix]:
print(f"{task.task_id}\t{task.category}\t{task.description}")
return
@@ -165,10 +204,27 @@ def main() -> None:
raise SystemExit(
f"{args.server} supports backends {supported_backends}, not {args.backend!r}"
)
tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2)
tasks = (
[task_by_id(task_id, args.matrix) for task_id in args.task]
if args.task
else list(MATRICES[args.matrix])
)
adapter = _make_adapter(entry, args.backend)
report = asyncio.run(run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout))
report = asyncio.run(
run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout, matrix=args.matrix)
)
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.publish:
args.publish.parent.mkdir(parents=True, exist_ok=True)
args.publish.write_text(
json.dumps(
_sanitize_for_publication(report), ensure_ascii=False, indent=2, sort_keys=True
)
+ "\n",
encoding="utf-8",
newline="\n",
)
print(f"Published: {args.publish}", file=sys.stderr)
print(rendered)
+84
View File
@@ -0,0 +1,84 @@
"""The v3 task matrix: the v2 ten, plus five the v2 ten could not see.
v2 scored this server 10/10, and that number was structurally uninformative:
every task in it exercised something the server was already built around. A
matrix that cannot fail is a matrix that measures nothing, so the five added
here were picked to cover capability the v2 set never touches — discovery,
idle token cost, hatch islands, selection semantics, and measuring an entity
that is already in the drawing.
Two of the five are renamed from the release plan, and the renames are the
point rather than bookkeeping:
``tool_discovery_bilingual`` -> ``tool_discovery``
The plan assumed a Turkish query normalizer. One was written and then
removed: this repository is English throughout, and the Turkish was an
inference from the author's chat language, not a product requirement. A
task named "bilingual" would advertise a capability that does not exist,
which is the class of claim this release spent four milestones deleting.
``measurement_massprops`` -> ``measure_from_handle``
No mass properties ship. The plan's eight-tool measurement family (F2) was
cut to one after measurement showed the repo already carried eight
``analysis_*`` tools; centroids and moments of inertia were not among the
survivors. What did ship is measuring a *handle* instead of a caller's
remembered coordinates, so that is what the task is called.
The v2 ids and weights are untouched. The published competitor reports were
run against the v2 matrix at v1.4 and are not re-scored here — those servers
have never been asked these five questions, and inventing a result for them
would be worse than the gap. The task-matrix chart renders them ``not_run``.
"""
from __future__ import annotations
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec
#: Added in v1.5.0. Every one of these can fail on this server.
NEW_TASKS_V3: tuple[TaskSpec, ...] = (
TaskSpec(
"tool_discovery",
"discovery",
"Resolve AutoCAD command names to tools through the search layer",
1.0,
),
TaskSpec(
"token_budget",
"efficiency",
"Keep the idle catalog under a fixed token ceiling",
1.0,
),
TaskSpec(
"hatch_islands",
"hatch",
"Hatch an area with an island and measure what it fills",
1.0,
),
TaskSpec(
"selection_filter",
"selection",
"Separate window from crossing, and a polygon from its bounding box",
1.0,
),
TaskSpec(
"measure_from_handle",
"measurement",
"Measure a curved boundary from its handle, bulges included",
1.0,
),
)
TASKS_V3: tuple[TaskSpec, ...] = TASKS_V2 + NEW_TASKS_V3
#: Runner-selectable task sets. v2 stays addressable so a v2 report can be
#: reproduced exactly rather than only described.
MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3}
DEFAULT_MATRIX = "v3"
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
for task in MATRICES[matrix]:
if task.task_id == task_id:
return task
raise KeyError(f"Unknown benchmark task: {task_id}")
File diff suppressed because it is too large Load Diff
+69 -5
View File
@@ -5,12 +5,48 @@ from __future__ import annotations
import os
from pathlib import Path
#: ProgID the live backend attaches to when ``CAD_PROGID`` is unset. The only
#: CAD this project is developed and tested against.
DEFAULT_CAD_PROGID = "AutoCAD.Application"
class Settings:
"""Server configuration loaded from environment variables."""
@property
def backend(self) -> str:
"""Which engine to run: ``ezdxf``, ``com`` or ``auto``.
Read live rather than captured in ``__init__``, and deliberately
read-only. ``_make_backend`` used to read ``os.environ`` itself while
this attribute had no production reader at all, so
``monkeypatch.setattr(settings, "backend", "ezdxf")`` looked like a
guard and was a no-op — on a machine with AutoCAD open that meant a test
attaching to the operator's live drawing. Late binding is what keeps
``monkeypatch.setenv`` working for the call sites that pin the headless
backend correctly; the missing setter is what makes the broken spelling
fail loudly instead of silently.
"""
return os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
@property
def cad_progid(self) -> str:
"""COM ProgID the live backend attaches to.
Read live and deliberately read-only for the same reasons as
``backend``: late binding keeps ``monkeypatch.setenv`` working, and the
missing setter makes a wrong spelling fail loudly rather than look like
a guard that does nothing.
Stripped but **not** lowercased — the value is echoed back in error
messages and ``system_status``, where ``gstarcad.application`` reads
like a typo we introduced. Never validated against what is actually
registered on the machine: an unrecognised ProgID is not "invalid", it
is unverifiable by us, and only AutoCAD is tested.
"""
return os.environ.get("CAD_PROGID", "").strip() or DEFAULT_CAD_PROGID
def __init__(self):
self.backend: str = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
self.log_level: str = os.environ.get("LOG_LEVEL", "INFO").upper().strip()
raw_paths = os.environ.get("ALLOWED_PATHS", "").strip()
@@ -37,18 +73,46 @@ class Settings:
# otherwise block the single-thread STA executor forever.
self.com_call_timeout: float = float(os.environ.get("COM_CALL_TIMEOUT", "60"))
# Per-call timeout for the headless ezdxf backend. Every ezdxf method
# runs under one document lock, so a single hung call (pathological DXF,
# a render that never returns) would otherwise hold that lock forever
# and deadlock every later tool call. Higher than com_call_timeout
# because matplotlib PDF/PNG rendering of a large drawing is legitimately
# slow. Set to 0 to disable the check.
self.ezdxf_call_timeout: float = float(os.environ.get("EZDXF_CALL_TIMEOUT", "120"))
# How many undo steps the headless backend keeps. 0 = off, which is the
# default because it is not free: ezdxf has no journal, so a step is a
# full DXF snapshot. Measured — 10 entities 3.1 ms / 19 KB, 1000
# entities 28 ms / 146 KB, 5000 entities 130 ms / 658 KB — and the cost
# is paid on every mutating call, so switching it on for a large drawing
# is a real trade the user should make deliberately rather than inherit.
self.ezdxf_undo_depth: int = max(0, int(os.environ.get("EZDXF_UNDO_DEPTH", "0")))
# HTTP transport: refuse non-loopback bind unless explicitly opted in.
self.allow_remote_http: bool = (
os.environ.get("ALLOW_REMOTE_HTTP", "false").lower().strip() == "true"
)
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
# Exposed tool profile: "full" (default, everything), "core" (hides the
# raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool
# drafting/inspection core for clients with tight tool caps). Invalid
# values fall back to "full" with a warning at startup.
# Exposed tool profile: "full" (default, everything) or "lean" (a
# curated ~47-tool drafting/inspection core for clients with tight tool
# caps). Invalid values fall back to "full" with a warning at startup;
# so does the "core" profile removed in v1.5.0 (see
# server.REMOVED_TOOL_PROFILES).
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip()
# Tool discovery: "off" (default) advertises the full catalog; "search"
# replaces it with a search tool plus a call_tool proxy (see
# discovery/transform.py and server._apply_discovery_mode).
#
# Default "off" deliberately. "search" is the intended v1.x default,
# but flipping it changes what every connected client sees on
# list_tools — a breaking wire-level change that needs its own
# migration note and README pass, neither of which ships in v1.5.0.
# Invalid values fall back to "off" with a warning at startup.
self.discovery_mode: str = os.environ.get("DISCOVERY_MODE", "off").lower().strip()
# Opt-in 3D solids (COM backend only). Disabled by default because the
# headless backend cannot produce ACIS solids and live 3D modifies real
# documents; enable deliberately.
+7
View File
@@ -0,0 +1,7 @@
"""Tool-discovery layer: searchable tool metadata and the search transform.
Kept deliberately free of ``fastmcp`` and ``ezdxf`` imports at module scope so
the discovery data stays cheap to import and testable in isolation.
"""
__all__: list[str] = []
+1342
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
"""Compact one-line cards for tool-search results.
Passed to a search transform as ``search_result_serializer``. FastMCP's default
(``serialize_tools_for_output_json``) re-emits each hit's full ``list_tools``
entry, JSON Schema and all — for this server that is roughly 1.5 kB per hit,
and a five-hit answer costs more than the crowded catalog the search was meant
to replace.
One line per hit::
entity_fillet (write) | Round a corner between two entities. | req: handle_a, handle_b, radius | acad: FILLET, F
``req:`` lists *required* parameters only. Optional parameters and types are
deliberately absent — the honest cost of this format is that a caller must
either already know the optional arguments or read the error from a failed
call. That trade is worth it at five hits per search; it would not be at one.
Scope note: the compact payload is a real saving but it is **not** what makes
this discovery layer better than stock. Stock BM25 with this same serializer
gets the same size win. The differentiators are the alias corpus (which lets a
query find anything at all) and the risk filter — not bytes.
"""
from __future__ import annotations
from collections.abc import Sequence
from fastmcp.tools.base import Tool
from discovery.aliases import aliases_for
__all__ = ["MAX_SUMMARY_CHARS", "serialize_search_results"]
# Long enough for every first docstring line in the live catalog (measured max
# 96 characters), short enough that a rambling one cannot dominate the payload.
MAX_SUMMARY_CHARS = 110
# Beyond three, AutoCAD command names stop disambiguating and start padding.
MAX_ACAD_COMMANDS = 3
EMPTY_RESULT = "No tools matched the query."
def _risk(tool: Tool) -> str:
"""Same derivation as ``discovery.transform.tool_risk``, inlined.
Imported the other way round would make ``discovery.serialize`` depend on
the transform, and the transform already depends on this module for its
default serializer.
"""
annotations = tool.annotations
if annotations is not None:
if annotations.destructiveHint:
return "destructive"
if annotations.readOnlyHint:
return "read"
return "write"
def _truncate(text: str) -> str:
if len(text) <= MAX_SUMMARY_CHARS:
return text
clipped = text[:MAX_SUMMARY_CHARS].rsplit(" ", 1)[0].rstrip(" ,;:.")
return f"{clipped}..."
def _summary(tool: Tool) -> str:
"""One line describing the tool.
Prefers the hand-written ``@cad_tool`` summary in the MCP ``meta`` channel;
falls back to the first line of the docstring, which for this server is
already written as a standalone sentence.
"""
cad = (tool.meta or {}).get("cad")
if isinstance(cad, dict) and cad.get("summary"):
return _truncate(str(cad["summary"]).strip())
for line in (tool.description or "").strip().splitlines():
if line.strip():
return _truncate(line.strip())
return "(no description)"
def _required(tool: Tool) -> str:
schema = tool.parameters or {}
required = schema.get("required")
if not isinstance(required, list) or not required:
return "(none)"
return ", ".join(str(name) for name in required)
def _acad(tool: Tool) -> str:
record = aliases_for(tool.name)
if record is None or not record.acad:
return ""
return ", ".join(record.acad[:MAX_ACAD_COMMANDS])
def _card(tool: Tool) -> str:
parts = [
f"{tool.name} ({_risk(tool)})",
_summary(tool),
f"req: {_required(tool)}",
]
acad = _acad(tool)
if acad:
parts.append(f"acad: {acad}")
return " | ".join(parts)
def serialize_search_results(tools: Sequence[Tool]) -> str:
"""Render search hits as one compact card per line."""
if not tools:
return EMPTY_RESULT
return "\n".join(_card(tool) for tool in tools)
+402
View File
@@ -0,0 +1,402 @@
"""Tool-search transform that speaks AutoCAD.
Subclasses FastMCP's :class:`BM25SearchTransform` rather than replacing it, so
the ``call_tool`` proxy, the auth-filtered catalog accessor
(``_get_visible_tools``) and the re-entrancy bypass all come for free. Two
things are added on top, and only two:
**1. Vocabulary.** Stock BM25 indexes tool name + description + parameter names
+ parameter descriptions. Measured against this server's live catalog, that
leaves ``BPOLY``, ``PEDIT``, ``WBLOCK``, ``OVERKILL``, ``LAYTRANS``,
``MATCHPROP`` and ``QSELECT`` at df=0 — a search for any of them returns
nothing at all. :func:`discovery.aliases.alias_text` supplies the missing
AutoCAD command names and shop-floor synonyms; this module folds them into the
indexed document. (``FILLET`` is *not* in that list: it is already indexed,
because the tool is literally named ``entity_fillet``.)
**2. Two additive boosts**, so an exact alias match outranks an incidental
description word. See :data:`ACAD_MATCH_BOOST` / :data:`SYNONYM_MATCH_BOOST`.
BM25 itself is not reimplemented: the index object the base class already
built is reused, only with different documents.
"""
from __future__ import annotations
import hashlib
import re
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Annotated, Any, Literal
from fastmcp.server.context import Context
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.transforms.search.base import SearchResultSerializer
from fastmcp.server.transforms.search.base import (
_extract_searchable_text as _stock_searchable_text,
)
from fastmcp.tools.base import Tool
from pydantic import Field
from discovery.aliases import alias_text, aliases_for
from discovery.serialize import serialize_search_results
__all__ = [
"ACAD_MATCH_BOOST",
"MAX_SEARCH_LIMIT",
"RISK_CEILINGS",
"SYNONYM_MATCH_BOOST",
"CadSearchTransform",
"SearchOutcome",
"tool_risk",
]
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
#
# ``_BM25Index.query()`` hands back a *ranking*, not scores, so the two signals
# are fused on a rank scale instead of a raw-score scale: BM25's nth hit is
# worth ``1 / (1 + n)``. That makes the boosts below trivially explainable in
# the only units that matter here — positions.
#
# ACAD_MATCH_BOOST the query names an AutoCAD command this tool owns,
# *written as a command* — see _names_a_command below.
# Worth exactly as much as being BM25's top hit, so a
# command match pulls a tool past any number of tools
# that merely share a description word.
# SYNONYM_MATCH_BOOST the query contains one of the tool's synonym phrases
# verbatim ("bill of materials", "round the corner").
# Worth BM25's second position: strong, but it loses to
# an outright command match.
#
# That is the whole scorer. Both boosts are flat and apply at most once, so a
# tool with a long synonym list cannot out-shout a tool with a short one.
ACAD_MATCH_BOOST = 1.0
SYNONYM_MATCH_BOOST = 0.5
_WORD_RE = re.compile(r"[a-z0-9]+")
_RAW_WORD_RE = re.compile(r"[A-Za-z0-9]+")
# Dropped from both sides before phrase matching, so the synonym "delete a
# layer" still matches "delete the layer called GEOMETRY". Articles only —
# a general stop-word list would start matching phrases that mean other things.
_ARTICLES = frozenset({"a", "an", "the"})
def _words(text: str) -> list[str]:
"""Lowercase word list. Unlike BM25's tokenizer, single characters survive.
``_tokenize`` drops ``len <= 1`` tokens, which is right for an inverted
index and wrong for phrase matching: dropping the "a" out of "round the
corner" would silently change which phrases can match.
"""
return _WORD_RE.findall(text.lower())
def _phrase_words(text: str) -> list[str]:
return [word for word in _words(text) if word not in _ARTICLES]
def _names_a_command(query: str, command: str) -> bool:
"""True when ``query`` uses ``command`` as a command, not as English.
Several AutoCAD command names are also ordinary nouns — LAYER, TABLE, LINE,
POINT, TEXT, BOX, ARRAY. Boosting on a bare lowercase "layer" put
``layer_create`` / ``layer_modify`` above ``layer_delete`` for "delete the
layer called GEOMETRY", which is exactly backwards. A drafter reaching for
the command types it in capitals, or types nothing else:
"BPOLY", "trace it with BPOLY" -> command
"bpoly" -> command (nothing else in the query)
"delete the layer ..." -> English; BM25 handles it
"""
words = _words(query)
if len(words) == 1 and words[0] == command.lower():
return True
return command in _RAW_WORD_RE.findall(query)
def _contains_phrase(haystack: Sequence[str], needle: Sequence[str]) -> bool:
"""True when ``needle`` appears as a contiguous run of words in ``haystack``.
Word-level rather than substring, so the synonym "line" does not match
"polyline" or "linear".
"""
n = len(needle)
if not n or n > len(haystack):
return False
first = needle[0]
for i, word in enumerate(haystack):
if word == first and list(haystack[i : i + n]) == list(needle):
return True
return False
# ---------------------------------------------------------------------------
# Risk
# ---------------------------------------------------------------------------
#
# Derived from the MCP annotations each tool already carries, so there is no
# second risk table to drift out of step with the registrations. The ceiling a
# caller may ask for, most permissive first:
#
# any everything (default — a search must not quietly narrow itself)
# write hide tools that delete or overwrite (entity_delete_many, layer_delete,
# transaction_rollback, drawing_finalize, ...)
# read only read-only query tools — the right ceiling for "how many ...?"
RISK_CEILINGS = ("any", "write", "read")
MAX_SEARCH_LIMIT = 25
def tool_risk(tool: Tool) -> str:
"""``"read"`` | ``"write"`` | ``"destructive"`` for one tool.
``destructiveHint`` wins over ``readOnlyHint``: no tool in this catalog
sets both, and if one ever did, the safe reading is the destructive one.
Tools that annotate neither are treated as ``"write"`` — the honest default
for an unannotated tool on a server whose job is mutating drawings.
"""
annotations = tool.annotations
if annotations is not None:
if annotations.destructiveHint:
return "destructive"
if annotations.readOnlyHint:
return "read"
return "write"
def _risk_allowed(ceiling: str, level: str) -> bool:
if ceiling == "any":
return True
if ceiling == "write":
return level != "destructive"
return level == "read"
@dataclass(frozen=True, slots=True)
class SearchOutcome:
"""One search: the tools shown, plus what the risk ceiling took away.
``hidden`` holds only the tools the caller would otherwise have *seen* —
blocked hits inside the unfiltered top-``limit`` — not every blocked tool
in the catalog. That keeps the notice a fact about this answer.
"""
hits: tuple[Tool, ...] = ()
hidden: tuple[Tool, ...] = ()
risk: str = "any"
def notice(self) -> str:
"""One line naming what the risk ceiling removed, or ``""``.
A filter that silently halves a mixed intent ("list the layers then
delete GEOMETRY") is worse than no filter: the caller cannot tell the
difference between "no such tool" and "not shown to you".
"""
if not self.hidden:
return ""
names = ", ".join(tool.name for tool in self.hidden)
return (
f"Risk filter: {len(self.hidden)} higher-risk match(es) hidden by "
f'risk="{self.risk}" ({names}). Re-run with risk="any" to include them.'
)
class CadSearchTransform(BM25SearchTransform):
"""BM25 tool search with the AutoCAD alias corpus folded into the index.
Args:
max_results: Default number of tools returned per search.
bm25_b: BM25 length-normalisation, left at the 0.75 textbook default —
the value every measured number in this module was obtained with.
Lowering it is defensible in principle (these "documents" are
metadata records of deliberately unequal verbosity, and a tool with
six synonyms is not a broader tool than one with two), but it was
not what shipped, so re-measure the golden set before changing it.
"""
def __init__(
self,
*,
max_results: int = 5,
bm25_b: float = 0.75,
always_visible: list[str] | None = None,
search_tool_name: str = "search_tools",
call_tool_name: str = "call_tool",
search_result_serializer: SearchResultSerializer | None = None,
) -> None:
super().__init__(
max_results=max_results,
always_visible=always_visible,
search_tool_name=search_tool_name,
call_tool_name=call_tool_name,
search_result_serializer=search_result_serializer or serialize_search_results,
)
self._bm25_k1 = self._index.k1
self._bm25_b = bm25_b
# ------------------------------------------------------------------
# Indexed document
# ------------------------------------------------------------------
def _extract_searchable_text(self, tool: Tool) -> str:
"""Stock searchable text plus this tool's alias vocabulary.
The narrowest available hook: upstream's ``_extract_searchable_text``
is a module-level function, not a method, so it cannot be overridden in
place — it is called and extended instead. Tools with no alias record
(anything registered outside this server) keep the stock text
unchanged.
"""
extra = alias_text(tool.name)
stock = _stock_searchable_text(tool)
return f"{stock} {extra}" if extra else stock
# ------------------------------------------------------------------
# Index lifecycle
# ------------------------------------------------------------------
def _ensure_index(self, tools: Sequence[Tool]) -> None:
"""Rebuild the index when the catalog's indexed text changes.
The hash is order-sensitive on purpose: results are mapped back to
tools positionally, so a reordered catalog with identical text is still
a different index.
No lock: this and its only caller (:meth:`rank`) are synchronous and
await nothing, so two concurrent searches on one event loop cannot
interleave a rebuild with a query.
"""
documents = [self._extract_searchable_text(t) for t in tools]
digest = hashlib.sha256("\x00".join(documents).encode()).hexdigest()
if digest == self._last_hash:
return
# Built via type() rather than importing the private _BM25Index name:
# the base class already owns an instance, and reusing its class keeps
# this from depending on a private module path.
index = type(self._index)(self._bm25_k1, self._bm25_b)
index.build(documents)
self._index = index
self._indexed_tools = tuple(tools)
self._last_hash = digest
# ------------------------------------------------------------------
# Ranking
# ------------------------------------------------------------------
def _alias_boost(self, tool: Tool, query: str, phrase_words: Sequence[str]) -> float:
record = aliases_for(tool.name)
if record is None:
return 0.0
boost = 0.0
if any(_names_a_command(query, command) for command in record.acad):
boost += ACAD_MATCH_BOOST
if any(_contains_phrase(phrase_words, _phrase_words(phrase)) for phrase in record.synonyms):
boost += SYNONYM_MATCH_BOOST
return boost
def rank(
self,
tools: Sequence[Tool],
query: str,
*,
limit: int | None = None,
risk: str = "any",
) -> SearchOutcome:
"""Rank ``tools`` against ``query``. Pure — no Context, no I/O."""
if risk not in RISK_CEILINGS:
raise ValueError(f"risk must be one of {RISK_CEILINGS}, got {risk!r}")
cutoff = self._max_results if limit is None else max(1, min(limit, MAX_SEARCH_LIMIT))
tools = list(tools)
if not tools:
return SearchOutcome(risk=risk)
self._ensure_index(tools)
ranked = self._index.query(query, len(tools))
bm25_points = {position: 1.0 / (1.0 + rank) for rank, position in enumerate(ranked)}
phrase_words = _phrase_words(query)
scored: list[tuple[float, str, Tool]] = []
for position, tool in enumerate(tools):
score = bm25_points.get(position, 0.0) + self._alias_boost(tool, query, phrase_words)
if score > 0.0:
scored.append((score, tool.name, tool))
scored.sort(key=lambda row: (-row[0], row[1]))
matches = [tool for _, _, tool in scored]
# Filter before truncating, so a blocked hit frees its slot for the
# next safe tool instead of just shortening the answer.
allowed = [tool for tool in matches if _risk_allowed(risk, tool_risk(tool))]
hidden = tuple(
tool for tool in matches[:cutoff] if not _risk_allowed(risk, tool_risk(tool))
)
return SearchOutcome(hits=tuple(allowed[:cutoff]), hidden=hidden, risk=risk)
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
return list(self.rank(tools, query).hits)
# ------------------------------------------------------------------
# The synthetic search tool
# ------------------------------------------------------------------
async def render(self, outcome: SearchOutcome) -> Any:
"""Serialize an outcome, keeping the risk notice on its own line.
With the default (string) serializer the notice is appended as a
trailing line. A serializer that returns structured data instead gets
the notice as a sibling key, because there is no line to append to.
"""
rendered = await self._render_results(outcome.hits)
notice = outcome.notice()
if not notice:
return rendered
if isinstance(rendered, str):
return f"{rendered}\n\n{notice}"
return {"results": rendered, "notice": notice}
def _make_search_tool(self) -> Tool:
transform = self
default_limit = self._max_results
async def search_tools(
query: Annotated[
str,
Field(
description=(
"What you want to do, in plain English or as an AutoCAD "
"command name (FILLET, BPOLY, QSELECT, DLI)."
)
),
],
limit: Annotated[
int,
Field(description=(f"How many tools to return. Clamped to 1..{MAX_SEARCH_LIMIT}.")),
] = default_limit,
risk: Annotated[
Literal["any", "write", "read"],
Field(
description=(
"Highest risk level to return. 'any' (default) is everything; "
"'write' hides tools that delete or overwrite; 'read' returns "
"only read-only query tools — use it for questions like 'how "
"many ...?'. Anything filtered out is reported in the result."
)
),
] = "any",
ctx: Context = None, # type: ignore[assignment]
) -> Any:
"""Find the AutoCAD tools that do a job.
Accepts AutoCAD command names as well as English. Returns one
compact line per tool: name, risk level, what it does, its required
arguments and the AutoCAD command it corresponds to. Call a result
with call_tool.
"""
visible = await transform._get_visible_tools(ctx)
outcome = transform.rank(visible, query, limit=limit, risk=risk)
return await transform.render(outcome)
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 90 KiB

@@ -448,10 +448,7 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
labels = [item["repository"] for item in projects]
scores = [item["score"] for item in projects]
grades = [item["evidence_grade"] for item in projects]
colors = [
"#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781"
for label in labels
]
colors = ["#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781" for label in labels]
fig, ax = plt.subplots(figsize=(12, 6.4), constrained_layout=True)
fig.patch.set_facecolor("#f6f8fa")
File diff suppressed because it is too large Load Diff
+230 -12
View File
@@ -10,14 +10,25 @@ Scope (deliberate, documented):
- Nominal sizes over 1 mm up to and including 500 mm.
- IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors).
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p.
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS).
- Transition/interference HOLE letters (K, M, N, P) require the ISO 286
delta-rule and sub-stepped r/s tables; they are intentionally excluded and
raise a ValueError naming the supported set.
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p, and the interference
shafts r, s, t, u (t from 24 mm, where ISO 286 begins defining it).
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS), and
K, M, N, P via the ISO 286 delta rule ``ES = -ei + Δ``, ``Δ = IT(n) −
IT(n−1)``. That is a rule over tables already here, not new table data, which
is why it can be pinned against published values rather than trusted.
r/s/t/u *are* new table data, sub-stepped more finely than the main size steps,
so they are cross-checked in the tests against ISO 286-1's own derivation
formulae — an independent source. r reproduces as the geometric mean of p and s
on 24/24 steps (with p pinned before r existed); s, t and u track their
IT7-based formulae within 1 µm on every step from 18 mm up, and the five places
where the standard's rounding puts a published value 2-3 µm off its own formula
are listed by name in the tests rather than hidden.
This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6,
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...).
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...), the hole-basis
transition and interference pairs (K7/h6, M7/h6, N7/h6, P7/h6), and the heavy
press/drive fits (H7/r6, H7/s6, H7/t6, H7/u6).
"""
from __future__ import annotations
@@ -78,10 +89,206 @@ _SHAFT_EI: dict[str, tuple[int, ...]] = {
"p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68),
}
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p")
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS")
# Interference shafts r/s/t/u are sub-stepped more finely than the main steps:
# ISO 286 splits >18-30 into 18-24/24-30, >30-50 into 30-40/40-50, and every step
# above 50 mm in two or three. Flattening them onto the main steps is a silent
# error of tens of microns at the sizes these fits are actually used at.
_FINE_SIZE_STEPS: tuple[tuple[float, float], ...] = (
(1, 3),
(3, 6),
(6, 10),
(10, 18),
(18, 24),
(24, 30),
(30, 40),
(40, 50),
(50, 65),
(65, 80),
(80, 100),
(100, 120),
(120, 140),
(140, 160),
(160, 180),
(180, 200),
(200, 225),
(225, 250),
(250, 280),
(280, 315),
(315, 355),
(355, 400),
(400, 450),
(450, 500),
)
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$")
# Fundamental deviations ei (µm) for the interference shafts, one per fine step.
#
# PROVENANCE — this is table data, not a rule, so it is cross-checked in
# tests/test_fits.py against ISO 286-1's own derivation formulae rather than
# trusted on its own:
# r = geometric mean of p and s -> 24/24 steps agree within 1 µm, and p
# was already pinned before r existed
# s (>50 mm) = IT7 + 0.40*D -> agrees within 1 µm
# s (<=50 mm) = IT8 + 0..4 -> monotone inside the standard's band
# t = IT7 + 0.63*D -> 17/19 within 1 µm
# u = IT7 + 1.00*D -> 17/20 within 1 µm
# D is the geometric mean of the step bounds. The handful of steps where the
# standard's rounding puts the published value 2-3 µm off its own formula are
# listed by name in KNOWN_FORMULA_DIVERGENCES, so a reader holding ISO 286 has
# five numbers to check rather than ninety-five.
#
# t is undefined below 24 mm in the standard and is None there — refused, not
# extrapolated.
_SHAFT_EI_FINE: dict[str, tuple[int | None, ...]] = {
"r": (
10,
15,
19,
23,
28,
28,
34,
34,
41,
43,
51,
54,
63,
65,
68,
77,
80,
84,
94,
98,
108,
114,
126,
132,
),
"s": (
14,
19,
23,
28,
35,
35,
43,
43,
53,
59,
71,
79,
92,
100,
108,
122,
130,
140,
158,
170,
190,
208,
232,
252,
),
"t": (
None,
None,
None,
None,
None,
41,
48,
54,
66,
75,
91,
104,
122,
134,
146,
166,
180,
196,
218,
240,
268,
294,
330,
360,
),
"u": (
18,
23,
28,
33,
41,
48,
60,
70,
87,
102,
124,
144,
170,
190,
210,
236,
258,
284,
315,
350,
390,
435,
490,
540,
),
}
def _fine_size_index(nominal_mm: float) -> int:
for index, (over, up_to) in enumerate(_FINE_SIZE_STEPS):
if over < nominal_mm <= up_to:
return index
raise ValueError( # pragma: no cover - _size_index rejects out-of-range first
f"No ISO 286 sub-step covers {nominal_mm} mm"
)
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p", "r", "s", "t", "u")
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS", "K", "M", "N", "P")
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[K-Pk-p]|[r-ur-u])(\d{1,2})\s*$")
#: Holes derived from the same-letter shaft by the ISO 286 delta rule. No new
#: table data: ES = -ei + Δ, with Δ = IT(n) − IT(n−1) taken from _IT_TABLE.
_DELTA_RULE_HOLES = ("K", "M", "N", "P")
#: The highest IT grade the delta still applies at. Above it ISO 286 sets Δ = 0,
#: so the hole is the exact mirror of the shaft.
_DELTA_MAX_GRADE = {"K": 8, "M": 8, "N": 8, "P": 7}
def _delta_rule_es(letter: str, grade: int, size_index: int, it_um: int) -> float:
"""Upper deviation of a transition/interference hole (ISO 286 delta rule).
``ES = -ei + Δ`` where ``ei`` is the same-letter shaft's fundamental
deviation and ``Δ = IT(n) − IT(n−1)``. Both inputs are already authored in
this module, so this adds a *rule* rather than a table — and the rule is
pinned against published values in the tests, which is what makes it
checkable at all.
"""
if grade <= _IT_GRADES[0]:
raise ValueError(
f"Fit {letter}{grade}: the ISO 286 delta rule needs IT{grade - 1}, which is "
f"below the authored IT{_IT_GRADES[0]}-IT{_IT_GRADES[-1]} range. Use IT5 or "
"coarser for a transition/interference hole."
)
ei = float(_SHAFT_EI[letter.lower()][size_index])
if grade > _DELTA_MAX_GRADE[letter]:
return -ei # ISO 286: above this grade the delta is zero
previous_it = _IT_TABLE[size_index][_IT_GRADES.index(grade) - 1]
return -ei + float(it_um - previous_it)
@dataclass(frozen=True)
@@ -163,12 +370,23 @@ def fit_lookup(code: str, nominal_mm: float) -> FitDeviation:
else:
ei = float(_SHAFT_EI[letter][index])
upper_um, lower_um = ei + it_um, ei
elif letter in _DELTA_RULE_HOLES: # K, M, N, P — transition/interference holes
es = _delta_rule_es(letter, grade, index, it_um)
upper_um, lower_um = es, es - it_um
elif letter in _SHAFT_EI_FINE: # r, s, t, u — interference shafts
ei_um = _SHAFT_EI_FINE[letter][_fine_size_index(float(nominal_mm))]
if ei_um is None:
raise ValueError(
f"Fit {letter}{grade}: ISO 286 does not define shaft {letter!r} below "
f"24 mm, and {nominal_mm} mm is under it. Use s (defined from 1 mm) "
"for a lighter interference fit, or explicit tol_upper/tol_lower."
)
ei = float(ei_um)
upper_um, lower_um = ei + it_um, ei
else:
raise ValueError(
f"Fit letter {letter!r} is outside the authored scope. Supported: "
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. "
"Transition/interference hole letters (K, M, N, P) need the ISO 286 "
"delta-rule and are not authored yet."
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}."
)
return FitDeviation(
+149
View File
@@ -0,0 +1,149 @@
"""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)))
+10 -5
View File
@@ -108,13 +108,18 @@ def preflight_drawing(
questions: list[PreflightQuestion] = []
required = (
("units", "MISSING_UNITS", "Çizim birimi nedir?", ["mm", "inch"]),
("part_type", "MISSING_PART_TYPE", "Üretilecek parça/çizim türü nedir?", []),
("dimensions", "MISSING_DIMENSIONS", "Ana üretim ölçüleri nelerdir?", []),
("units", "MISSING_UNITS", "What drawing units should be used?", ["mm", "inch"]),
(
"part_type",
"MISSING_PART_TYPE",
"What kind of part or drawing is being produced?",
[],
),
("dimensions", "MISSING_DIMENSIONS", "What are the main production dimensions?", []),
(
"tolerance_policy",
"MISSING_TOLERANCE_POLICY",
"Hangi tolerans politikası kullanılmalı?",
"Which tolerance policy should apply?",
["ISO 2768-m", "explicit", "none"],
),
)
@@ -138,7 +143,7 @@ def preflight_drawing(
"CONFLICTING_REQUIREMENT",
field_name,
[seen_constraints[field_name], value],
f"{field_name} için birbiriyle çelişen değerler verildi.",
f"Conflicting values were supplied for {field_name}.",
)
)
else:
+42 -1
View File
@@ -59,11 +59,50 @@ async def apply_iso_a3_titleblock(
*,
metadata: TitleBlockMetadata,
origin: tuple[float, float] = (0.0, 0.0),
layout: str | None = None,
) -> dict:
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet)."""
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet).
With `layout`, the sheet is drawn on that paper-space layout — which is
where a title block belongs, since the border frames the printed sheet
rather than the model. The caller's current space is restored afterwards, so
asking for a sheet border does not silently move them onto the sheet.
Without `layout` it draws in whatever space is current, as it always did.
"""
ox, oy = float(origin[0]), float(origin[1])
previous_space = "Model"
if layout is not None:
listing = await backend.layout_list()
names = {str(entry) for entry in (listing.get("layouts") or [])}
if layout not in names:
raise RuntimeError(
f"apply_iso_a3_titleblock: layout {layout!r} does not exist "
f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with "
"layout_create first."
)
previous_space = getattr(backend, "_current_space", "Model")
switched = await backend.layout_set_current(layout)
if not switched.get("ok", True):
raise RuntimeError(f"apply_iso_a3_titleblock: {switched.get('error')}")
try:
return await _draw_titleblock(backend, metadata=metadata, ox=ox, oy=oy, layout=layout)
finally:
if layout is not None:
await backend.layout_set_current(previous_space)
async def _draw_titleblock(
backend: AutoCADBackend,
*,
metadata: TitleBlockMetadata,
ox: float,
oy: float,
layout: str | None,
) -> dict:
# ---- outer + inner borders -------------------------------------------------
outer_pts = [
[ox, oy],
@@ -263,6 +302,8 @@ async def apply_iso_a3_titleblock(
)
return {
"ok": True,
"layout": layout or "Model",
"outer_border": outer.handle,
"inner_border": inner.handle,
"titleblock_lines": titleblock_lines,
+59 -8
View File
@@ -1,6 +1,6 @@
[project]
name = "autocad-mcp-pro"
version = "1.4.0"
version = "1.5.0"
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
readme = "README.md"
requires-python = ">=3.11"
@@ -32,9 +32,31 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"fastmcp>=3.0",
"ezdxf>=1.3",
"pydantic>=2.0",
# These ranges are what an *installer* gets from PyPI, so they stay ranges:
# a library that pins its dependencies exactly is unco-installable. They are
# deliberately narrow because a range is all a `pip install` has to protect
# it. The floor of each range is the version this release is tested against.
#
# What CI and contributors get is different and exact: uv.lock pins the
# whole transitive graph (including the dev tools) to the set the suite was
# last run against, and every locked lane installs with `--locked` so an
# untested upstream cannot appear silently. Ranges guard users; the lock
# guards us. Regenerate the lock with `uv lock` whenever these change.
#
# Every ceiling is the next major. A tighter cap (e.g. `<3.5`) would block a
# user whose environment already holds a newer minor from installing us at
# all, and it buys nothing here: the lock is what protects our own runs, and
# `deps-floating.yml` runs the suite against unpinned latest on a schedule,
# so a breaking upstream minor surfaces as a failing scheduled job rather
# than as a silent CI-green-but-users-broken state.
#
# fastmcp 3.x moves fast and `fastmcp` is now a thin meta-package
# (Requires-Dist: fastmcp-slim[client,server]), so a new minor can reshape
# the API we build on. If deps-floating goes red, add a targeted `!=` for
# the bad version rather than lowering this ceiling.
"fastmcp>=3.4.5,<4",
"ezdxf>=1.4,<2",
"pydantic>=2.0,<3",
]
[project.urls]
@@ -44,11 +66,20 @@ Issues = "https://github.com/U-C4N/Autocad-MCP/issues"
Changelog = "https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md"
[project.optional-dependencies]
# Rendering needs an extra — the core install cannot draw pixels.
# ezdxf.addons.drawing.frontend does an unconditional top-level `import
# PIL.Image`, so Pillow is required by *every* ezdxf render path (PNG
# screenshot and PDF export alike), not only by the COM window-capture path
# it is listed under below. `pip install autocad-mcp-pro` on its own therefore
# renders nothing. Install `[pdf]` (or `[full]`) for rendering on any platform:
# Pillow comes in transitively as a matplotlib dependency.
com = [
"pywin32>=306",
# Win32 window capture for the live-AutoCAD screenshot path.
"Pillow>=10.0",
]
pdf = [
# Also satisfies ezdxf's hard Pillow requirement, transitively.
"matplotlib>=3.8",
]
full = [
@@ -63,12 +94,24 @@ autocad-mcp = "server:main"
# without a --from indirection (MCP registry / uvx convention).
autocad-mcp-pro = "server:main"
[tool.uv]
dev-dependencies = [
# PEP 735 dependency group (was [tool.uv] dev-dependencies, which uv now warns
# is deprecated). The standard spelling is understood by uv *and* by pip >=25.1
# (`pip install --group dev`), so the dev toolchain has one definition whether
# or not you use uv.
#
# These are the exact gates CI runs, and uv.lock freezes them: `uv sync
# --locked` reproduces this list bit-for-bit, so a lint or test job can no
# longer be broken by a tool release nobody chose.
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-cov>=5.0",
"ruff>=0.8",
# Exact pin *and* locked: the formatter's output changes between minors, so
# a floating ruff makes `ruff format --check .` fail on unchanged code. This
# is now the only place the version is written — the workflows install ruff
# from the lock rather than restating it.
"ruff==0.16.1",
]
[build-system]
@@ -78,7 +121,15 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
# Flat layout: no directory matches the project name, so hatchling needs an
# explicit file selection (GH issue #2). server.py imports config + security.
only-include = ["server.py", "config.py", "security.py", "version.py", "backends", "engineering"]
only-include = [
"server.py",
"config.py",
"security.py",
"version.py",
"backends",
"engineering",
"discovery",
]
[tool.ruff]
line-length = 100
+258
View File
@@ -0,0 +1,258 @@
"""Generate ``docs/tool-inventory.json`` — a machine-readable snapshot of the surface.
Everything here is derived from the **live component registry**, never by
parsing server.py's text. That is the whole point: the release gates used to
count the literal string ``@mcp.tool(`` in server.py, which made that string
load-bearing anywhere in the file (writing it in a docstring inflated the
count, so the one docstring that needed the example had to spell it without its
parentheses). A snapshot taken from the registry cannot be fooled by prose, and
it carries far more than a count — every tool's group, cost and summary, plus
the resource and prompt inventories.
The snapshot is read back by ``tests/test_tool_inventory.py`` (drift gate) and
by ``tests/test_release_consistency.py`` (README release-snapshot counts).
Usage::
python scripts/generate_tool_inventory.py # write the file
python scripts/generate_tool_inventory.py --check # exit 1 if it drifted
``--check`` is the same comparison the test makes, for use outside pytest.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import tomllib
from pathlib import Path
from typing import Any
# Make the project root importable when this script is run from anywhere.
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
#: Where the generated snapshot lives (docs, not a shipped package file — it is
#: deliberately absent from the wheel's ``only-include`` and from the Docker
#: image, so the Dockerfile/wheel parity gate is unaffected).
INVENTORY_PATH = _PROJECT_ROOT / "docs" / "tool-inventory.json"
#: Path as written in messages, so a failure reads the same on every platform.
INVENTORY_REL_PATH = "docs/tool-inventory.json"
#: The one string every failure message must contain.
REGENERATE_COMMAND = "python scripts/generate_tool_inventory.py"
#: Bump when the document's shape changes (not when its contents change).
SCHEMA_VERSION = 1
_FILE_COMMENT = (
"GENERATED FILE - do not edit by hand. Derived from the live FastMCP "
f"component registry; regenerate with `{REGENERATE_COMMAND}`."
)
# Ordered so a diff of the annotations block is stable.
_ANNOTATION_FIELDS = (
"title",
"readOnlyHint",
"destructiveHint",
"idempotentHint",
"openWorldHint",
)
def _pyproject_version() -> str:
"""The version every other release gate keys off (pyproject is the source)."""
with (_PROJECT_ROOT / "pyproject.toml").open("rb") as stream:
return str(tomllib.load(stream)["project"]["version"])
def _components() -> dict[str, Any]:
"""The untransformed local component registry — tools, resources, prompts.
``server._registered_tools()`` reads the same mapping for the tool half; this
is the resource/prompt half of it. Reading the registry (rather than
``list_resources`` / ``list_prompts``) keeps the snapshot below every
transform layer, so what gets committed is what is *registered*, not what
some discovery mode happens to advertise.
"""
import server
return server.mcp._local_provider._components
def _annotations(tool: Any) -> dict[str, Any]:
"""The tool's own MCP annotations, minus the fields it left unset."""
annotations = getattr(tool, "annotations", None)
if annotations is None:
return {}
return {
field: getattr(annotations, field)
for field in _ANNOTATION_FIELDS
if getattr(annotations, field, None) is not None
}
def _tool_rows(tools: list[Any], groups: dict[str, list[str]]) -> list[dict[str, Any]]:
"""One row per registered tool, sorted by name.
``cost`` and ``summary`` come from the ``meta["cad"]`` card that ``@cad_tool``
writes; ``group`` from ``server._tool_groups()``, i.e. from the tool's own
tags. Aliases are deliberately *not* copied here — ``discovery/aliases.py``
is their single source of truth and already has its own coverage gate.
"""
group_of = {name: label for label, names in groups.items() for name in names}
rows = []
for tool in tools:
cad = (getattr(tool, "meta", None) or {}).get("cad") or {}
rows.append(
{
"name": tool.name,
"group": group_of.get(tool.name),
"cost": cad.get("cost"),
"summary": cad.get("summary"),
"tags": sorted(getattr(tool, "tags", None) or ()),
"annotations": _annotations(tool),
}
)
return sorted(rows, key=lambda row: row["name"])
def _resource_rows(components: dict[str, Any]) -> list[dict[str, Any]]:
"""Plain resources and resource templates in one uniformly-shaped list.
FastMCP files them under two registry prefixes (``resource:`` and
``template:``); the README counts them as one number, so they are merged and
told apart by ``templated``.
"""
rows = []
for key, component in components.items():
if key.startswith("resource:"):
uri, templated, parameters = str(component.uri), False, []
elif key.startswith("template:"):
uri, templated = str(component.uri_template), True
parameters = sorted(
(getattr(component, "parameters", None) or {}).get("properties", {})
)
else:
continue
rows.append(
{
"uri": uri,
"name": component.name,
"description": component.description or "",
"mime_type": component.mime_type,
"tags": sorted(getattr(component, "tags", None) or ()),
"templated": templated,
"parameters": parameters,
}
)
return sorted(rows, key=lambda row: row["uri"])
def _prompt_rows(components: dict[str, Any]) -> list[dict[str, Any]]:
rows = []
for key, component in components.items():
if not key.startswith("prompt:"):
continue
rows.append(
{
"name": component.name,
"description": component.description or "",
"tags": sorted(getattr(component, "tags", None) or ()),
"arguments": [
{"name": argument.name, "required": bool(argument.required)}
for argument in (component.arguments or [])
],
}
)
return sorted(rows, key=lambda row: row["name"])
async def build_inventory() -> dict[str, Any]:
"""Build the snapshot document from the live registry.
Raises rather than emitting an empty section: committing an inventory that
silently lost every tool would be worse than a broken generator, because the
drift gate would then happily lock the emptiness in.
"""
import server
components = _components()
tools = await server._registered_tools()
groups = await server._tool_groups()
tool_rows = _tool_rows(tools, groups)
resource_rows = _resource_rows(components)
prompt_rows = _prompt_rows(components)
for label, rows in (
("tools", tool_rows),
("resources", resource_rows),
("prompts", prompt_rows),
):
if not rows:
raise RuntimeError(
f"refusing to write an inventory with zero {label}: the component "
"registry looks unreadable (FastMCP layout change?)"
)
return {
"_comment": _FILE_COMMENT,
"schema_version": SCHEMA_VERSION,
"package_version": _pyproject_version(),
"totals": {
"tools": len(tool_rows),
"resources": len(resource_rows),
"prompts": len(prompt_rows),
"groups": len(groups),
},
"groups": {label: len(names) for label, names in sorted(groups.items())},
"tools": tool_rows,
"resources": resource_rows,
"prompts": prompt_rows,
}
def render(inventory: dict[str, Any]) -> str:
"""Serialize deterministically: same registry in, byte-identical text out."""
return json.dumps(inventory, indent=2, ensure_ascii=False) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate docs/tool-inventory.json from the live MCP registry."
)
parser.add_argument(
"--check",
action="store_true",
help="do not write; exit 1 if the committed file has drifted from the live surface",
)
args = parser.parse_args(argv)
text = render(asyncio.run(build_inventory()))
if args.check:
current = INVENTORY_PATH.read_text(encoding="utf-8") if INVENTORY_PATH.exists() else None
if current == text:
print(f"ok: {INVENTORY_REL_PATH} matches the live surface")
return 0
reason = "is missing" if current is None else "has drifted from the live surface"
print(f"DRIFT: {INVENTORY_REL_PATH} {reason}")
print(f" regenerate it with: {REGENERATE_COMMAND}")
return 1
INVENTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
INVENTORY_PATH.write_text(text, encoding="utf-8", newline="\n")
inventory = json.loads(text)
totals = inventory["totals"]
print(
f"wrote {INVENTORY_REL_PATH}: {totals['tools']} tools, "
f"{totals['resources']} resources, {totals['prompts']} prompts, "
f"{totals['groups']} groups"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+155 -51
View File
@@ -6,6 +6,7 @@ Path traversal protection and command injection prevention.
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
@@ -29,6 +30,38 @@ _BLOCKED_PATH_PATTERNS = re.compile(
)
_DRIVE_ANCHOR = re.compile(r"^[A-Za-z]:$")
def _reject_non_local_anchor(original: str, resolved: Path) -> None:
r"""Every guard below compares against plain ``X:\`` paths.
Windows accepts three other anchors that name the same files but compare
unequal: the extended-length prefix (``\\?\C:\...``), UNC (``\\host\share``)
and the admin shares (``\\localhost\C$``). The backslash spellings are
already rejected by ``_BLOCKED_PATH_PATTERNS``, but ``pathlib`` normalises
the *forward-slash* spellings into them only after ``.resolve()`` — so they
slipped past the pattern check and then failed to match ``C:/Windows`` in
the system-directory loop. Proven: ``//?/C:/Windows/win.ini`` handed back a
working handle to ``C:\Windows\win.ini``.
A hard input rejection, so a plain ``ToolError`` like its siblings — not a
capability refusal, which means "this backend cannot" and would wrongly
suggest another engine would accept it.
"""
drive = resolved.drive
if _DRIVE_ANCHOR.match(drive):
return
log.warning("Blocked non-local path anchor: %s -> %s", original, resolved)
raise ToolError(
f"Path rejected: '{original}' resolves to '{resolved}', which is not a "
f"plain local drive path (anchor '{drive}'). Extended-length (\\\\?\\), "
"device and UNC (\\\\host\\share, \\\\localhost\\C$) paths bypass the "
"system-directory and allowed-root checks and are not accepted. Use a "
"normal 'X:\\folder\\file.dxf' path."
)
def validate_path(path: str, *, allow_write: bool = False) -> Path:
"""Resolve and validate a file path against security rules.
@@ -49,6 +82,12 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
resolved = Path(path).resolve()
# After .resolve() (drive is only meaningful once resolved) and before the
# system-directory loop, whose comparisons this is protecting. POSIX has no
# drive component at all, so the check is Windows-only by construction.
if os.name == "nt":
_reject_non_local_anchor(path, resolved)
_SYSTEM_DIRS = [
Path("C:/Windows").resolve(),
Path("C:/Program Files").resolve(),
@@ -103,56 +142,107 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
# ---------------------------------------------------------------------------
# Command / LISP sanitization
#
# Why it is NOT incoherent to block INSERT/SAVEAS/NEW/OPEN/PURGE/ERASE here
# while `block_insert` / `drawing_save_as` / `drawing_new` / `drawing_open` /
# `drawing_purge` / `entity_delete` are tools — this gets re-raised, so it is
# recorded once:
#
# `sanitize_command` and `sanitize_lisp` each have exactly ONE caller, both of
# them the COM-only free-text escape hatch into SendCommand. The policy is
# scoped to the CHANNEL, not to the operation, which is capability narrowing
# rather than a contradiction:
#
# * `block_insert(name, x, y, ...)` takes a block ALREADY in the drawing plus
# typed geometry. The INSERT *command* takes a file path and pulls in an
# external DWG, which can carry an attached VBA/LISP payload. Same name,
# different capability.
# * `entity_delete(handle)` deletes one named entity. `ERASE ALL` does not.
# * `drawing_save_as` / `_open` / `_new` route their path through
# validate_path. A free-text `SAVEAS C:\...` routes through nothing.
# ---------------------------------------------------------------------------
_DANGEROUS_COMMANDS = [
"ERASE",
"_ERASE",
"DELETE",
"_DELETE",
"PURGE",
"_PURGE",
"QUIT",
"_QUIT",
"CLOSE",
"_CLOSE",
"QSAVE",
"_QSAVE",
"SAVEAS",
"_SAVEAS",
"NEW",
"_NEW",
"OPEN",
"_OPEN",
"RECOVER",
"_RECOVER",
"WBLOCK",
"_WBLOCK",
"INSERT",
"_INSERT",
"XREF",
"_XREF",
"SHELL",
"_SHELL",
"SCRIPT",
"_SCRIPT",
"APPLOAD",
"_APPLOAD",
"NETLOAD",
"_NETLOAD",
"VBARUN",
"_VBARUN",
"VBALOAD",
"_VBALOAD",
"SECURITYOPTIONS",
"_SECURITYOPTIONS",
]
_DANGEROUS_COMMAND_PATTERNS = re.compile(
r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b",
re.IGNORECASE,
# Matched against the extracted VERB, so the "_XXX" duplicates the old list
# carried are gone — command_verbs() strips every decorator AutoCAD accepts.
#
# Single-letter aliases are deliberately absent. `E` is the ERASE alias, but it
# is also the Extents *option* of ZOOM, so blocking it refuses `_ZOOM\nE\n` —
# the canonical zoom-extents macro, and this repo's own safe-command fixture.
# A guardrail that blocks the most common harmless macro gets switched off, and
# then it guards nothing.
_DANGEROUS_COMMANDS = frozenset(
{
# destructive to the drawing
"ERASE",
"DELETE",
"PURGE",
"WBLOCK",
"INSERT",
"XREF",
# ends or overwrites the session / file
"QUIT",
"EXIT",
"CLOSE",
"CLOSEALL",
"QSAVE",
"SAVE",
"SAVEAS",
"NEW",
"OPEN",
"RECOVER",
"RECOVERALL",
# loads or runs code — every channel found while probing
"SHELL",
"SCRIPT",
"SCR",
"APPLOAD",
"NETLOAD",
"ARX",
"VBARUN",
"VBALOAD",
"VBASTMT",
"VBAIDE",
"VBAMAN",
"CUILOAD",
"MENULOAD",
"MENU",
"CUI",
# writes files outside the tool layer's path validation
"EXPORT",
"ETRANSMIT",
"PUBLISH",
"SECURITYOPTIONS",
}
)
# AutoCAD accepts a command with any mix of these leading decorators:
# _ international (locale-independent) form
# . suppress a redefined command
# - the command-line (no dialog) variant
# ' transparent
_COMMAND_DECORATORS = "_.-*'"
def command_verbs(command: str) -> list[str]:
"""Every command verb in a SendCommand string.
SendCommand takes a whole macro, not one command: segments are separated by
a newline or ``;``, and only the FIRST token of a segment is a verb — the
rest is argument data. Scanning the raw string with a word-boundary regex is
what refused ``MTEXT 0,0 10,10 Do not erase this part``, which is annotation
text, while still missing spellings AutoCAD accepts.
"""
verbs: list[str] = []
for segment in re.split(r"[\n;]+", command):
segment = segment.strip()
if not segment:
continue
token = segment.split()[0].split(",")[0].lstrip(_COMMAND_DECORATORS)
if token:
verbs.append(token.upper())
return verbs
# Dangerous LISP symbols. We match these as **bare tokens anywhere** in the
# expression — not only when they appear right after `(` — because aliasing
# tricks like `(setq f command)(f "ERASE")` reference the symbol as a value.
@@ -191,6 +281,12 @@ _DANGEROUS_LISP_PATTERNS = [
r"\*push-error-using-stack\*",
]
# A double-quoted literal is DATA — drawing text, a layer name, a note. The
# symbol denylist must not read it. Without this, an engineering CAD server
# refused its own vocabulary: '(setq desc "Max load 500 kg")' matched
# r"(?:auto)?load" and '(setq layer "C:CENTER")' matched r"c:[\w-]+".
_LISP_STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"')
_DANGEROUS_LISP_PATTERN = re.compile(
r"(?:(?<![\w-])|^)(?:" + "|".join(_DANGEROUS_LISP_PATTERNS) + r")(?![\w-])",
re.IGNORECASE,
@@ -210,12 +306,17 @@ def sanitize_command(command: str) -> str:
log.info("Dangerous commands enabled — bypassing sanitization for: %s", command[:80])
return command
if _DANGEROUS_COMMAND_PATTERNS.search(command):
blocked = {verb for verb in command_verbs(command) if verb in _DANGEROUS_COMMANDS}
if blocked:
log.warning("Blocked dangerous command: %s", command[:120])
raise ToolError(
f"Command rejected: '{command[:80]}' contains a restricted command. "
"Restricted commands include ERASE, DELETE, PURGE, QUIT, SHELL, SCRIPT, etc. "
"Set DANGEROUS_COMMANDS_ENABLED=true to allow all commands."
f"Command rejected: {', '.join(sorted(blocked))} "
f"{'is a restricted command' if len(blocked) == 1 else 'are restricted commands'}. "
"This denylist is a guardrail against accidental destruction, not a "
"security boundary — "
"prefer the typed tools (entity_delete, drawing_save_as, block_insert, "
"drawing_purge), which validate their arguments. Set "
"DANGEROUS_COMMANDS_ENABLED=true to allow all commands."
)
return command
@@ -236,7 +337,10 @@ def sanitize_lisp(expression: str) -> str:
)
return expression
if _DANGEROUS_LISP_PATTERN.search(expression):
# Blank the literals, then scan. The ORIGINAL expression is returned:
# this decides whether to run it, it does not rewrite it.
code_only = _LISP_STRING_LITERAL.sub('""', expression)
if _DANGEROUS_LISP_PATTERN.search(code_only):
log.warning("Blocked dangerous LISP expression: %s", expression[:120])
raise ToolError(
"LISP expression rejected: contains a restricted function. "
+10 -3
View File
@@ -7,12 +7,12 @@
"url": "https://github.com/U-C4N/Autocad-MCP",
"source": "github"
},
"version": "1.4.0",
"version": "1.5.0",
"packages": [
{
"registryType": "pypi",
"identifier": "autocad-mcp-pro",
"version": "1.4.0",
"version": "1.5.0",
"transport": {
"type": "stdio"
},
@@ -33,7 +33,14 @@
},
{
"name": "TOOL_PROFILE",
"description": "Exposed tool profile: full (default), core or lean.",
"description": "Exposed tool profile: full (default) or lean.",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "DISCOVERY_MODE",
"description": "Tool discovery: off (default) advertises the full catalog; search replaces it with search_tools plus a call_tool proxy.",
"isRequired": false,
"format": "string",
"isSecret": false
+2592 -115
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -1,10 +1,37 @@
"""Shared fixtures for AutoCAD MCP tests."""
import pytest
import pytest_asyncio
from backends.ezdxf_backend import EzdxfBackend
@pytest.fixture(autouse=True)
def pin_headless_backend(request, monkeypatch):
"""Every test talks to the headless engine unless it says otherwise.
This suite runs on developer machines that have AutoCAD installed, and a
test that opens ``Client(server.mcp)`` without pinning would run the real
lifespan: ``AUTOCAD_MCP_BACKEND`` unset means ``auto``, which on Windows
means attaching to the operator's open, unsaved drawing and creating
entities in it. That has actually happened here. Defaulting the whole suite
to ``ezdxf`` is the only guard that covers tests nobody has written yet.
Opt out with ``@pytest.mark.live_backend`` for a test that deliberately
exercises backend selection.
"""
if request.node.get_closest_marker("live_backend"):
return
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
def pytest_configure(config):
config.addinivalue_line(
"markers",
"live_backend: test drives backend selection itself; do not pin AUTOCAD_MCP_BACKEND",
)
@pytest_asyncio.fixture
async def backend():
"""Create an ezdxf backend with a fresh empty document."""
+1
View File
@@ -0,0 +1 @@
"""Static fixtures for the test suite."""
+179
View File
@@ -0,0 +1,179 @@
"""Golden query set for tool discovery: English in, one right tool out.
Sixty-six cases (51 tuning + 15 holdout) split into a **tuning** set and a
**holdout** set. The split is
the point of the file. Ranking work is measured against
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
before any scoring parameter was chosen, and were not looked at until the
scorer was frozen. A retrieval number quoted against cases the ranker was fitted
to is not evidence of anything.
Rules the cases follow:
* Every ``expect`` is a tool that genuinely, unambiguously does the job. Where
two tools would both be defensible answers (``view_screenshot`` vs
``view_zoom_and_screenshot``, ``entity_delete`` vs ``entity_delete_many``)
the query is worded to pick one, or the case is left out. A golden set that
scores ambiguity is measuring the author, not the ranker.
* ``kind`` records what the case exercises, so accuracy can be reported per
category instead of as one flattering average.
* ``risk`` is the ceiling a caller would sensibly pass. It is ``"read"`` for
the counting questions — the ones where stock BM25 answers "how many entities
are on the GEOMETRY layer" with ``entity_delete_many``.
"""
from __future__ import annotations
from dataclasses import dataclass
__all__ = ["ALL_CASES", "HOLDOUT_CASES", "TUNING_CASES", "GoldenCase"]
@dataclass(frozen=True, slots=True)
class GoldenCase:
"""One query and the tool it must find."""
query: str
expect: str
kind: str # "command" | "synonym" | "paraphrase" | "counting"
risk: str = "any"
def __str__(self) -> str: # readable pytest ids
return f"{self.kind}:{self.query}"
# ── tuning set ──────────────────────────────────────────────────────────────
# Free to be measured against, argued with and re-measured.
TUNING_CASES: tuple[GoldenCase, ...] = (
# AutoCAD command names a drafter types verbatim.
# Re-pointed in v1.5.0 (M8): BPOLY now has a real destination. It was
# parked on entity_create_polyline only because nothing traced a boundary,
# and this is a correction of the expectation, not a tuning of the ranker.
GoldenCase("BPOLY", "boundary_trace", "command"),
GoldenCase("PEDIT", "entity_edit_geometry", "command"),
GoldenCase("WBLOCK", "block_create_from_entities", "command"),
GoldenCase("OVERKILL", "drawing_refine", "command"),
GoldenCase("LAYTRANS", "template_apply_layers", "command"),
GoldenCase("MATCHPROP", "entity_set_properties", "command"),
GoldenCase("QSELECT", "entity_select_smart", "command"),
GoldenCase("DIMLINEAR", "dimension_linear", "command"),
GoldenCase("ARRAYPOLAR", "entity_array_polar", "command"),
GoldenCase("MLEADER", "leader_create_mleader", "command"),
GoldenCase("BHATCH", "entity_create_hatch", "command"),
GoldenCase("LAYISO", "layer_isolate", "command"),
GoldenCase("XLINE", "construction_xline", "command"),
GoldenCase("EXPLODE", "block_explode", "command"),
GoldenCase("DXFOUT", "drawing_export_dxf", "command"),
GoldenCase("QDIM", "dimension_auto", "command"),
GoldenCase("CHA", "entity_chamfer", "command"),
GoldenCase("DLI", "dimension_linear", "command"),
GoldenCase("LAYDEL", "layer_delete", "command"),
GoldenCase("PURGE", "drawing_purge", "command"),
# Shop-floor vocabulary.
GoldenCase("bill of materials", "entity_create_table", "synonym"),
GoldenCase("parts list", "entity_create_table", "synonym"),
GoldenCase("round the corner", "entity_fillet", "synonym"),
GoldenCase("bolt circle", "entity_array_polar", "synonym"),
GoldenCase("title block", "titleblock_apply_iso_a3", "synonym"),
GoldenCase("keyed bore", "keyway_draw_keyed_bore", "synonym"),
GoldenCase("feature control frame", "gd_frame", "synonym"),
GoldenCase("object snap", "point_from_snap", "synonym"),
GoldenCase("zoom to fit", "view_zoom_extents", "synonym"),
GoldenCase("smooth curve", "entity_create_spline", "synonym"),
GoldenCase("true position", "gd_frame", "synonym"),
GoldenCase("datum triangle", "datum_feature", "synonym"),
# Paraphrases — no shared vocabulary with the tool name.
GoldenCase("I need the corner rounded off with a 3 mm radius", "entity_fillet", "paraphrase"),
GoldenCase(
"put an arrow with a note pointing at the hole", "leader_create_mleader", "paraphrase"
),
GoldenCase("turn a layer off so it does not print", "layer_hide", "paraphrase"),
GoldenCase("make the whole shape smaller by half", "entity_scale", "paraphrase"),
GoldenCase(
"repeat this shape around a centre point six times", "entity_array_polar", "paraphrase"
),
GoldenCase(
"the two lines overshoot, cut them back to where they cross", "entity_trim", "paraphrase"
),
GoldenCase("write a paragraph of general notes", "entity_create_mtext", "paraphrase"),
GoldenCase("take a picture of the current drawing", "view_screenshot", "paraphrase"),
GoldenCase("start a checkpoint I can roll back to", "transaction_begin", "paraphrase"),
GoldenCase(
"what is wrong with this drawing before I finish it", "drawing_critique", "paraphrase"
),
GoldenCase("fill this closed area with section hatching", "entity_create_hatch", "paraphrase"),
GoldenCase("bevel a corner at 45 degrees", "entity_chamfer", "paraphrase"),
GoldenCase("load the dashed hidden linetype", "linetype_load", "paraphrase"),
# Counting / inspection. Under stock BM25 the first of these answers with
# entity_delete_many.
GoldenCase(
"how many entities are on the GEOMETRY layer",
"analysis_entity_stats",
"counting",
risk="read",
),
GoldenCase("what layers exist in this drawing", "layer_list", "counting", risk="read"),
GoldenCase("how big is the drawing overall", "analysis_bounding_box", "counting", risk="read"),
# Re-pointed when analysis_measure_entity was added, NOT to match the
# ranker: "this closed polyline" names an entity, and until that tool existed
# the only destination was the one tool that cannot look at the drawing. The
# expectation was wrong the whole time; the case is now answerable.
GoldenCase(
"what is the area of this closed polyline",
"analysis_measure_entity",
"counting",
risk="read",
),
# Withdrawn: "list every circle in the drawing" -> analysis_select_by_type.
# ``entity_list(type_filter="CIRCLE")`` answers it just as correctly (and
# with a layer filter and paging on top), so the case scored the author's
# preference, not the ranker. Replaced rather than re-labelled: relabelling
# a case to whatever the ranker returned is how a golden set stops being
# evidence.
GoldenCase(
"what is inside this rectangular window",
"analysis_find_in_region",
"counting",
risk="read",
),
GoldenCase(
"where is this block used in the drawing",
"block_find_references",
"counting",
risk="read",
),
)
# ── holdout set ─────────────────────────────────────────────────────────────
# Written alongside the tuning cases, before any scoring constant was chosen,
# and deliberately not measured until the scorer was frozen. Do not tune
# against these. If a holdout case fails, the honest fix is a new tuning case
# that captures the same class of failure — not an edit to this tuple.
HOLDOUT_CASES: tuple[GoldenCase, ...] = (
GoldenCase("DDEDIT", "entity_edit_text", "command"),
GoldenCase("MVIEW", "viewport_create", "command"),
GoldenCase("SPL", "entity_create_spline", "command"),
GoldenCase("TOLERANCE", "gd_frame", "command"),
GoldenCase("crosshatch", "entity_create_hatch", "synonym"),
GoldenCase("wall thickness", "entity_offset", "synonym"),
GoldenCase("undo the undo", "drawing_redo", "synonym"),
GoldenCase("pitch circle", "entity_create_circle", "synonym"),
GoldenCase("I want a parallel copy 5 mm inside the outline", "entity_offset", "paraphrase"),
GoldenCase("get rid of the unused blocks and layers", "drawing_purge", "paraphrase"),
GoldenCase("change the wording of this label", "entity_edit_text", "paraphrase"),
GoldenCase("make a scaled window of the model on the sheet", "viewport_create", "paraphrase"),
GoldenCase(
"how far apart are these two points",
"analysis_measure_distance",
"counting",
risk="read",
),
GoldenCase("which blocks are defined", "block_list", "counting", risk="read"),
GoldenCase("what are the properties of this entity", "entity_get", "counting", risk="read"),
)
ALL_CASES: tuple[GoldenCase, ...] = TUNING_CASES + HOLDOUT_CASES
+220
View File
@@ -0,0 +1,220 @@
"""M8/F15 — annotation objects: wipeout, revision cloud, MTEXT mask, find/replace.
Four tools that fill real gaps rather than restating the 141 already here. Each
carries a measured caveat:
* **WIPEOUT** is a real DXF entity (`add_wipeout`) that round-trips.
* **REVCLOUD** is genuinely available: `ezdxf.revcloud.add_entity()` emits the
bulged LWPOLYLINE that AutoCAD's REVCLOUD *is*, stamps the `RevcloudProps`
xdata and picks the bulge sign from the winding direction — so the result is
recognised by `is_revcloud()` rather than merely resembling a cloud. The trap
is the segment length: pass one larger than the shortest edge and you get a
"cloud" with no arcs at all, which looks like a plain rectangle and reports
success.
* **MTEXT background mask** round-trips (`bg_fill`, `box_fill_scale`) — proven
through saveas/readfile, because a mask that vanishes on save is worse than
no mask.
* **find/replace** is the one with a real scope problem. TEXT, MTEXT, ATTRIB
and ATTDEF are all readable *and* writable, so all four are in scope. A
DIMENSION's `dxf.text` is `'<>'` — the override placeholder, not the
measurement — so replacing text inside dimensions would either do nothing or
destroy the association with the measured value. The tool reports which types
it searched instead of silently skipping some, because "0 replacements" and
"I did not look there" are different answers.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SQUARE = [[0.0, 0.0], [100.0, 0.0], [100.0, 60.0], [0.0, 60.0]]
# ── wipeout ─────────────────────────────────────────────────────────────────
async def test_a_wipeout_is_created_and_reports_its_handle(backend):
result = await backend.entity_create_wipeout(SQUARE)
assert result["ok"] is True
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "WIPEOUT"
async def test_a_wipeout_survives_save_and_reload(backend, tmp_path):
import ezdxf
created = await backend.entity_create_wipeout(SQUARE)
path = tmp_path / "wipeout.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"])
assert reloaded is not None and reloaded.dxftype() == "WIPEOUT"
@pytest.mark.parametrize("bad", [[], [[0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]]])
async def test_a_wipeout_needs_at_least_three_points(backend, bad):
"""Two points enclose no area; a zero-area mask masks nothing."""
result = await backend.entity_create_wipeout(bad)
assert result["ok"] is False
# ── revision cloud ──────────────────────────────────────────────────────────
async def test_a_revision_cloud_is_a_bulged_polyline(backend):
"""The arcs are the cloud. A revcloud without bulges is a rectangle."""
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
assert result["ok"] is True
entity = backend._doc.entitydb.get(result["handle"])
assert entity.dxftype() == "LWPOLYLINE"
bulges = [point[4] for point in entity.get_points()]
assert all(abs(b) > 0.1 for b in bulges), "every segment must carry an arc"
assert result["segments"] == len(bulges)
async def test_the_cloud_is_recognisable_as_one(backend):
from ezdxf.revcloud import is_revcloud
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
assert is_revcloud(backend._doc.entitydb.get(result["handle"]))
async def test_a_segment_longer_than_the_path_is_refused(backend):
"""Otherwise the result is a plain rectangle reported as a cloud."""
result = await backend.entity_create_revcloud(SQUARE, segment_length=10_000.0)
assert result["ok"] is False
assert "segment_length" in result["error"]
@pytest.mark.parametrize("bad", [0.0, -5.0])
async def test_a_non_positive_segment_length_is_refused(backend, bad):
result = await backend.entity_create_revcloud(SQUARE, segment_length=bad)
assert result["ok"] is False
# ── MTEXT background mask ───────────────────────────────────────────────────
async def test_a_background_mask_round_trips(backend, tmp_path):
"""A mask that vanishes on save is worse than no mask."""
import ezdxf
mtext = await backend.entity_create_mtext("SECTION A-A", 10, 10)
result = await backend.text_set_background(mtext.handle, color=2, scale=1.5)
assert result["ok"] is True
path = tmp_path / "masked.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(mtext.handle)
assert reloaded.dxf.bg_fill == 1
assert reloaded.dxf.box_fill_scale == pytest.approx(1.5)
async def test_a_background_mask_can_be_turned_off_again(backend):
mtext = await backend.entity_create_mtext("X", 10, 10)
await backend.text_set_background(mtext.handle, color=2)
result = await backend.text_set_background(mtext.handle, enabled=False)
assert result["ok"] is True
assert result["enabled"] is False
assert backend._doc.entitydb.get(mtext.handle).dxf.get("bg_fill", 0) == 0
async def test_a_background_mask_on_plain_text_is_refused(backend):
"""Only MTEXT carries a background fill; TEXT silently would not."""
text = await backend.entity_create_text("PLAIN", 0, 0)
result = await backend.text_set_background(text.handle, color=2)
assert result["ok"] is False
assert "MTEXT" in result["error"]
@pytest.mark.parametrize("bad", [0.5, 0.0, -1.0])
async def test_a_mask_scale_below_one_is_refused(backend, bad):
"""A box smaller than the text is not a mask, it is a stripe."""
mtext = await backend.entity_create_mtext("X", 0, 0)
result = await backend.text_set_background(mtext.handle, color=2, scale=bad)
assert result["ok"] is False
# ── find / replace ──────────────────────────────────────────────────────────
async def _tagged_block(backend):
doc = backend._doc
block = doc.blocks.new("TAGGED")
block.add_attdef("PART", (0, 0), text="OLD")
ref = doc.modelspace().add_blockref("TAGGED", (0, 0))
ref.add_auto_attribs({"PART": "OLD"})
return ref
async def test_find_replace_covers_text_mtext_and_block_attributes(backend):
"""Silently skipping attributes inside blocks is this tool's honesty trap.
Four hits, not three: the ATTDEF in the block *definition* carries the
default value the next insert will use, so leaving it behind would fix the
drawing and reintroduce the old text on the next block reference.
"""
text = await backend.entity_create_text("OLD value", 0, 0)
mtext = await backend.entity_create_mtext("OLD value", 0, 10)
await _tagged_block(backend)
result = await backend.text_find_replace("OLD", "NEW")
assert result["ok"] is True
assert result["replaced"] == 4
changed = {row["type"] for row in result["entities"]}
assert changed == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW value"
assert backend._doc.entitydb.get(mtext.handle).text == "NEW value"
async def test_it_reports_which_types_it_searched(backend):
""" "0 replacements" and "I never looked there" are different answers."""
result = await backend.text_find_replace("NOTHING", "X")
assert result["ok"] is True
assert result["replaced"] == 0
assert set(result["searched_types"]) == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
assert "DIMENSION" in result["note"]
async def test_a_dry_run_changes_nothing(backend):
text = await backend.entity_create_text("OLD", 0, 0)
result = await backend.text_find_replace("OLD", "NEW", dry_run=True)
assert result["replaced"] == 1
assert result["dry_run"] is True
assert backend._doc.entitydb.get(text.handle).dxf.text == "OLD"
async def test_it_can_be_scoped_to_a_layer(backend):
await backend.entity_create_text("OLD", 0, 0, layer="TEXT")
other = await backend.entity_create_text("OLD", 0, 10, layer="DIM")
result = await backend.text_find_replace("OLD", "NEW", layer="TEXT")
assert result["replaced"] == 1
assert backend._doc.entitydb.get(other.handle).dxf.text == "OLD"
async def test_an_empty_search_string_is_refused(backend):
"""Replacing "" matches between every character and destroys the text."""
result = await backend.text_find_replace("", "X")
assert result["ok"] is False
async def test_case_sensitivity_is_the_callers_choice(backend):
text = await backend.entity_create_text("Old Value", 0, 0)
result = await backend.text_find_replace("old", "NEW", match_case=False)
assert result["replaced"] == 1
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW Value"
+101
View File
@@ -0,0 +1,101 @@
"""T0.8-B — which engine a test talks to must not be a matter of luck.
``_make_backend`` read ``os.environ["AUTOCAD_MCP_BACKEND"]`` directly while
``config.settings.backend`` — the setting that *looks* like the control — had no
production reader at all. So ``monkeypatch.setattr(config.settings, "backend",
"ezdxf")`` was a no-op that read like a safety guard, and on a developer machine
with AutoCAD open an unguarded test would attach to the operator's live unsaved
drawing and start creating entities in it. That is not hypothetical: it happened
during this release's development.
The fix makes ``Settings.backend`` a property that reads the environment live,
and points ``_make_backend`` at it. One source of truth, and late binding is
preserved so ``monkeypatch.setenv`` — which four call sites and
``benchmarks/token_suite.py`` already use correctly — keeps working. Assigning to
``config.settings.backend`` now raises instead of quietly protecting nothing.
"""
from __future__ import annotations
import sys
import types
import pytest
import config
import server
pytestmark = pytest.mark.asyncio
class _ComBackendSentinel:
"""Stands in for the real COM backend and screams if anyone builds it."""
constructed: list[str] = []
def __init__(self, *args, **kwargs):
_ComBackendSentinel.constructed.append("ComBackend() CONSTRUCTED")
raise AssertionError("a test tried to construct the live COM backend")
@pytest.fixture
def com_sentinel(monkeypatch):
"""Replace backends.com_backend with a module that cannot connect anywhere."""
_ComBackendSentinel.constructed.clear()
fake = types.ModuleType("backends.com_backend")
fake.ComBackend = _ComBackendSentinel
monkeypatch.setitem(sys.modules, "backends.com_backend", fake)
return _ComBackendSentinel
async def test_the_env_var_is_what_actually_selects_the_backend(monkeypatch, com_sentinel):
from backends.ezdxf_backend import EzdxfBackend
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
backend = await server._make_backend()
assert isinstance(backend, EzdxfBackend)
assert com_sentinel.constructed == [], "the COM backend must never have been touched"
await backend.disconnect()
async def test_settings_backend_reports_what_make_backend_will_use(monkeypatch):
"""The setting must not be able to disagree with the selector."""
for value in ("ezdxf", "com", "auto"):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", value)
assert config.settings.backend == value
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", " EzDxF ")
assert config.settings.backend == "ezdxf", "must normalise like the selector does"
monkeypatch.delenv("AUTOCAD_MCP_BACKEND", raising=False)
assert config.settings.backend == "auto"
async def test_assigning_to_settings_backend_fails_loudly(monkeypatch):
"""The dead monkeypatch that made two tests *look* guarded must now raise.
Silently accepting the assignment is the whole bug: the test passes, reads
as safe, and talks to live AutoCAD anyway.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
with pytest.raises(AttributeError):
config.settings.backend = "com"
assert config.settings.backend == "ezdxf"
async def test_make_backend_reads_the_setting_not_the_environment_directly(monkeypatch):
"""One source of truth: patching the property must steer the selector.
If ``_make_backend`` goes back to reading ``os.environ`` itself, this fails —
which is the regression guard, because that direct read is what let the
setting and the behaviour drift apart in the first place.
"""
from backends.ezdxf_backend import EzdxfBackend
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "com")
monkeypatch.setattr(type(config.settings), "backend", property(lambda self: "ezdxf"))
backend = await server._make_backend()
assert isinstance(backend, EzdxfBackend)
await backend.disconnect()
+169
View File
@@ -0,0 +1,169 @@
"""M9 — the v3 task matrix.
v2 returned 10/10 for this server. That was not a good sign: every task in it
tested something the server was designed around, so the score could only ever
have been full marks. The five tasks added here were chosen because they can
fail, and three of them did fail while being written — a hatch could not be
measured at all, and the boundary tools disagreed with the measurement tool
about the same shape.
Two assertions in this file exist purely to keep the matrix honest rather than
to test behaviour:
* the plan's ``tool_discovery_bilingual`` and ``measurement_massprops`` must
**not** appear, because neither capability shipped; and
* the published competitor reports must not carry the new task ids, because
those servers were never run against them and a zero we invented would read
exactly like a zero we measured.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from backends.base import UnsupportedCapabilityError
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
from benchmarks.adapters.base import BenchmarkAdapter
from benchmarks.run_competitors import run_tasks
from benchmarks.tasks_v2 import TASKS_V2
from benchmarks.tasks_v3 import MATRICES, NEW_TASKS_V3, TASKS_V3, task_by_id
PUBLISHED = Path(__file__).resolve().parents[1] / "benchmarks" / "results" / "published"
# ── shape of the matrix ─────────────────────────────────────────────────────
def test_v3_extends_v2_without_disturbing_it():
"""A renumbered or reweighted v2 task would silently invalidate the
published v1.4 reports, which are keyed by task id."""
assert TASKS_V3[: len(TASKS_V2)] == TASKS_V2
assert len(NEW_TASKS_V3) == 5
assert len(TASKS_V3) == 15
def test_every_task_id_is_unique():
ids = [task.task_id for task in TASKS_V3]
assert len(ids) == len(set(ids))
def test_the_two_capabilities_that_did_not_ship_are_not_advertised_as_tasks():
ids = {task.task_id for task in TASKS_V3}
assert "tool_discovery_bilingual" not in ids, (
"the Turkish normalizer was built and removed; this repo is English "
"throughout, so a bilingual task would be a benchmark for nothing"
)
assert "measurement_massprops" not in ids, (
"no centroid or moment-of-inertia tool ships — F2 was cut to entity_measure"
)
assert "tool_discovery" in ids
assert "measure_from_handle" in ids
def test_task_by_id_can_still_address_the_v2_matrix():
assert task_by_id("core_geometry", "v2").category == "creation"
with pytest.raises(KeyError):
task_by_id("hatch_islands", "v2")
def test_matrices_registry_exposes_both_sets():
assert MATRICES["v2"] == TASKS_V2
assert MATRICES["v3"] == TASKS_V3
# ── the reference adapter actually answers them ─────────────────────────────
def test_the_reference_adapter_implements_every_v3_task():
"""An unimplemented task scores `unsupported` and is easy to not notice."""
missing = [
task.task_id
for task in TASKS_V3
if not hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}")
]
assert missing == []
@pytest.mark.asyncio
@pytest.mark.parametrize("task", NEW_TASKS_V3, ids=lambda t: t.task_id)
async def test_each_new_task_passes_on_the_headless_backend(task, tmp_path):
report = await run_tasks(AutoCADMCPProAdapter(), [task], tmp_path, timeout=120.0)
result = report["results"][0]
assert result["status"] == "pass", result["message"]
assert result["metrics"], "a task that proves nothing reports no evidence"
@pytest.mark.asyncio
async def test_the_full_v3_matrix_runs_green(tmp_path):
report = await run_tasks(AutoCADMCPProAdapter(), TASKS_V3, tmp_path, timeout=180.0)
assert report["summary"]["attempted"] == 15
assert report["summary"]["passed"] == 15, [
(item["task_id"], item["status"], item["message"]) for item in report["results"]
]
assert report["matrix"] == "v3"
# ── a capability refusal is not a failure ───────────────────────────────────
class RefusingAdapter(BenchmarkAdapter):
name = "refusing"
async def setup(self, artifact_dir):
return None
async def run_task(self, task):
raise UnsupportedCapabilityError("chspace", "no change-space member on this engine")
async def cleanup(self):
return None
@pytest.mark.asyncio
async def test_a_capability_refusal_is_reported_unsupported_not_failed(tmp_path):
"""`fail` and `unsupported` are different claims about a competitor.
"Your server got this wrong" and "your engine cannot reach this" are not
the same finding, and the runner used to collapse the second into the
first because a refusal is still an exception.
"""
report = await run_tasks(RefusingAdapter(), [NEW_TASKS_V3[0]], tmp_path)
result = report["results"][0]
assert result["status"] == "unsupported"
assert "chspace" in result["message"]
assert report["summary"]["unsupported"] == 1
assert report["summary"]["score"] == 0.0
# ── nothing was invented for the competitors ────────────────────────────────
@pytest.mark.parametrize(
"filename", ["beiming183-autocad-mcp.json", "puran-water-autocad-mcp.json"]
)
def test_competitor_reports_carry_no_result_for_the_new_tasks(filename):
report = json.loads((PUBLISHED / filename).read_text(encoding="utf-8"))
scored = {item["task_id"] for item in report["results"]}
new_ids = {task.task_id for task in NEW_TASKS_V3}
assert scored & new_ids == set(), (
"these servers were pinned and run against the v2 matrix at v1.4; a "
"result here would be fabricated"
)
assert report.get("matrix", "v2") == "v2"
def test_the_published_reference_report_covers_the_whole_v3_matrix():
report = json.loads((PUBLISHED / "autocad-mcp-pro.json").read_text(encoding="utf-8"))
scored = {item["task_id"] for item in report["results"]}
assert scored == {task.task_id for task in TASKS_V3}
assert report["matrix"] == "v3"
assert report["summary"]["score"] == 100.0
+168
View File
@@ -0,0 +1,168 @@
"""M5 — `block_create_from_entities` on both engines.
The live backend refused this outright ("not supported in COM backend. Use
system_run_command with _BLOCK instead"), which pushed users at the free-text
escape hatch for something ActiveX does directly: `Blocks.Add` makes the
definition and `Document.CopyObjects` moves entities into it. Meanwhile the
tool's own docstring said the opposite — "works by using AutoCAD's BLOCK command
(COM backend only)" — so the one backend that could not do it was named as the
one that could.
Both engines also used to swallow handles they could not resolve: pass five
handles with two typos and you got `entity_count: 3` and no mention of the two.
The count is now accompanied by what was skipped.
The COM path is unit-tested against a fake ActiveX object. This machine has a
live AutoCAD and connecting to it during development is forbidden, so that path
ships verified at this level only — the CHANGELOG says so rather than implying
hardware coverage.
"""
from __future__ import annotations
import types
import pytest
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
# ── headless ────────────────────────────────────────────────────────────────
async def test_ezdxf_builds_the_definition_from_real_entities(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
circle = await backend.entity_create_circle(5, 5, 2)
result = await backend.block_create_from_entities("BRACKET", [line.handle, circle.handle])
assert result["ok"] is True
assert result["entity_count"] == 2
assert result["skipped"] == []
block = backend._doc.blocks.get("BRACKET")
assert sorted(e.dxftype() for e in block) == ["CIRCLE", "LINE"]
async def test_ezdxf_reports_handles_it_could_not_resolve(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.block_create_from_entities("PARTIAL", [line.handle, "NOSUCH", "ALSONO"])
assert result["entity_count"] == 1
assert result["skipped"] == ["NOSUCH", "ALSONO"], (
"a count with no mention of what was dropped is the same shape of lie "
"as the audit reporting no fixes"
)
async def test_ezdxf_refuses_a_block_with_nothing_in_it(backend):
with pytest.raises(RuntimeError, match="NOSUCH"):
await backend.block_create_from_entities("EMPTY", ["NOSUCH"])
assert "EMPTY" not in backend._doc.blocks, "a failed call must leave no stub behind"
async def test_ezdxf_honours_the_base_point(backend):
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.block_create_from_entities("BASED", [line.handle], base_x=3.0, base_y=4.0)
block = backend._doc.blocks.get("BASED")
assert tuple(block.block.dxf.base_point)[:2] == (3.0, 4.0)
# ── live backend, against a fake ActiveX surface ────────────────────────────
class _FakeBlock:
def __init__(self, name):
self.Name = name
class _FakeDocument:
"""The three ActiveX members the implementation is allowed to touch."""
def __init__(self, handles):
self._handles = handles
self.Blocks = types.SimpleNamespace(Add=self._add_block)
self.copied = None
self.added = None
def _add_block(self, base_point, name):
self.added = (tuple(base_point.value), name)
return _FakeBlock(name)
def HandleToObject(self, handle):
if handle not in self._handles:
raise RuntimeError(f"no object with handle {handle}")
return types.SimpleNamespace(Handle=handle, ObjectName="AcDbLine")
def CopyObjects(self, objects, owner, *rest):
self.copied = ([o.Handle for o in objects.value], owner.Name)
@pytest.fixture
def com_backend(monkeypatch):
"""A ComBackend whose document is a fake — no COM connection is made."""
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
document = _FakeDocument({"2F", "30"})
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_regen", lambda: None)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, document
async def test_com_creates_the_definition_and_copies_into_it(com_backend):
backend, document = com_backend
result = await backend.block_create_from_entities("BRACKET", ["2F", "30"], 3.0, 4.0)
assert result["ok"] is True
assert result["entity_count"] == 2
assert result["skipped"] == []
assert document.added == ((3.0, 4.0, 0.0), "BRACKET"), "base point must reach Blocks.Add"
assert document.copied == (["2F", "30"], "BRACKET")
async def test_com_reports_handles_it_could_not_resolve(com_backend):
backend, document = com_backend
result = await backend.block_create_from_entities("PARTIAL", ["2F", "NOSUCH"])
assert result["entity_count"] == 1
assert result["skipped"] == ["NOSUCH"]
assert document.copied == (["2F"], "PARTIAL")
async def test_com_refuses_a_block_with_nothing_in_it(com_backend):
backend, document = com_backend
with pytest.raises(RuntimeError, match="NOSUCH"):
await backend.block_create_from_entities("EMPTY", ["NOSUCH"])
assert document.copied is None, "nothing may be copied when nothing resolved"
async def test_both_backends_return_the_same_key_set(backend, com_backend):
com, _ = com_backend
line = await backend.entity_create_line(0, 0, 10, 0)
headless = await backend.block_create_from_entities("A", [line.handle])
live = await com.block_create_from_entities("A", ["2F"])
assert headless.keys() == live.keys()
async def test_ezdxf_still_declares_no_capability_gap_here():
"""Both engines can do this now, so it must not look like a boundary."""
features = EzdxfBackend().capabilities().features
assert "block_definition" not in features, (
"a capability key would imply one engine cannot; both can"
)
+447
View File
@@ -0,0 +1,447 @@
"""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"]
+935
View File
@@ -0,0 +1,935 @@
"""``cad_batch`` — the ordered multi-tool executor.
One round trip instead of N, with a later step able to reference an earlier
one's handle so the model never has to echo it back.
Where the win actually comes from, measured on a 17-step mounting-plate task
(ISO layers, 4 outline lines, 2 fillets, 2 snapped midpoints, 5 circles, 2
dimensions) against the real in-memory server:
* **Payload alone: 1.28x** — and *0.74x* with ``verbose=True``. The batched
request is the LARGER of the two (1,579 vs 1,357 chars: every step carries a
``{tool, args, bind}`` wrapper the sequential arm gets free from the protocol
envelope). Batching does not win by making payloads smaller.
* **Agent loop: 9.07x** — 340,447 -> 37,548 input tokens. This is the real
mechanism, and it is *turn elimination*: 18 billed turns become 2, and each
turn re-sends the whole conversation plus the 18,306-token tool prefix.
So the honest framing is that batching multiplies with the discovery lane
rather than beating it — search alone is 18.29x on the same task, and the two
together are 106.99x. Nothing here should claim batching is the biggest lever.
What these tests actually guard is the *dangerous* half of the idea, because
the happy path is the easy half:
* **Privilege.** A single ``cad_batch`` grant must not reach the raw
command/LISP escape hatches. The deny set is derived from the live
``@cad_tool(cost="escape")`` cards rather than hand-listed, so a future
escape hatch is denied the day it is registered, and a denied step refuses
the *whole* batch before anything executes — otherwise ``on_error="continue"``
would turn the denylist into a no-op.
* **Error typing.** The re-entrant call path reformats every non-FastMCP
exception into ``ToolError(f"Error calling tool {name!r}: {e}")``. The
classifier must recover the real cause from the ``__cause__`` chain and
never from the message text.
* **Atomicity honesty.** ezdxf rolls back by restoring a full DXF snapshot;
COM ends an undo mark and fires ``_UNDO B`` at AutoCAD without ever
confirming it landed. The payload must say which one it got, and must not
claim a rollback it did not verify.
* **dry_run.** Validates against each tool's real JSON Schema without
executing a single step.
"""
from __future__ import annotations
import json
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
import server
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def _entity_count(client) -> int:
info = (await client.call_tool("drawing_info", {})).structured_content or {}
return int(info["entity_count"])
async def _batch(client, steps, **kwargs):
payload = {"steps": steps, **kwargs}
return (await client.call_tool("cad_batch", payload)).structured_content
def dumps(obj) -> str:
"""Compact JSON — how the payload is actually charged for."""
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
LINE = "entity_create_line"
def _line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, **extra):
return {"tool": LINE, "args": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, **extra}
# ---------------------------------------------------------------------------
# Registration and metadata
# ---------------------------------------------------------------------------
async def test_cad_batch_is_registered_with_a_discovery_card():
tools = {t.name: t for t in await server._registered_tools()}
assert "cad_batch" in tools, "cad_batch must be a registered tool"
card = (tools["cad_batch"].meta or {})["cad"]
assert card["cost"] == "destructive", "it can run any mutating tool, so it costs the worst case"
assert card["summary"] and "\n" not in card["summary"]
assert card["synonyms"], "the alias corpus must cover it"
async def test_cad_batch_is_filed_under_the_batch_group():
groups = await server._tool_groups()
assert "cad_batch" in groups["batch"]
assert {"entity_batch_create", "entity_batch_modify"} <= set(groups["batch"]), (
"the pre-existing batch tools stay: cad_batch supersedes them in scope, not in the registry"
)
# ---------------------------------------------------------------------------
# The happy path
# ---------------------------------------------------------------------------
async def test_a_three_step_batch_runs_in_one_round_trip(client):
result = await _batch(
client,
[
_line(),
_line(y1=10.0, y2=10.0),
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
],
)
assert result["ok"] is True
assert (result["steps"], result["executed"], result["succeeded"], result["failed"]) == (
3,
3,
3,
0,
)
assert [row["status"] for row in result["results"]] == ["ok", "ok", "ok"]
assert await _entity_count(client) == 3
async def test_a_successful_step_reports_only_its_handle_by_default(client):
result = await _batch(client, [_line()])
payload = result["results"][0]["result"]
assert set(payload) == {"handle"}, (
"an EntityInfo is fully recoverable via entity_get(handle); re-emitting it "
"in every batch row is exactly the cost cad_batch exists to remove"
)
assert payload["handle"]
async def test_verbose_restores_the_full_step_result(client):
result = await _batch(client, [_line()], verbose=True)
payload = result["results"][0]["result"]
assert payload["handle"] and payload["type"] == "LINE"
assert "properties" in payload
async def test_a_result_without_a_handle_is_never_trimmed(client):
"""Compaction only drops what ``entity_get(handle)`` can give back."""
result = await _batch(client, [{"tool": "drawing_info", "args": {}}])
payload = result["results"][0]["result"]
assert payload["entity_count"] == 0
assert payload["name"]
async def test_steps_run_in_the_order_given(client):
"""Reads interleaved with writes see the document as of their own position."""
result = await _batch(
client,
[
_line(),
{"tool": "drawing_info", "args": {}},
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
{"tool": "drawing_info", "args": {}},
],
)
assert result["ok"] is True
assert result["results"][1]["result"]["entity_count"] == 1
assert result["results"][3]["result"]["entity_count"] == 2
# ---------------------------------------------------------------------------
# Binding — the half that removes the handle echo
# ---------------------------------------------------------------------------
async def test_a_bound_handle_feeds_a_later_step(client):
result = await _batch(
client,
[
_line(bind="edge"),
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 5.0, "dy": 5.0}},
],
)
assert result["ok"] is True
assert result["bindings"] == ["edge"]
handle = result["results"][0]["result"]["handle"]
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert got["properties"]["start"][0] == pytest.approx(5.0)
async def test_a_dotted_reference_reaches_into_a_step_result(client):
"""``point_from_snap`` returns {x, y}; without ``$p.x`` that is a round trip."""
result = await _batch(
client,
[
_line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, bind="edge"),
{
"tool": "point_from_snap",
"args": {"handle": "$edge", "snap": "mid"},
"bind": "mid",
},
{
"tool": "entity_create_circle",
"args": {"cx": "$mid.x", "cy": "$mid.y", "radius": 1.0},
},
],
)
assert result["ok"] is True, result["results"]
handle = result["results"][2]["result"]["handle"]
circle = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert circle["properties"]["center"][0] == pytest.approx(5.0)
async def test_a_list_index_is_a_valid_reference_segment():
resolved = server._resolve_batch_ref("$box.handles.1", {"box": {"handles": ["A", "B", "C"]}})
assert resolved == "B"
async def test_a_bare_reference_resolves_to_the_handle():
resolved = server._resolve_batch_ref("$edge", {"edge": {"handle": "2F1", "type": "LINE"}})
assert resolved == "2F1"
async def test_a_bare_reference_to_a_handleless_result_is_a_typed_error():
with pytest.raises(server.BatchReferenceError) as excinfo:
server._resolve_batch_ref("$info", {"info": {"entity_count": 3}})
assert "handle" in str(excinfo.value)
async def test_a_doubled_dollar_escapes_a_literal_dollar(client):
"""Text really does start with ``$`` sometimes; ``$$`` is the way out."""
result = await _batch(
client,
[{"tool": "entity_create_text", "args": {"text": "$$4.50", "x": 0, "y": 0, "height": 2.5}}],
)
handle = result["results"][0]["result"]["handle"]
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
assert got["properties"]["text"] == "$4.50"
async def test_only_a_whole_string_is_a_reference():
"""Partial interpolation is deliberately unsupported — it is ambiguous."""
assert server._substitute_batch_refs("cost is $edge each", {"edge": {"handle": "1"}}) == (
"cost is $edge each"
)
# ---------------------------------------------------------------------------
# dry_run — the real JSON Schema, nothing executed
# ---------------------------------------------------------------------------
async def test_dry_run_validates_without_executing(client):
result = await _batch(client, [_line(), _line(y1=5.0, y2=5.0)], dry_run=True)
assert result["dry_run"] is True
assert result["ok"] is True
assert result["executed"] == 0
assert [row["status"] for row in result["results"]] == ["valid", "valid"]
assert await _entity_count(client) == 0, "a dry run must not draw anything"
async def test_dry_run_rejects_a_missing_required_argument(client):
result = await _batch(client, [{"tool": LINE, "args": {"x1": 0, "y1": 0}}], dry_run=True)
assert result["ok"] is False
row = result["results"][0]
assert row["status"] == "invalid"
assert row["error"]["kind"] == "invalid_args"
assert "x2" in row["error"]["message"]
async def test_dry_run_rejects_an_argument_of_the_wrong_type(client):
result = await _batch(
client,
[{"tool": LINE, "args": {"x1": "over there", "y1": 0, "x2": 1, "y2": 1}}],
dry_run=True,
)
assert result["results"][0]["error"]["kind"] == "invalid_args"
async def test_dry_run_rejects_an_unknown_argument(client):
result = await _batch(
client,
[{"tool": LINE, "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 1, "colour": 3}}],
dry_run=True,
)
assert result["results"][0]["error"]["kind"] == "invalid_args"
async def test_dry_run_names_an_unknown_tool(client):
result = await _batch(client, [{"tool": "entity_create_dragon", "args": {}}], dry_run=True)
row = result["results"][0]
assert row["error"]["kind"] == "unknown_tool"
assert "entity_create_dragon" in row["error"]["message"]
async def test_dry_run_catches_a_forward_reference(client):
"""A step may only reference something an *earlier* step bound."""
result = await _batch(
client,
[
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 1.0, "dy": 0.0}},
_line(bind="edge"),
],
dry_run=True,
)
row = result["results"][0]
assert row["error"]["kind"] == "unresolved_ref"
assert "edge" in row["error"]["message"]
async def test_dry_run_catches_a_reference_nobody_binds(client):
result = await _batch(
client,
[{"tool": "entity_move", "args": {"handle": "$typo", "dx": 1.0, "dy": 0.0}}],
dry_run=True,
)
assert result["results"][0]["error"]["kind"] == "unresolved_ref"
async def test_a_reference_stands_in_for_any_type_during_validation(client):
"""``$mid.x`` is a float at run time and a string at validation time.
The validator must not reject it for that — but it must still check
everything around it, which the following test proves.
"""
result = await _batch(
client,
[
_line(bind="edge"),
{"tool": "point_from_snap", "args": {"handle": "$edge", "snap": "mid"}, "bind": "m"},
{"tool": "entity_create_circle", "args": {"cx": "$m.x", "cy": "$m.y", "radius": 1.0}},
],
dry_run=True,
)
assert result["ok"] is True, result["results"]
async def test_a_reference_does_not_excuse_the_rest_of_the_step(client):
result = await _batch(
client,
[
_line(bind="edge"),
{"tool": "entity_move", "args": {"handle": "$edge", "dx": "sideways", "dy": 0.0}},
],
dry_run=True,
)
assert result["results"][1]["error"]["kind"] == "invalid_args"
async def test_an_invalid_step_stops_a_real_batch_before_anything_runs(client):
with pytest.raises(ToolError) as excinfo:
await _batch(client, [_line(), {"tool": "entity_create_dragon", "args": {}}])
assert "entity_create_dragon" in str(excinfo.value)
assert await _entity_count(client) == 0, "validation must fail closed, before step 1 draws"
async def test_a_malformed_step_is_reported_not_crashed(client):
result = await _batch(client, [{"args": {"x1": 0}}], dry_run=True)
assert result["results"][0]["error"]["kind"] == "malformed_step"
# ---------------------------------------------------------------------------
# Privilege — the deny set
# ---------------------------------------------------------------------------
async def test_the_deny_set_is_derived_from_the_escape_cost_cards():
"""Hand-listing would go stale the day a new escape hatch is registered."""
escapes = {
tool.name
for tool in await server._registered_tools()
if ((tool.meta or {}).get("cad") or {}).get("cost") == "escape"
}
assert escapes, "the registry must still carry escape-cost tools for this to mean anything"
assert server._batch_denied_tools() == escapes | {"cad_batch"}
async def test_the_raw_command_and_lisp_hatches_are_denied():
denied = server._batch_denied_tools()
assert {"system_run_command", "system_run_lisp"} <= denied
@pytest.mark.parametrize("tool", ["system_run_command", "system_run_lisp"])
async def test_a_denied_tool_refuses_the_whole_batch(client, tool):
args = {"command": "_ZOOM E"} if tool.endswith("command") else {"expression": "(+ 1 1)"}
with pytest.raises(ToolError) as excinfo:
await _batch(client, [_line(), {"tool": tool, "args": args}])
message = str(excinfo.value)
assert tool in message
assert "denied" in message.lower() or "not callable" in message.lower()
assert await _entity_count(client) == 0, "a denied step must abort before step 1 draws"
async def test_a_denied_step_is_not_survivable_with_on_error_continue(client):
"""Otherwise the deny set would just be a step you skip past."""
with pytest.raises(ToolError):
await _batch(
client,
[{"tool": "system_run_lisp", "args": {"expression": "(+ 1 1)"}}, _line()],
on_error="continue",
)
assert await _entity_count(client) == 0
async def test_cad_batch_cannot_nest_inside_itself(client):
"""No recursion, and no laundering a denied step through a nested executor."""
result = await _batch(
client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}], dry_run=True
)
assert result["results"][0]["error"]["kind"] == "denied"
with pytest.raises(ToolError, match="cad_batch"):
await _batch(client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}])
async def test_a_tool_with_no_discovery_card_is_not_run_blind(client):
"""Fail closed on unknown provenance: the deny set is computed from the local
registry, but a mounted or transformed provider can supply a tool it never saw.
Every tool this server registers carries a card (there is a coverage gate),
so this costs the native surface nothing."""
@server.mcp.tool(name="_batch_uncarded_probe")
async def _uncarded(ctx=None) -> dict:
return {"ok": True}
try:
result = await _batch(client, [{"tool": "_batch_uncarded_probe", "args": {}}], dry_run=True)
assert result["results"][0]["error"]["kind"] == "denied"
assert "card" in result["results"][0]["error"]["message"]
finally:
server.mcp.local_provider.remove_tool("_batch_uncarded_probe")
async def test_a_dry_run_reports_a_denied_step_rather_than_hiding_it(client):
result = await _batch(
client,
[{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}],
dry_run=True,
)
assert result["ok"] is False
assert result["results"][0]["error"]["kind"] == "denied"
async def test_the_refusal_points_at_the_direct_tool(client):
"""The escape hatch stays reachable — separately, where its own gate applies."""
with pytest.raises(ToolError) as excinfo:
await _batch(client, [{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}])
assert "directly" in str(excinfo.value)
# ---------------------------------------------------------------------------
# The production laundering vector: DISCOVERY_MODE=search
# ---------------------------------------------------------------------------
#
# In search mode the advertised surface is just ``search_tools`` + ``call_tool``,
# and ``call_tool`` invokes any tool by name. That is a general-purpose proxy
# sitting inside the same server as a denylist — i.e. exactly the shape of a way
# around it. The deny set is derived from ``cost="escape"`` cards, and the proxy
# is not a local component, so nothing in the *denylist* stops it; what stops it
# is the fail-closed "no @cad_tool card, no execution" rule.
#
# These pin that, because the coupling is invisible: giving ``call_tool`` a card
# — an entirely reasonable thing to want, so it is costed and discoverable —
# silently reopens the escalation path with no other test failing.
@pytest_asyncio.fixture
async def search_mode(monkeypatch):
"""The real search surface, restored to ``off`` afterwards."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
server._apply_discovery_mode("search")
try:
async with Client(server.mcp) as connected:
yield connected
finally:
server._apply_discovery_mode("off")
async def test_search_mode_advertises_the_proxy_that_makes_this_a_risk(search_mode):
names = {tool.name for tool in await search_mode.list_tools()}
assert names == {"search_tools", "call_tool"}, (
"if the search surface changed, the laundering test below is aiming at the wrong target"
)
@pytest.mark.parametrize("hatch", ["system_run_command", "system_run_lisp"])
async def test_the_search_proxy_cannot_launder_a_denied_hatch(search_mode, hatch):
"""cad_batch -> call_tool -> system_run_* must not execute."""
result = (
await search_mode.call_tool(
"call_tool",
{
"name": "cad_batch",
"arguments": {
"steps": [{"tool": "call_tool", "args": {"name": hatch, "arguments": {}}}],
"dry_run": True,
},
},
)
).structured_content or {}
assert result["ok"] is False
assert result["results"][0]["error"]["kind"] == "denied"
async def test_the_laundered_hatch_is_refused_before_anything_executes(search_mode):
"""Not merely reported — the whole batch is refused, so the proxy never runs."""
with pytest.raises(ToolError) as excinfo:
await search_mode.call_tool(
"call_tool",
{
"name": "cad_batch",
"arguments": {
"steps": [
{
"tool": "call_tool",
"args": {
"name": "system_run_command",
"arguments": {"command": "_ZOOM E"},
},
}
]
},
},
)
assert "refused all 1 steps - nothing was executed" in str(excinfo.value)
async def test_cad_batch_still_works_through_the_search_proxy(search_mode):
"""The denial above must be a denial, not search mode breaking batching."""
result = (
await search_mode.call_tool(
"call_tool",
{
"name": "cad_batch",
"arguments": {
"steps": [{"tool": "drawing_new", "args": {}}, _line()],
},
},
)
).structured_content or {}
assert result["ok"] is True
assert result["succeeded"] == 2
async def test_a_batch_step_may_still_call_the_older_batch_tools(client):
"""The reconciliation: they compose. cad_batch orders, entity_batch_create bulks."""
result = await _batch(
client,
[
{
"tool": "entity_batch_create",
"args": {
"entities": [
{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
{"type": "circle", "cx": 0, "cy": 0, "radius": 1},
]
},
},
_line(),
],
)
assert result["ok"] is True
assert await _entity_count(client) == 3
async def test_a_tool_hidden_by_the_profile_is_not_reachable_through_a_batch(client):
"""cad_batch must not be a way around TOOL_PROFILE or the ENABLE_3D gate."""
result = await _batch(client, [{"tool": "solid_box", "args": {}}], dry_run=True)
assert result["results"][0]["error"]["kind"] == "unknown_tool"
# ---------------------------------------------------------------------------
# Error taxonomy — typed, never string-sniffed
# ---------------------------------------------------------------------------
async def test_a_backend_capability_refusal_is_typed_as_unsupported(client, tmp_path):
result = await _batch(
client,
[{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "part.dwg")}}],
on_error="continue",
)
error = result["results"][0]["error"]
assert error["kind"] == "unsupported"
assert error["capability"] == "dwg", "the capability key must survive the re-entrant wrapper"
async def test_the_classifier_reads_the_cause_chain_not_the_message():
"""The proof that no message text is being sniffed: same words, different type."""
from backends.ezdxf_backend import UnsupportedCapabilityError
liar = RuntimeError("the headless ezdxf backend cannot write DWG (capability 'dwg')")
assert server._classify_batch_error(liar)["kind"] == "failed"
honest = ToolError("Error calling tool 'drawing_save_as': nothing useful here")
honest.__cause__ = UnsupportedCapabilityError("dwg", "no DWG writer")
classified = server._classify_batch_error(honest)
assert classified["kind"] == "unsupported"
assert classified["capability"] == "dwg"
async def test_a_backend_failure_is_typed_as_failed(client):
result = await _batch(
client, [{"tool": "entity_get", "args": {"handle": "NOSUCH"}}], on_error="continue"
)
assert result["results"][0]["error"]["kind"] == "failed"
async def test_a_deliberate_tool_refusal_is_typed_as_refused(client):
result = await _batch(
client,
[{"tool": "drawing_open", "args": {"path": "../../etc/passwd"}}],
on_error="continue",
)
assert result["results"][0]["error"]["kind"] == "refused"
async def test_a_bound_value_of_the_wrong_type_is_typed_at_run_time(client):
"""The one thing dry_run cannot check, reported precisely when it happens."""
result = await _batch(
client,
[
{"tool": "entity_create_text", "args": {"text": "T", "x": 0, "y": 0}, "bind": "t"},
{"tool": "entity_move", "args": {"handle": "$t", "dx": "$t.type", "dy": 0.0}},
],
on_error="continue",
)
assert result["results"][1]["error"]["kind"] == "invalid_args"
async def test_every_reported_kind_is_a_declared_kind(client, tmp_path):
result = await _batch(
client,
[
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "p.dwg")}},
_line(),
],
on_error="continue",
)
kinds = {row["error"]["kind"] for row in result["results"] if row["status"] == "error"}
assert kinds <= set(server.BATCH_ERROR_KINDS)
assert kinds
# ---------------------------------------------------------------------------
# on_error
# ---------------------------------------------------------------------------
async def test_stop_is_the_default_because_both_backends_deliver_it(client):
result = await _batch(client, [_line()])
assert result["on_error"] == "stop"
async def test_stop_leaves_earlier_steps_applied_and_says_so(client):
result = await _batch(
client,
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
on_error="stop",
)
assert result["ok"] is False
statuses = [row["status"] for row in result["results"]]
assert statuses == ["ok", "error", "skipped"]
assert result["skipped"] == 1
assert await _entity_count(client) == 1
assert result["atomicity"]["rolled_back"] is False
assert result["atomicity"]["guarantee"] == "none"
async def test_continue_runs_every_step(client):
result = await _batch(
client,
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
on_error="continue",
)
assert [row["status"] for row in result["results"]] == ["ok", "error", "ok"]
assert result["succeeded"] == 2 and result["failed"] == 1
assert await _entity_count(client) == 2
async def test_continue_does_not_execute_a_step_whose_binding_never_landed(client):
"""A dependent step must not silently run on a stale or missing value."""
result = await _batch(
client,
[
{"tool": "entity_get", "args": {"handle": "NOSUCH"}, "bind": "ghost"},
{"tool": "entity_move", "args": {"handle": "$ghost", "dx": 1.0, "dy": 0.0}},
_line(),
],
on_error="continue",
)
assert result["results"][0]["status"] == "error"
assert result["results"][1]["status"] == "error"
assert result["results"][1]["error"]["kind"] == "unresolved_ref"
assert result["results"][2]["status"] == "ok"
async def test_an_unknown_on_error_mode_is_rejected(client):
with pytest.raises(ToolError):
await _batch(client, [_line()], on_error="pray")
# ---------------------------------------------------------------------------
# Atomicity — honest per backend
# ---------------------------------------------------------------------------
class _Caps:
def __init__(self, mode, supported=True):
from backends.base import CapabilityMap, FeatureCapability
self.map = CapabilityMap(
backend="stub", features={"transactions": FeatureCapability(supported, mode)}
)
def capabilities(self):
return self.map
async def test_a_snapshot_backend_is_reported_as_a_snapshot_guarantee():
guarantee, note = server._batch_rollback_guarantee(_Caps("snapshot"))
assert guarantee == "snapshot"
assert "snapshot" in note.lower()
async def test_an_undo_mark_backend_is_never_called_atomic():
"""COM ends the undo mark and fires ``_UNDO B`` without confirming it landed."""
guarantee, note = server._batch_rollback_guarantee(_Caps("undo_mark"))
assert guarantee == "best_effort_undo"
lowered = note.lower()
assert "not atomic" in lowered, "it must say so, not merely omit the word"
assert "best-effort" in lowered
assert "does not confirm" in lowered
async def test_a_backend_without_transactions_gets_no_guarantee():
guarantee, _ = server._batch_rollback_guarantee(_Caps(None, supported=False))
assert guarantee == "none"
async def test_an_unrecognised_transaction_mode_is_not_assumed_good():
guarantee, _ = server._batch_rollback_guarantee(_Caps("time_travel"))
assert guarantee == "unverified"
async def test_rollback_restores_the_document_on_ezdxf(client):
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
result = await _batch(
client,
[_line(), _line(y1=4.0, y2=4.0), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
on_error="rollback",
)
assert result["ok"] is False
assert await _entity_count(client) == 1, "the two batch lines must be gone"
atomicity = result["atomicity"]
assert atomicity["guarantee"] == "snapshot"
assert atomicity["rolled_back"] is True
assert atomicity["verified"] is True
assert atomicity["before"] == atomicity["after"]
async def test_a_successful_rollback_batch_commits_and_keeps_its_work(client):
result = await _batch(client, [_line(), _line(y1=4.0, y2=4.0)], on_error="rollback")
assert result["ok"] is True
assert result["atomicity"]["rolled_back"] is False
assert result["atomicity"]["committed"] is True
assert await _entity_count(client) == 2
async def test_rollback_reports_the_guarantee_it_actually_got(client):
result = await _batch(client, [_line()], on_error="rollback")
atomicity = result["atomicity"]
assert atomicity["mode"] == "rollback"
assert atomicity["guarantee"] in server.BATCH_ROLLBACK_GUARANTEES
assert atomicity["note"]
async def test_rollback_refuses_when_the_backend_declines_the_checkpoint(client, monkeypatch):
"""A checkpoint we did not open is not one we can roll back to.
This is the live COM path: ``ComBackend.transaction_begin`` *returns*
``{"ok": False, "error": "A transaction is already active"}`` rather than
raising, so a batch that took the return value on faith would run every
step and then report a rollback it never had. Simulated on the headless
backend, whose own transaction stack happily nests.
"""
from backends.ezdxf_backend import EzdxfBackend
async def _decline(self):
return {"ok": False, "error": "A transaction is already active"}
monkeypatch.setattr(EzdxfBackend, "transaction_begin", _decline)
result = await _batch(client, [_line()], on_error="rollback")
assert result["ok"] is False
assert result["executed"] == 0
assert result["atomicity"]["transaction"] is False
assert "already active" in result["atomicity"]["note"]
assert await _entity_count(client) == 0, "nothing may run without the checkpoint"
async def test_a_rollback_batch_does_not_disturb_a_transaction_the_caller_opened(client):
"""The headless backend's checkpoints are a stack; ours must pop only its own."""
await client.call_tool("transaction_begin", {})
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
result = await _batch(
client,
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
on_error="rollback",
)
assert result["atomicity"]["rolled_back"] is True
assert await _entity_count(client) == 1, "the caller's circle survives our rollback"
await client.call_tool("transaction_rollback", {})
assert await _entity_count(client) == 0, "the caller's own checkpoint still works"
async def test_non_rollback_modes_make_no_atomicity_claim(client):
"""And say it in four keys, not in two paragraphs about a rollback that was
never on the table — that boilerplate measured 61% of a short batch's reply."""
for mode in ("stop", "continue"):
result = await _batch(client, [_line()], on_error=mode)
atomicity = result["atomicity"]
assert atomicity["transaction"] is False
assert atomicity["rolled_back"] is False
assert atomicity["guarantee"] == "none"
assert set(atomicity) == {"mode", "guarantee", "transaction", "rolled_back", "note"}
assert "atomic" not in atomicity["note"].lower()
assert len(dumps(atomicity)) < 200
async def test_a_dry_run_opens_no_transaction(client):
result = await _batch(client, [_line()], on_error="rollback", dry_run=True)
assert result["atomicity"]["transaction"] is False
status = (await client.call_tool("system_status", {})).structured_content or {}
assert status.get("transaction_depth", 0) == 0
# ---------------------------------------------------------------------------
# Limits and progress
# ---------------------------------------------------------------------------
async def test_an_empty_batch_is_rejected(client):
with pytest.raises(ToolError):
await _batch(client, [])
async def test_the_step_count_is_capped(client):
with pytest.raises(ToolError) as excinfo:
await _batch(client, [_line()] * (server.MAX_BATCH_STEPS + 1))
assert str(server.MAX_BATCH_STEPS) in str(excinfo.value)
async def test_progress_is_reported_for_every_step(monkeypatch):
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
seen: list[tuple[float, float | None]] = []
async def handler(progress, total, message=None):
seen.append((progress, total))
async with Client(server.mcp, progress_handler=handler) as connected:
await connected.call_tool("drawing_new", {})
await connected.call_tool(
"cad_batch", {"steps": [_line(), _line(y1=2.0, y2=2.0), _line(y1=4.0, y2=4.0)]}
)
assert seen, "a long batch is the canonical case for ctx.report_progress"
assert (3.0, 3.0) in [(p, t) for p, t in seen], "the final tick must report completion"
# ---------------------------------------------------------------------------
# The rollback claim must come from the backend, not from control flow
# ---------------------------------------------------------------------------
async def test_rollback_is_not_claimed_when_the_backend_declines_it(client):
"""A step that commits consumes the checkpoint out from under us.
``transaction_rollback()`` then returns ``{"ok": False, "error": "No active
transaction to rollback"}``. Reporting ``rolled_back: True`` off control
flow would be the same class of lie as writing DXF bytes into a .dwg.
"""
steps = [
_line(),
{"tool": "transaction_commit", "args": {}},
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
]
result = await _batch(client, steps, on_error="rollback")
assert result["ok"] is False
assert result["atomicity"]["rolled_back"] is False, (
"the backend refused the rollback; the payload must not claim it happened"
)
assert await _entity_count(client) == 1, "the line really did survive"
async def test_commit_is_not_claimed_when_the_backend_declines_it(client):
"""Same rule on the success path: a step that already committed leaves
nothing for the batch's own commit to do."""
steps = [_line(), {"tool": "transaction_commit", "args": {}}]
result = await _batch(client, steps, on_error="rollback")
assert result["ok"] is True
assert result["atomicity"]["committed"] is False, (
"the checkpoint was already gone; do not report a commit that did not happen"
)
async def test_a_read_only_finding_is_not_a_failed_step(client):
"""A checker reporting findings must not be mistaken for a step that failed.
``validation_check`` is ``readOnlyHint=True`` and answers
``{"ok": len(issues) == 0}`` — ``ok: False`` there means *it found something*,
not *it could not run*. Treating that as a refusal made a read-only check
trigger a verified rollback that destroyed the geometry the batch had just
drawn. A mutator's ``ok: False`` still means it did not mutate.
"""
steps = [_line(), {"tool": "validation_check", "args": {}}]
result = await _batch(client, steps, on_error="rollback")
assert result["succeeded"] == 2
assert result["failed"] == 0
assert result["ok"] is True
assert result["atomicity"]["rolled_back"] is False
assert await _entity_count(client) == 1, "the read-only check must not undo the line"
+153
View File
@@ -0,0 +1,153 @@
"""``CAD_PROGID`` — which COM application the live backend attaches to.
The ProgID was hardcoded twice inside ``_acad_app``, so the only CAD this server
could ever drive was AutoCAD itself. GstarCAD, ZWCAD and BricsCAD all expose the
same ActiveX object model under a different ProgID, and the connection is the
only thing standing between them and the existing 100+ COM methods.
Two things make this more delicate than a string swap, and both are tested here:
* **Nothing may connect.** These tests run on a machine with AutoCAD installed.
The ``Dispatch`` branch is not a passive probe — it COM-launches the
application and sets ``Visible = True``. So the fake below records the ProgID
it was *asked* for and returns a stub; every assertion is on the recorded
calls, never on a returned object. ``raising=False`` on the ``win32com`` patch
is load-bearing: that name only exists in the module when the pywin32 imports
succeeded, so without it these tests would pass on a Windows dev box and error
on Linux CI.
* **The cache would hide the bug.** ``_acad_app`` short-circuits on
``_COM_STATE["app"]``, so a warm cache makes it perform zero lookups and the
test asserts nothing at all. The fixture rebinds ``_COM_STATE`` to a fresh
dict rather than mutating it — that also guarantees a test can never leave a
fake application object in the real process-wide cache.
An unknown ProgID fails loudly rather than falling back to AutoCAD. Unlike
``TOOL_PROFILE`` and ``DISCOVERY_MODE``, which fall back with a warning because
they have closed enums, this is an open string: an unrecognised value is not
"invalid", it is "unverifiable by us", and it has no principled substitute. The
blast radius decides it — refusing costs a readable startup error, while falling
back launches and attaches to a live AutoCAD the user explicitly said they were
not using.
"""
from __future__ import annotations
import types
import pytest
import config
from backends import com_backend as cb
DEFAULT = "AutoCAD.Application"
OTHER = "BricscadApp.AcadApplication"
@pytest.fixture
def com_calls(monkeypatch):
"""Record every ProgID ``_acad_app`` asks for, and connect to nothing."""
calls: list[tuple[str, str]] = []
def _get_active_object(progid):
calls.append(("GetActiveObject", progid))
raise OSError("no running instance (fake)")
def _dispatch(progid):
calls.append(("Dispatch", progid))
return types.SimpleNamespace(Visible=False)
monkeypatch.setattr(
cb,
"win32com",
types.SimpleNamespace(
client=types.SimpleNamespace(
GetActiveObject=_get_active_object,
Dispatch=_dispatch,
)
),
raising=False,
)
monkeypatch.setattr(cb, "_COM_STATE", {})
return calls
# -- the setting ------------------------------------------------------------
def test_the_default_progid_is_autocad(monkeypatch):
monkeypatch.delenv("CAD_PROGID", raising=False)
assert config.settings.cad_progid == DEFAULT
def test_the_setting_is_read_live_and_stripped(monkeypatch):
monkeypatch.setenv("CAD_PROGID", f" {OTHER} ")
assert config.settings.cad_progid == OTHER
def test_case_is_preserved_because_the_value_is_shown_to_humans(monkeypatch):
"""``gstarcad.application`` in an error message reads like a typo we made."""
monkeypatch.setenv("CAD_PROGID", "GstarCAD.Application")
assert config.settings.cad_progid == "GstarCAD.Application"
def test_an_empty_value_falls_back_to_the_default(monkeypatch):
monkeypatch.setenv("CAD_PROGID", " ")
assert config.settings.cad_progid == DEFAULT
def test_assigning_to_the_setting_fails_loudly(monkeypatch):
"""Same reasoning as ``backend``: a silent no-op assignment reads as control."""
monkeypatch.setenv("CAD_PROGID", OTHER)
with pytest.raises(AttributeError):
config.settings.cad_progid = DEFAULT
assert config.settings.cad_progid == OTHER
# -- what the connection actually asks for ----------------------------------
def test_unset_attaches_to_autocad_on_both_paths(monkeypatch, com_calls):
monkeypatch.delenv("CAD_PROGID", raising=False)
cb._acad_app()
assert com_calls == [("GetActiveObject", DEFAULT), ("Dispatch", DEFAULT)]
def test_the_configured_progid_reaches_both_call_sites(monkeypatch, com_calls):
"""The Dispatch fallback is the one that launches an application.
Honouring the setting on ``GetActiveObject`` and then quietly launching
AutoCAD on the fallback would be worse than ignoring the setting entirely.
"""
monkeypatch.setenv("CAD_PROGID", OTHER)
cb._acad_app()
assert [progid for _, progid in com_calls] == [OTHER, OTHER]
assert DEFAULT not in [progid for _, progid in com_calls]
def test_a_failed_connection_names_the_progid_it_tried(monkeypatch, com_calls):
def _boom(progid):
com_calls.append(("Dispatch", progid))
raise OSError("class not registered (fake)")
monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom)
monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered")
with pytest.raises(RuntimeError) as excinfo:
cb._acad_app()
message = str(excinfo.value)
assert "Nope.NotRegistered" in message, "a bare 'cannot connect to AutoCAD' misdirects"
assert "CAD_PROGID" in message, "the message must say which knob to turn"
assert DEFAULT not in [progid for _, progid in com_calls], "must never fall back to AutoCAD"
def test_a_failed_connection_does_not_poison_the_cache(monkeypatch, com_calls):
def _boom(progid):
raise OSError("class not registered (fake)")
monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom)
monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered")
with pytest.raises(RuntimeError):
cb._acad_app()
assert "app" not in cb._COM_STATE
+5 -4
View File
@@ -33,11 +33,12 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
"reason": None,
}
assert caps["features"]["mleader"]["mode"] == "composite"
# v1.4: paper-space layouts/viewports are native on both backends;
# projecting model content through viewports stays COM-only, and 3D
# ACIS solids remain impossible headlessly.
# Paper-space layouts/viewports are native on both backends. Viewport
# model projection was declared COM-only in v1.4 and is not — measured in
# tests/test_viewport_ops.py, which renders a sheet holding nothing but a
# viewport. 3D ACIS solids do remain impossible headlessly.
assert caps["features"]["paper_space"]["supported"] is True
assert caps["features"]["viewport_render"]["supported"] is False
assert caps["features"]["viewport_render"]["supported"] is True
assert caps["features"]["solid_3d"]["supported"] is False
+118
View File
@@ -0,0 +1,118 @@
"""``@capability``: the escape hatch that is not allowed to become a hole.
M7 split a 1854-line ABC with 112 abstract methods into per-domain contracts.
The split makes contract changes navigable; it does not fix the reason contract
changes hurt, which is that every method is an `@abstractmethod`, so adding one
makes *both* backends fail at instantiation until both are written — and two
test modules construct `ComBackend` at import time, so a half-finished edit
takes the whole suite red.
`@capability` gives such a method a default. The default is the dangerous part,
so it is pinned here:
* It **raises** `UnsupportedCapabilityError`, never returns a quiet
`{"ok": False}`. A method nobody implemented must be indistinguishable from a
deliberate refusal, not from a soft failure — the v1.5.0 honesty pass exists
because things that quietly did nothing looked like things that worked.
* Its capability key is **declared in both backends' capability maps**. Without
this test the decorator would be a way to ship refusals that
`system_capabilities` never mentions, which is worse than the build break it
replaces: a client that pre-checks the feature map would be told the call is
fine and then be refused.
* Overriding it is ordinary subclassing, so an implementing backend pays
nothing.
"""
from __future__ import annotations
import inspect
import pytest
from backends.base import AutoCADBackend, UnsupportedCapabilityError
from backends.capability import capability_keys, is_capability_default
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
# Marked per-test rather than module-wide: half of these are plain registry
# checks, and pytest-asyncio warns on a sync test carrying the mark.
asyncio_test = pytest.mark.asyncio
def _feature_keys(backend) -> set[str]:
return set(backend.capabilities().to_dict()["features"])
# ── the registry is real ────────────────────────────────────────────────────
def test_at_least_one_method_uses_the_decorator():
"""An unused abstraction is dead weight; this keeps the gate meaningful."""
assert capability_keys(), "no @capability methods — delete the decorator or use it"
def test_every_declared_key_exists_in_both_capability_maps():
"""The gate. A refusal the feature map never mentions is a lie by omission."""
ezdxf_keys = _feature_keys(EzdxfBackend())
com_keys = _feature_keys(ComBackend())
for key in capability_keys():
assert key in ezdxf_keys, f"@capability({key!r}) is undeclared on the ezdxf backend"
assert key in com_keys, f"@capability({key!r}) is undeclared on the COM backend"
def test_the_contract_still_exposes_every_method_it_used_to():
"""The mixin split moved code; it must not have dropped any."""
for names in capability_keys().values():
for name in names:
assert callable(getattr(AutoCADBackend, name, None)), f"{name} vanished in the split"
# ── the default behaves like a deliberate refusal ───────────────────────────
@asyncio_test
async def test_a_backend_that_does_not_implement_it_refuses_with_the_key():
backend = ComBackend()
assert is_capability_default(ComBackend, "entity_change_space"), (
"fixture assumption: the COM backend inherits the default here"
)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_change_space(["1A"], "2B")
assert excinfo.value.capability == "chspace"
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert payload["capability"] == "chspace"
@asyncio_test
async def test_the_refusal_names_the_backend_and_says_what_to_do_instead():
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await ComBackend().entity_change_space(["1A"], "2B")
message = str(excinfo.value)
assert "com" in message
assert "ezdxf" in message, "a refusal that does not name the way out is half a refusal"
@asyncio_test
async def test_the_default_never_returns_a_soft_failure():
"""The design decision this file exists to defend.
Returning ``{"ok": False, "capability": ...}`` would satisfy every caller's
type expectations and silently mean "nothing happened".
"""
try:
result = await ComBackend().entity_change_space(["1A"], "2B")
except UnsupportedCapabilityError:
return
pytest.fail(f"the default returned {result!r} instead of raising")
@asyncio_test
async def test_an_implementing_backend_is_unaffected(backend):
assert not is_capability_default(EzdxfBackend, "entity_change_space")
assert inspect.iscoroutinefunction(EzdxfBackend.entity_change_space)
result = await backend.entity_change_space([], "nope")
assert result["ok"] is False
assert "error" in result, "the real implementation still does its own validation"
+158
View File
@@ -0,0 +1,158 @@
"""T0.7 — a capability refusal has to survive the trip to the client.
The backends know precisely why they cannot do a thing: they raise
``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
key of ``capabilities().features`` so a client can pre-check the same boundary
through ``system_capabilities``. That type does not reach anybody.
``FastMCP.call_tool`` rewraps any non-``FastMCPError`` as
``ToolError(f"Error calling tool {name!r}: {e}") from e``; the ``from e`` keeps
the cause alive **in process** — which is the only reason ``cad_batch`` can
classify it — but ``__cause__`` does not serialise. Over the wire the client got
an English sentence and nothing else, so "this backend cannot do that" was
indistinguishable from "your arguments were wrong" except by substring matching.
Subclassing ``FastMCPError`` does not fix this; it changes nothing the client can
observe. The one channel that carries a machine-readable payload *and* the error
flag is ``ToolResult(structured_content=..., is_error=True)``, so a single
middleware — the last place the exception type is still knowable — converts the
refusal, and no tool function changes at all.
The other half is ``cad_batch``: a tool may decline *by value* rather than by
raising, and counting that as a succeeded step is the same class of lie as
writing DXF bytes into a ``.dwg`` or reporting a rollback the backend refused.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from backends.base import UnsupportedCapabilityError
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
# ── the wire contract ───────────────────────────────────────────────────────
async def test_refusal_reaches_the_client_as_a_machine_readable_payload(client, tmp_path):
target = tmp_path / "part.dwg"
result = await client.call_tool("drawing_save_as", {"path": str(target)}, raise_on_error=False)
assert result.is_error is True
payload = result.structured_content
assert isinstance(payload, dict), "a refusal must not arrive as prose only"
assert payload["ok"] is False
assert payload["kind"] == "unsupported"
assert payload["capability"] == "dwg"
assert payload["tool"] == "drawing_save_as"
assert payload["backend"] == "ezdxf"
# The prose channel still works for clients that only read text, and it is
# the FULL sentence — a refusal that names no way forward is not help.
assert payload["error"] in result.content[0].text
assert ".dxf" in payload["error"]
assert not target.exists(), "a refusal must not leave a mislabelled file behind"
async def test_the_capability_is_a_key_a_client_can_pre_check(client, tmp_path):
"""The whole point of the key: ask before calling, get the same answer."""
caps = (await client.call_tool("system_capabilities", {})).structured_content or {}
features = caps["features"]
result = await client.call_tool(
"drawing_save_as", {"path": str(tmp_path / "x.dwg")}, raise_on_error=False
)
capability = result.structured_content["capability"]
assert features[capability]["supported"] is False
async def test_ordinary_errors_are_left_exactly_as_they_were(client):
"""The middleware must not turn every failure into a capability refusal."""
result = await client.call_tool("entity_get", {"handle": "NOSUCH"}, raise_on_error=False)
assert result.is_error is True
payload = result.structured_content
assert not (isinstance(payload, dict) and payload.get("kind") == "unsupported")
async def test_a_successful_call_is_untouched(client):
result = await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 5, "y2": 0})
assert result.is_error is False
assert result.structured_content["handle"]
# ── one refusal mechanism, not two ──────────────────────────────────────────
async def test_solid_tools_raise_the_typed_refusal_instead_of_returning_a_dict():
"""Returning ``{"ok": False}`` puts a refusal outside the middleware's reach.
The payload the client sees is unchanged; what flips is ``is_error``, from a
quiet False to an honest True.
"""
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_new()
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.solid_box(0, 0, 0, 10, 10, 10)
assert excinfo.value.capability == "solid_3d"
assert excinfo.value.to_dict() == {
"ok": False,
"error": str(excinfo.value),
"capability": "solid_3d",
}
await backend.disconnect()
async def test_the_refusal_type_lives_on_the_shared_base():
"""Both backends must be able to raise the same class, not two look-alikes."""
from backends.ezdxf_backend import UnsupportedCapabilityError as from_ezdxf
assert from_ezdxf is UnsupportedCapabilityError
# ── cad_batch: a declined step is not a succeeded step ──────────────────────
async def _batch(client, steps, **kwargs):
payload = {"steps": steps, **kwargs}
return (await client.call_tool("cad_batch", payload)).structured_content
async def test_batch_does_not_count_a_step_that_declined_by_value_as_succeeded(client):
"""``drawing_redo`` with nothing to redo returns ``{"ok": False}`` and did nothing."""
result = await _batch(
client,
[
{"tool": "entity_create_line", "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 0}},
{"tool": "drawing_redo", "args": {}},
],
on_error="continue",
)
assert result["succeeded"] == 1, "the redo did nothing; it cannot be a success"
assert result["failed"] == 1
redo_row = result["results"][1]
assert redo_row["status"] == "error"
assert redo_row["error"]["kind"] in {"refused", "unsupported"}
+319
View File
@@ -0,0 +1,319 @@
"""M6 — CHSPACE: moving geometry between model space and a sheet.
AutoCAD's CHSPACE moves objects *through* a viewport, scaling and translating
them by that viewport's zoom so they look identical on screen before and after.
ezdxf's `move_to_layout` does the move and applies **no geometric change at
all** — so the obvious implementation reports `ok: true`, produces a 100 mm
feature as 100 mm of paper inside a 1:2 viewport, and writes a file that audits
perfectly clean. That is the exact class of silent lie this release exists to
remove, so the transform is the tool, not a nicety.
The matrix is `viewport.get_transformation_matrix()`, not a hand-rolled
`translate(-view_center) @ scale @ translate(center)`. The hand-rolled version
ignores `view_target_point`, and with a target point set it misplaces geometry
by tens of millimetres while `is_top_view` is still True and the twist angle is
still zero — i.e. past every guard one would naturally write. The regression
test below pins the result against what ezdxf's own rendering pipeline projects.
What this refuses, and why each refusal is not pedantry:
* **Non-top views and twisted viewports** — ezdxf returns a meaningless
top-view matrix for the first with no complaint, and rotates about the paper
origin for the second.
* **Dimensions** — `transform` halves `get_measurement()` while the baked
dimension-block text still reads the old value. Whichever the consumer
trusts, the other is a lie, so a dimension moves only with
`freeze_dimensions=True`, which bakes the pre-transform measurement into the
text first.
* **ACIS bodies** — `3DSOLID`, `BODY`, `REGION` and `SURFACE` accept
`transform()` and move nothing; the matrix is parked in a pending
transformation the headless engine cannot apply.
* **Tables and proxies** — `ACAD_TABLE`, `ACAD_PROXY_ENTITY` and `OLE2FRAME`
raise `NotImplementedError` from `transform()`, and `hasattr(e, "transform")`
is `True` for all of them, so a `hasattr` dispatch does not filter them out.
* **Viewports** — ezdxf will happily move one into model space; AutoCAD's
CHSPACE never does.
* **Entities already in the target space** — `move_to_layout` to the same
layout silently succeeds, and a no-op must not read as a move.
Clipping is *flagged*, not refused: AutoCAD also lets you move geometry that
falls outside the viewport, so each moved entity reports `inside_viewport` and
`on_sheet` rather than being rejected.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
async def _sheet_with_viewport(backend, *, scale=0.5, name=SHEET, **vp_dxf):
"""An A3 sheet with one 1:2 viewport centred on the page."""
await backend.layout_create(name)
layout = backend._doc.layouts.get(name)
layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
created = await backend.viewport_create(name, 210, 148.5, 200, 150, 50, 25, scale=scale)
viewport = backend._doc.entitydb.get(created["handle"])
for key, value in vp_dxf.items():
viewport.dxf.set(key, value)
return created["handle"], viewport
def _layout_of(backend, handle: str) -> str:
"""The layout tab an entity lives on, by owning block record."""
doc = backend._doc
owner = doc.entitydb.get(handle).dxf.owner
if owner == doc.modelspace().block_record.dxf.handle:
return "Model"
for name in doc.layouts.names():
if name != "Model" and doc.layouts.get(name).block_record.dxf.handle == owner:
return name
return owner
# ── the transform is the point ──────────────────────────────────────────────
async def test_moving_to_paper_scales_the_geometry_by_the_viewport(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
line = await backend.entity_create_line(50, 25, 150, 25) # 100 mm through the view centre
result = await backend.entity_change_space([line.handle], vp)
assert result["ok"] is True
assert result["moved"][0]["handle"] == line.handle
moved = backend._doc.entitydb.get(line.handle)
length = (moved.dxf.end - moved.dxf.start).magnitude
assert length == pytest.approx(50.0), "a 100 mm model line is 50 mm of paper at 1:2"
async def test_the_moved_geometry_lands_where_the_renderer_projects_it(backend):
"""Regression test for the hand-rolled matrix that ignores view_target_point.
With ``view_target_point`` set, the naive
``translate(-view_center) @ scale @ translate(center)`` misplaces the view
centre by 25 mm while ``is_top_view`` is True and the twist is zero — so no
guard catches it and only this comparison does.
"""
from ezdxf.math import Vec3
vp, viewport = await _sheet_with_viewport(backend, scale=0.5, view_target_point=(50, 25, 0))
expected = viewport.get_transformation_matrix().transform(Vec3(60, 30, 0))
line = await backend.entity_create_line(60, 30, 70, 30)
await backend.entity_change_space([line.handle], vp)
moved = backend._doc.entitydb.get(line.handle)
assert moved.dxf.start.x == pytest.approx(expected.x)
assert moved.dxf.start.y == pytest.approx(expected.y)
async def test_the_move_preserves_the_handle(backend):
"""Every tool in this server addresses entities by handle."""
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
await backend.entity_change_space([line.handle], vp)
info = await backend.entity_get(line.handle)
assert info.handle == line.handle
assert _layout_of(backend, line.handle) == SHEET
async def test_a_round_trip_returns_the_original_coordinates(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
line = await backend.entity_create_line(50, 25, 150, 25)
await backend.entity_change_space([line.handle], vp, direction="to_paper")
await backend.entity_change_space([line.handle], vp, direction="to_model")
moved = backend._doc.entitydb.get(line.handle)
assert moved.dxf.start.x == pytest.approx(50.0)
assert moved.dxf.end.x == pytest.approx(150.0)
assert _layout_of(backend, line.handle) == "Model"
async def test_the_reported_scale_matches_the_viewport(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.25)
line = await backend.entity_create_line(50, 25, 90, 25)
result = await backend.entity_change_space([line.handle], vp)
assert result["scale"] == pytest.approx(0.25)
assert result["viewport"] == vp
# ── what it refuses ─────────────────────────────────────────────────────────
async def test_a_twisted_viewport_is_refused(backend):
"""ezdxf rotates about the paper origin; the alternative is unvalidatable."""
vp, _ = await _sheet_with_viewport(backend, view_twist_angle=30.0)
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle], vp)
assert result["ok"] is False
assert "twist" in result["error"].lower()
assert _layout_of(backend, line.handle) == "Model", "a refused move must not move anything"
async def test_a_non_top_view_viewport_is_refused(backend):
"""ezdxf hands back a meaningless top-view matrix with no complaint."""
vp, _ = await _sheet_with_viewport(backend, view_direction_vector=(1, 1, 1))
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle], vp)
assert result["ok"] is False
assert _layout_of(backend, line.handle) == "Model"
async def test_a_viewport_handle_that_is_not_a_viewport_is_refused(backend):
await _sheet_with_viewport(backend)
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.entity_change_space([line.handle], line.handle)
assert result["ok"] is False
async def test_an_unfrozen_dimension_is_refused(backend):
"""transform() halves the measurement while the block text keeps the old one."""
vp, _ = await _sheet_with_viewport(backend)
dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40)
result = await backend.entity_change_space([dim.handle], vp)
assert result["ok"] is True
assert result["moved"] == []
reason = result["refused"][0]["reason"]
assert "dimension" in reason.lower() and "freeze_dimensions" in reason
async def test_freezing_bakes_the_measurement_before_scaling(backend):
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40)
result = await backend.entity_change_space([dim.handle], vp, freeze_dimensions=True)
assert result["ok"] is True
row = result["moved"][0]
assert row["frozen_text"] == "100"
assert backend._doc.entitydb.get(dim.handle).dxf.text == "100"
@pytest.mark.parametrize("maker", ["add_body", "add_3dsolid", "add_region"])
async def test_acis_bodies_are_refused_because_transform_moves_nothing(backend, maker):
vp, _ = await _sheet_with_viewport(backend)
entity = getattr(backend._doc.modelspace(), maker)()
result = await backend.entity_change_space([entity.dxf.handle], vp)
assert result["moved"] == []
assert entity.dxftype() in result["refused"][0]["reason"]
async def test_moving_a_viewport_itself_is_refused(backend):
vp, _ = await _sheet_with_viewport(backend)
other, _ = await _sheet_with_viewport(backend, name="Second")
result = await backend.entity_change_space([other], vp)
assert result["moved"] == []
assert "VIEWPORT" in result["refused"][0]["reason"]
async def test_an_entity_already_in_the_target_space_is_refused(backend):
"""`move_to_layout` to the same layout silently succeeds."""
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
await backend.entity_change_space([line.handle], vp)
result = await backend.entity_change_space([line.handle], vp)
assert result["moved"] == []
assert "already" in result["refused"][0]["reason"].lower()
async def test_an_entity_on_a_different_sheet_is_refused(backend):
"""`move_to_layout` raises a raw DXFValueError from deep inside ezdxf here.
Worse, it raises *after* the transform has already been applied: the failed
call left a line that had been scaled and translated by the viewport and
then stayed exactly where it was, on the wrong sheet.
"""
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
await backend.layout_create("Other-Sheet")
await backend.layout_set_current("Other-Sheet")
line = await backend.entity_create_line(0, 0, 10, 0)
await backend.layout_set_current("Model")
before = backend._doc.entitydb.get(line.handle).dxf.end
result = await backend.entity_change_space([line.handle], vp, direction="to_model")
assert result["moved"] == []
reason = result["refused"][0]["reason"]
assert "Other-Sheet" in reason
assert backend._doc.entitydb.get(line.handle).dxf.end == before, (
"a refused entity must not be left rescaled"
)
assert _layout_of(backend, line.handle) == "Other-Sheet"
async def test_one_bad_handle_does_not_rescale_the_good_ones_and_abort(backend):
"""A raw exception mid-loop would leave the batch half-applied."""
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
await backend.layout_create("Other-Sheet")
await backend.layout_set_current("Other-Sheet")
stray = await backend.entity_create_line(0, 0, 10, 0)
await backend.layout_set_current("Model")
good = await backend.entity_create_line(50, 25, 150, 25)
result = await backend.entity_change_space([stray.handle, good.handle], vp)
assert [row["handle"] for row in result["moved"]] == [good.handle]
assert result["refused"][0]["handle"] == stray.handle
assert _layout_of(backend, good.handle) == SHEET
async def test_an_unknown_handle_is_reported_without_stopping_the_others(backend):
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle, "DEADBEEF"], vp)
assert [row["handle"] for row in result["moved"]] == [line.handle]
assert result["refused"][0]["handle"] == "DEADBEEF"
async def test_an_unknown_direction_is_refused(backend):
vp, _ = await _sheet_with_viewport(backend)
line = await backend.entity_create_line(50, 25, 100, 25)
result = await backend.entity_change_space([line.handle], vp, direction="sideways")
assert result["ok"] is False
async def test_no_handles_is_refused_rather_than_reported_as_success(backend):
vp, _ = await _sheet_with_viewport(backend)
result = await backend.entity_change_space([], vp)
assert result["ok"] is False
# ── what it flags rather than refuses ───────────────────────────────────────
async def test_geometry_outside_the_viewport_is_flagged_not_refused(backend):
"""AutoCAD moves it too; the caller just has to be told it left the sheet."""
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
inside = await backend.entity_create_line(50, 25, 60, 25)
outside = await backend.entity_create_line(800, 25, 900, 25)
result = await backend.entity_change_space([inside.handle, outside.handle], vp)
rows = {row["handle"]: row for row in result["moved"]}
assert rows[inside.handle]["inside_viewport"] is True
assert rows[inside.handle]["on_sheet"] is True
assert rows[outside.handle]["inside_viewport"] is False
assert rows[outside.handle]["on_sheet"] is False
+132
View File
@@ -0,0 +1,132 @@
"""``ComBackend._run`` deadline honesty — the STA executor must only be rebuilt
when *our* deadline actually expired.
On Python 3.11+ ``TimeoutError`` **is** ``asyncio.TimeoutError`` (and an
``OSError`` subclass), so a wrapped COM call that raises ``TimeoutError`` itself
— a socket read against a dead network share, a helper that re-raises one —
arrives at ``except TimeoutError`` looking exactly like a blown deadline. Taking
it at face value told the user AutoCAD "did not respond within 60s" for a call
that failed instantly, and (far worse than the ezdxf equivalent) threw away the
live single-thread STA executor and the cached AutoCAD connection with it.
These tests exercise the real ``_run`` — a plain ``ThreadPoolExecutor`` stands in
for the COM apartment, no AutoCAD and no win32 calls involved.
"""
from __future__ import annotations
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
import backends.com_backend as cb
import config
from backends.com_backend import ComBackend
pytestmark = pytest.mark.asyncio
@pytest.fixture
def com_backend():
"""A ComBackend wired to a real (AutoCAD-free) single-thread executor."""
b = ComBackend()
# No initializer: _com_init would CoInitialize a real STA apartment.
b._executor = ThreadPoolExecutor(max_workers=1)
b._connected = True
yield b
if b._executor is not None:
b._executor.shutdown(wait=False)
b._executor = None
def _dead_share():
"""Stand-in for a COM/file call that raises TimeoutError of its own accord."""
raise TimeoutError("the network share went away")
async def test_call_raising_TimeoutError_is_not_blamed_on_the_deadline(com_backend, monkeypatch):
"""A TimeoutError *from the call* is an ordinary failure, not our deadline."""
monkeypatch.setattr(config.settings, "com_call_timeout", 30)
started = time.monotonic()
with pytest.raises(TimeoutError, match="network share") as excinfo:
await com_backend._run(_dead_share)
assert time.monotonic() - started < 5, "it failed instantly; nothing waited 30s"
assert "did not respond" not in str(excinfo.value)
assert not isinstance(excinfo.value, RuntimeError), "the original error must survive"
async def test_call_raising_TimeoutError_keeps_the_sta_executor(com_backend, monkeypatch):
"""Rebuilding the apartment on an ordinary error drops a live AutoCAD session."""
monkeypatch.setattr(config.settings, "com_call_timeout", 30)
monkeypatch.setitem(cb._COM_STATE, "app", object())
live = com_backend._executor
with pytest.raises(TimeoutError):
await com_backend._run(_dead_share)
assert com_backend._executor is live, "the STA executor must not be rebuilt"
assert com_backend._connected is True, "the AutoCAD connection must not be dropped"
assert "app" in cb._COM_STATE, "the cached COM app must survive an ordinary error"
assert await asyncio.wait_for(com_backend._run(lambda: "next"), timeout=5) == "next"
async def test_disabled_timeout_still_surfaces_a_callable_TimeoutError(com_backend, monkeypatch):
"""With COM_CALL_TIMEOUT=0 there is no deadline at all to blame."""
monkeypatch.setattr(config.settings, "com_call_timeout", 0)
live = com_backend._executor
with pytest.raises(TimeoutError, match="network share"):
await com_backend._run(_dead_share)
assert com_backend._executor is live
assert com_backend._connected is True
async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeypatch):
"""The real overrun path is deliberate and must keep working: the worker is
stuck inside an uncancellable COM call, so it is abandoned and replaced."""
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
monkeypatch.setitem(cb._COM_STATE, "app", object())
stuck = com_backend._executor
entered = threading.Event()
def _hang():
entered.set()
time.sleep(2.0)
return "too late"
started = time.monotonic()
with pytest.raises(RuntimeError, match="did not respond") as excinfo:
await com_backend._run(_hang)
assert entered.is_set()
assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out"
assert isinstance(excinfo.value.__cause__, TimeoutError)
assert com_backend._executor is not stuck, "the stuck apartment must be replaced"
assert com_backend._connected is False
assert "app" not in cb._COM_STATE, "the cached app belongs to the abandoned thread"
# The anti-deadlock guarantee: the next call runs on the fresh executor
# instead of queueing behind the still-sleeping worker.
monkeypatch.setattr(config.settings, "com_call_timeout", 10)
assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh"
async def test_ordinary_errors_are_unchanged(com_backend, monkeypatch):
"""Sanity: non-timeout failures never touched the executor and still don't."""
monkeypatch.setattr(config.settings, "com_call_timeout", 5)
live = com_backend._executor
def _boom():
raise ValueError("nope")
with pytest.raises(ValueError, match="nope"):
await com_backend._run(_boom)
assert com_backend._executor is live
assert await com_backend._run(lambda x: x * 2, 21) == 42
+210
View File
@@ -0,0 +1,210 @@
"""Gates for the AutoCAD command / synonym alias corpus (``discovery.aliases``).
FastMCP's ``BM25SearchTransform`` indexes only tool name + description +
parameter names + parameter descriptions (see
``fastmcp/server/transforms/search/base.py::_extract_searchable_text``).
AutoCAD command names appear in none of those for this server, so a drafter
searching for ``BPOLY`` or ``QSELECT`` scores df=0 and gets nothing back.
These tests both *prove* that gap is real against the live registry and lock the
corpus that closes it to the live tool list, so the corpus can never silently
drift away from the registered tools.
"""
from __future__ import annotations
import re
import pytest
import server
from discovery.aliases import (
SHARED_ACAD_COMMANDS,
TOOL_ALIASES,
ToolAliases,
alias_text,
aliases_for,
)
# AutoCAD command tokens: uppercase alphanumerics, no spaces, at least two
# characters (single-letter aliases like "L"/"C" are deliberately excluded --
# they are pure BM25 noise and collide with ordinary prose).
_ACAD_RE = re.compile(r"^[A-Z][A-Z0-9]+$")
# Commands proven below to be absent from every live tool's searchable text.
#
# Deliberately NOT in this list, and why:
# FILLET -- already present: the tool is literally named ``entity_fillet``.
# XCLIP -- the server has no xref/clipping support at all; any mapping
# would misroute the search, so the corpus honestly omits it.
# REVCLOUD -- no revision-cloud tool exists; a revcloud would have to be
# hand-built from arcs, so no single tool is the right answer.
#: AutoCAD commands that appear nowhere in the live tool text, so a
#: description-only ranker scores them df=0 and returns nothing.
#:
#: BPOLY and QSELECT used to be here and were removed in v1.5.0 (M8), when
#: `boundary_trace` and `selection_filter` landed and named those commands in
#: their own docstrings. The alias corpus is no longer the *only* way to reach
#: them, and saying otherwise would overstate what the corpus is worth. Two
#: commands moving out of this list is the honest cost of the drawing tools
#: growing to cover them — the corpus still carries the rest.
DF0_COMMANDS = (
"PEDIT",
"WBLOCK",
"OVERKILL",
"LAYTRANS",
"MATCHPROP",
)
async def _registered_tools() -> list:
return [t for t in await server._registered_tools() if getattr(t, "name", None)]
async def _registered_names() -> set[str]:
return {t.name for t in await _registered_tools()}
def _searchable_text(tool) -> str:
"""Mirror of fastmcp's ``_extract_searchable_text`` -- what BM25 indexes."""
parts = [tool.name]
if tool.description:
parts.append(tool.description)
schema = getattr(tool, "parameters", None) or {}
for param_name, param_info in (schema.get("properties") or {}).items():
parts.append(param_name)
if isinstance(param_info, dict) and param_info.get("description"):
parts.append(str(param_info["description"]))
return " ".join(parts).lower()
def _acad_owners() -> dict[str, list[str]]:
owners: dict[str, list[str]] = {}
for tool_name, record in TOOL_ALIASES.items():
for command in record.acad:
owners.setdefault(command, []).append(tool_name)
for names in owners.values():
names.sort()
return owners
# ── coverage ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_every_registered_tool_has_an_alias_record():
"""Adding a tool without an alias record must fail this gate."""
missing = sorted(await _registered_names() - set(TOOL_ALIASES))
assert not missing, f"tools with no alias record: {missing}"
@pytest.mark.asyncio
async def test_no_alias_key_is_an_unknown_tool():
"""Catches typos and tools that were renamed or removed."""
unknown = sorted(set(TOOL_ALIASES) - await _registered_names())
assert not unknown, f"alias keys that are not registered tools: {unknown}"
def test_every_record_is_a_tool_aliases_instance():
bad = sorted(k for k, v in TOOL_ALIASES.items() if not isinstance(v, ToolAliases))
assert not bad, f"records that are not ToolAliases: {bad}"
# ── shape ───────────────────────────────────────────────────────────────────
def test_acad_commands_are_uppercase_and_space_free():
bad: list[str] = []
for tool_name, record in TOOL_ALIASES.items():
for command in record.acad:
if not _ACAD_RE.fullmatch(command):
bad.append(f"{tool_name}:{command!r}")
assert not bad, f"malformed AutoCAD command tokens: {bad}"
def test_acad_commands_are_unique_within_a_record():
bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.acad)) != len(v.acad))
assert not bad, f"records with a repeated AutoCAD command: {bad}"
def test_synonyms_are_lowercase_non_empty_and_trimmed():
bad: list[str] = []
for tool_name, record in TOOL_ALIASES.items():
for phrase in record.synonyms:
if not phrase or phrase != phrase.lower() or phrase != phrase.strip():
bad.append(f"{tool_name}:{phrase!r}")
assert not bad, f"malformed synonyms: {bad}"
def test_every_tool_has_at_least_one_synonym():
"""``acad`` may legitimately be empty; a record with nothing in it is dead weight."""
empty = sorted(k for k, v in TOOL_ALIASES.items() if not v.synonyms)
assert not empty, f"records with no synonyms: {empty}"
def test_synonyms_are_unique_within_a_record():
bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.synonyms)) != len(v.synonyms))
assert not bad, f"records with a repeated synonym: {bad}"
# ── uniqueness ──────────────────────────────────────────────────────────────
#
# Rule: one AutoCAD command routes to exactly one tool. The only exceptions are
# the commands in ``SHARED_ACAD_COMMANDS`` -- option/dialog-driven umbrella
# commands (LAYER, ZOOM, ARRAY, LAYOUT, MEASUREGEOM, SETVAR) and the two places
# where this server genuinely exposes one operation through two tools (INSERT ->
# block_insert / entity_create_block_ref, ERASE -> entity_delete /
# entity_delete_many). Anything else is a copy-paste bug.
def test_each_acad_command_routes_to_a_single_tool():
unexpected = {
command: names
for command, names in _acad_owners().items()
if len(names) > 1 and command not in SHARED_ACAD_COMMANDS
}
assert not unexpected, f"AutoCAD command claimed by several tools: {unexpected}"
def test_shared_command_allowlist_has_no_stale_entries():
actually_shared = {c for c, names in _acad_owners().items() if len(names) > 1}
stale = sorted(SHARED_ACAD_COMMANDS - actually_shared)
assert not stale, f"SHARED_ACAD_COMMANDS lists commands that are not shared: {stale}"
# ── the df=0 regression guard ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_key_autocad_commands_are_absent_from_live_tool_text():
"""Proves the gap: these commands score df=0 against the live BM25 corpus."""
tools = await _registered_tools()
hits = {
command: sorted(t.name for t in tools if command.lower() in _searchable_text(t))
for command in DF0_COMMANDS
}
leaked = {c: names for c, names in hits.items() if names}
assert not leaked, f"command already indexed -- drop it from DF0_COMMANDS: {leaked}"
def test_key_autocad_commands_are_covered_by_the_corpus():
"""...and proves the corpus closes it."""
owners = _acad_owners()
missing = sorted(c for c in DF0_COMMANDS if c not in owners)
assert not missing, f"df=0 commands still unreachable via the corpus: {missing}"
# ── helpers ─────────────────────────────────────────────────────────────────
def test_aliases_for_returns_the_record_or_none():
assert aliases_for("entity_fillet") is TOOL_ALIASES["entity_fillet"]
assert aliases_for("no_such_tool") is None
def test_alias_text_contains_commands_and_synonyms():
record = TOOL_ALIASES["entity_create_circle"]
text = alias_text("entity_create_circle")
for token in (*record.acad, *record.synonyms):
assert token in text
assert alias_text("no_such_tool") == ""
+668
View File
@@ -0,0 +1,668 @@
"""The discovery search transform must find tools a drafter can actually name.
Stock ``BM25SearchTransform`` indexes tool name + description + parameter names
+ parameter descriptions and nothing else. Measured against this server's live
catalog, that means ``PEDIT``, ``WBLOCK``, ``OVERKILL``,
``LAYTRANS`` and ``MATCHPROP`` all score df=0 and return *nothing*,
and "how many entities are on the GEOMETRY layer" returns ``entity_delete_many``
as its top hit. Both are proved here against the real catalog before anything is
asserted about the replacement.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from fastmcp import Client
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.transforms.search.base import serialize_tools_for_output_json
import config
import server
from discovery.serialize import serialize_search_results
from discovery.transform import CadSearchTransform, tool_risk
from tests.data.discovery_golden import ALL_CASES, HOLDOUT_CASES, TUNING_CASES
pytestmark = pytest.mark.asyncio
@pytest.fixture(scope="module")
def catalog():
"""The real, untransformed 131-tool catalog.
Synchronous on purpose: ``_registered_tools`` only reads the in-process
component registry, and a module-scoped *async* fixture would need its own
event-loop scope under ``asyncio_mode = strict``.
"""
return asyncio.run(server._registered_tools())
@pytest.fixture
def transform():
return CadSearchTransform()
@pytest.fixture
def stock():
return BM25SearchTransform()
def _names(tools) -> list[str]:
return [t.name for t in tools]
# ── the df=0 vocabulary gap ─────────────────────────────────────────────────
# Verified against the live catalog: stock BM25 returns *zero* hits for every
# one of these. FILLET is deliberately absent — it is already indexed (df=2,
# the tool is literally named entity_fillet), so citing it would overstate the
# gap.
#
# BPOLY and QSELECT left this list in v1.5.0 (M8): `boundary_trace` and
# `selection_filter` name those commands in their own docstrings, so stock BM25
# now finds them and the gap is genuinely closed for those two. Keeping them
# here would have made the corpus look more load-bearing than it is. The
# remaining five are still unreachable without it.
DF_ZERO_COMMANDS = [
("PEDIT", "entity_edit_geometry"),
("WBLOCK", "block_create_from_entities"),
("OVERKILL", "drawing_refine"),
("LAYTRANS", "template_apply_layers"),
("MATCHPROP", "entity_set_properties"),
]
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
async def test_stock_bm25_cannot_find_these_commands_at_all(stock, catalog, command, expected):
"""Guard the guard: the gap is real, not an artefact of a weak assertion."""
assert await stock._search(catalog, command) == []
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
async def test_autocad_command_names_resolve(transform, catalog, command, expected):
assert _names(transform.rank(catalog, command).hits)[0] == expected
@pytest.mark.parametrize(
("command", "expected"),
[
("DLI", "dimension_linear"),
("REC", "entity_create_rectangle"),
("CHA", "entity_chamfer"),
("TR", "entity_trim"),
("MI", "entity_mirror"),
("XL", "construction_xline"),
],
)
async def test_short_command_aliases_resolve(transform, catalog, command, expected):
"""Two- and three-letter aliases are what drafters actually type."""
assert _names(transform.rank(catalog, command).hits)[0] == expected
async def test_searchable_text_extends_the_stock_document(transform, catalog):
"""The hook adds alias vocabulary; it never drops the stock text."""
from fastmcp.server.transforms.search.base import _extract_searchable_text
tool = next(t for t in catalog if t.name == "entity_create_polyline")
text = transform._extract_searchable_text(tool)
assert _extract_searchable_text(tool) in text
assert "PLINE" in text
assert "closed profile" in text
async def test_a_tool_with_no_alias_record_is_still_indexed(transform, catalog):
"""An un-aliased tool must stay findable, not vanish from the index."""
tool = next(t for t in catalog if t.name == "entity_create_line")
stripped = tool.model_copy(update={"name": "zzz_unaliased_probe"})
hits = transform.rank([*catalog, stripped], "zzz_unaliased_probe").hits
assert _names(hits)[0] == "zzz_unaliased_probe"
# ── the two boosts ──────────────────────────────────────────────────────────
async def test_an_exact_command_outranks_tools_that_share_a_description_word(transform, catalog):
""" "CIRCLE" is the command; a dozen tools merely mention circles."""
hits = _names(transform.rank(catalog, "CIRCLE", limit=5).hits)
assert hits[0] == "entity_create_circle"
async def test_a_command_name_that_is_also_an_english_word_stays_english(transform, catalog):
"""LAYER is a command *and* a noun.
Boosting on a bare lowercase "layer" made layer_modify / layer_list /
layer_create the top three for this query and pushed layer_delete out of
the answer entirely — exactly backwards. Measured regression, not a
hypothetical.
"""
hits = _names(transform.rank(catalog, "delete the layer called GEOMETRY", limit=8).hits)
assert "layer_delete" in hits
assert not {"layer_create", "layer_modify", "layer_list"} & set(hits[:3])
async def test_the_same_command_in_capitals_is_treated_as_a_command(transform, catalog):
"""The other half of the rule: LAYER typed as a command routes to LAYER tools."""
hits = set(_names(transform.rank(catalog, "LAYER", limit=5).hits))
assert {"layer_create", "layer_list", "layer_modify"} <= hits
async def test_a_lone_lowercase_command_still_counts_as_a_command(transform, catalog):
"""Nobody shouts when the command is the whole message."""
assert _names(transform.rank(catalog, "bpoly").hits)[0] == "boundary_trace"
async def test_articles_do_not_break_synonym_phrase_matching(transform, catalog):
"""The corpus says "delete a layer"; users say "delete the layer"."""
assert "layer_delete" in _names(transform.rank(catalog, "delete the layer", limit=5).hits)
# ── the risk filter ─────────────────────────────────────────────────────────
COUNTING_QUERY = "how many entities are on the GEOMETRY layer"
async def test_stock_bm25_answers_a_counting_question_with_destructive_tools(stock, catalog):
"""Guard the guard: the destructive-hit problem is measured, not assumed.
This asserted the top hit was ``entity_delete_many`` until
``analysis_measure_entity`` was added and displaced it to second. The
ranking order is incidental; what the risk ceiling exists to prevent is a
read-only question being answered with tools that destroy geometry, and that
is still exactly what stock BM25 does here.
"""
hits = await stock._search(catalog, COUNTING_QUERY)
risky = [t.name for t in hits if tool_risk(t) == "destructive"]
assert risky, f"expected destructive hits among {_names(hits)}"
assert "entity_delete_many" in risky
@pytest.mark.parametrize(
("name", "expected"),
[
("drawing_info", "read"),
("analysis_entity_stats", "read"),
("entity_create_line", "write"),
("entity_delete_many", "destructive"),
("layer_delete", "destructive"),
("transaction_rollback", "destructive"),
],
)
async def test_risk_is_derived_from_the_mcp_annotations(catalog, name, expected):
tool = next(t for t in catalog if t.name == name)
assert tool_risk(tool) == expected
async def test_read_ceiling_keeps_destructive_tools_out_of_a_counting_answer(transform, catalog):
outcome = transform.rank(catalog, COUNTING_QUERY, risk="read")
assert [tool_risk(t) for t in outcome.hits] == ["read"] * len(outcome.hits)
assert "analysis_entity_stats" in _names(outcome.hits)
async def test_write_ceiling_allows_mutation_but_not_destruction(transform, catalog):
outcome = transform.rank(catalog, "delete the entities on the GEOMETRY layer", risk="write")
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
async def test_the_filter_frees_slots_rather_than_shrinking_the_answer(transform, catalog):
"""Filtering must promote lower-ranked safe tools, not just delete rows."""
unfiltered = transform.rank(catalog, COUNTING_QUERY, limit=5)
filtered = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
assert len(unfiltered.hits) == 5
assert len(filtered.hits) == 5
assert set(_names(filtered.hits)) - set(_names(unfiltered.hits))
async def test_the_filter_records_exactly_what_it_hid(transform, catalog):
outcome = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
assert "entity_delete_many" in _names(outcome.hidden)
# Only tools the caller would otherwise have seen — not the whole catalog.
assert len(outcome.hidden) <= 5
async def test_nothing_is_recorded_as_hidden_when_the_filter_is_off(transform, catalog):
assert transform.rank(catalog, COUNTING_QUERY, limit=5).hidden == ()
# ── the risk filter must be visible in the payload ──────────────────────────
class _FakeServer:
def __init__(self, tools):
self._tools = list(tools)
async def list_tools(self, run_middleware: bool = True):
return list(self._tools)
class _FakeContext:
"""Just enough Context for ``_get_visible_tools`` to reach the catalog."""
def __init__(self, tools):
self.fastmcp = _FakeServer(tools)
async def _search_payload(transform, catalog, query: str, **kwargs):
tool = transform._make_search_tool()
return await tool.fn(query=query, ctx=_FakeContext(catalog), **kwargs)
async def test_the_payload_says_when_the_risk_filter_removed_hits(transform, catalog):
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="read")
notice = [line for line in payload.splitlines() if line.startswith("Risk filter:")]
assert len(notice) == 1
assert "entity_delete_many" in notice[0]
assert 'risk="any"' in notice[0]
async def test_a_mixed_intent_is_never_silently_halved(transform, catalog):
""" "list the layers then delete GEOMETRY" is half a read and half a delete."""
payload = await _search_payload(
transform, catalog, "list the layers then delete GEOMETRY", risk="read"
)
assert "layer_list" in payload
notice = next(line for line in payload.splitlines() if line.startswith("Risk filter:"))
assert "entity_delete" in notice
async def test_no_notice_when_the_filter_removed_nothing(transform, catalog):
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="any")
assert "Risk filter:" not in payload
async def test_a_structured_serializer_gets_the_notice_as_a_sibling_key(catalog):
"""There is no line to append to when the serializer returns JSON."""
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="read")
assert isinstance(payload["results"], list)
assert "entity_delete_many" in payload["notice"]
async def test_a_structured_serializer_returns_bare_results_when_nothing_was_hidden(catalog):
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="any")
assert isinstance(payload, list)
# ── the search tool's own schema ────────────────────────────────────────────
async def test_search_tool_advertises_limit_and_risk(transform):
properties = transform._make_search_tool().parameters["properties"]
assert set(properties) == {"query", "limit", "risk"}
assert properties["risk"]["enum"] == ["any", "write", "read"]
assert properties["limit"]["default"] == transform._max_results
@pytest.mark.parametrize(("asked", "served"), [(999, 25), (0, 1), (-4, 1), (3, 3)])
async def test_limit_is_clamped_to_a_usable_range(transform, catalog, asked, served):
outcome = transform.rank(catalog, "line", limit=asked)
assert len(outcome.hits) == served
async def test_an_unknown_risk_value_is_rejected_rather_than_ignored(transform, catalog):
with pytest.raises(ValueError, match="risk"):
transform.rank(catalog, "line", risk="mostly-harmless")
# ── the compact serializer ──────────────────────────────────────────────────
def _tools(catalog, *names):
by_name = {t.name: t for t in catalog}
return [by_name[n] for n in names]
async def test_one_card_per_tool_on_one_line_each(catalog):
tools = _tools(catalog, "entity_create_circle", "entity_delete", "drawing_info")
lines = serialize_search_results(tools).splitlines()
assert len(lines) == 3
assert lines[0].startswith("entity_create_circle")
async def test_a_card_carries_name_summary_and_required_params(catalog):
(line,) = serialize_search_results(_tools(catalog, "entity_create_circle")).splitlines()
assert "entity_create_circle" in line
assert "req: cx, cy, radius" in line
async def test_a_card_marks_the_risk_level(catalog):
text = serialize_search_results(
_tools(catalog, "drawing_info", "entity_create_circle", "entity_delete")
)
assert "drawing_info (read)" in text
assert "entity_create_circle (write)" in text
assert "entity_delete (destructive)" in text
async def test_a_card_carries_the_autocad_command(catalog):
(line,) = serialize_search_results(_tools(catalog, "entity_chamfer")).splitlines()
assert "acad: CHAMFER" in line
async def test_the_cad_meta_summary_wins_over_the_docstring(catalog):
"""@cad_tool exists to give a hit a hand-written one-liner."""
tool = next(t for t in catalog if t.name == "transaction_begin")
summary = (tool.meta or {})["cad"]["summary"]
assert summary in serialize_search_results([tool])
async def test_the_first_docstring_line_is_the_fallback_summary(catalog):
"""A card still reads well for a tool that carries no @cad_tool summary.
The fallback is exercised by stripping the meta channel off a real tool
rather than by naming one that happens to lack it: the @cad_tool rollout
is filling the catalog in, so "a tool without cad meta" is not a stable
fixture, while the serializer's fallback branch has to keep working for
any tool a transform hands it (a mounted server's, say).
"""
tool = next(t for t in catalog if t.name == "entity_create_circle")
bare = tool.model_copy(update={"meta": None})
first_line = bare.description.strip().splitlines()[0]
assert first_line in serialize_search_results([bare])
async def test_a_long_summary_is_truncated_on_a_word_boundary(catalog):
tool = next(t for t in catalog if t.name == "entity_create_circle")
verbose = tool.model_copy(update={"description": "word " * 200, "meta": None})
(line,) = serialize_search_results([verbose]).splitlines()
assert len(line) < 250
assert line.count("...") == 1
async def test_a_zero_argument_tool_says_so(catalog):
(line,) = serialize_search_results(_tools(catalog, "drawing_info")).splitlines()
assert "req: (none)" in line
async def test_an_empty_result_set_is_explicit():
assert serialize_search_results([]) == "No tools matched the query."
# Budgets read off the measured output, not chosen first and enforced onto the
# renderer. Over the 15 REALISTIC_QUERIES below at limit=5 the worst rendered
# payload measured 798 characters and the worst single card in the whole
# 131-tool catalog measured 191 (p50 123, p90 160). The thresholds sit ~30%
# above those so an added tool with a long docstring does not turn into a
# spurious CI failure — a real regression would be a multiple, not a nudge.
MAX_CARD_CHARS = 256
MAX_PAYLOAD_CHARS = 1024
REALISTIC_QUERIES = [
"FILLET",
"BPOLY",
"round the corner",
"bill of materials",
"how many entities are on the GEOMETRY layer",
"draw a hole in the plate",
"put a leader note on the chamfer",
"dimension everything with a chain",
"make a title block",
"save the drawing as a pdf",
"select every circle on the GEOMETRY layer",
"gear",
"keyway in the hub bore",
"screenshot",
"check the drawing before finalising",
]
async def test_no_single_card_exceeds_the_measured_budget(catalog):
"""Checked over every registered tool, not just the ones a query happens to hit."""
worst = max(catalog, key=lambda tool: len(serialize_search_results([tool])))
assert len(serialize_search_results([worst])) <= MAX_CARD_CHARS, worst.name
@pytest.mark.parametrize("query", REALISTIC_QUERIES)
async def test_a_five_hit_payload_stays_inside_the_measured_budget(transform, catalog, query):
hits = transform.rank(catalog, query, limit=5).hits
assert len(serialize_search_results(hits)) <= MAX_PAYLOAD_CHARS
async def test_the_serializer_is_far_smaller_than_stock_json(transform, catalog):
"""Payload size is a real gain, but it is *not* the differentiator.
Stock BM25 with this same serializer would close most of the gap; what it
cannot do is find BPOLY at all. Asserted as a ratio so it stays honest.
"""
hits = transform.rank(catalog, "round the corner", limit=5).hits
compact = len(serialize_search_results(hits))
stock_json = len(json.dumps(serialize_tools_for_output_json(hits)))
assert compact < stock_json / 5
# ── the golden set ──────────────────────────────────────────────────────────
TOP_K = 3
# Measured on the tuning set with the scorer frozen: top-1 47/51 (92.2%),
# top-3 50/51 (98.0%). The bars sit below those, not on them.
TUNING_TOP1_BAR = 0.85
TUNING_TOP3_BAR = 0.94
# Declared *before* the holdout set was measured for the first time, and not
# revised afterwards. Lower than the tuning bar on purpose: a ranker that
# scores the same on data it never saw is the exception, not the expectation.
HOLDOUT_TOP3_BAR = 0.80
# A tuning case that fails for a reason this module cannot fix. "smaller" and
# "by half" appear nowhere in discovery/aliases.py (which has "shrink" but not
# "smaller"), so no scoring change reaches entity_scale — only a corpus edit
# does, and the corpus is a separate deliverable. Left in the set rather than
# quietly deleted: it is the honest ceiling of an alias-driven approach.
KNOWN_CORPUS_GAPS = {"make the whole shape smaller by half"}
def _tuning_params():
return [
pytest.param(
case,
marks=(
[pytest.mark.xfail(reason="vocabulary missing from discovery/aliases.py")]
if case.query in KNOWN_CORPUS_GAPS
else []
),
id=str(case),
)
for case in TUNING_CASES
]
async def _accuracy(transform, stock, catalog, cases):
"""(mine_top1, mine_topk, stock_top1, stock_topk, misses) over ``cases``."""
mine1 = mine_k = stock1 = stock_k = 0
misses = []
for case in cases:
# risk="any" for both, so the A/B never credits the risk filter.
ours = _names(transform.rank(catalog, case.query, limit=5).hits)
theirs = _names(await stock._search(catalog, case.query))
mine1 += ours[:1] == [case.expect]
stock1 += theirs[:1] == [case.expect]
if case.expect in ours[:TOP_K]:
mine_k += 1
else:
misses.append(f"{case.query!r} want={case.expect} got={ours[:TOP_K]}")
stock_k += case.expect in theirs[:TOP_K]
return mine1, mine_k, stock1, stock_k, misses
@pytest.mark.parametrize("case", _tuning_params())
async def test_tuning_case_finds_its_tool(transform, catalog, case):
hits = _names(transform.rank(catalog, case.query, limit=5).hits)
assert case.expect in hits[:TOP_K], hits
async def test_tuning_accuracy_holds_its_measured_bar(transform, stock, catalog):
mine1, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, TUNING_CASES)
n = len(TUNING_CASES)
assert mine1 / n >= TUNING_TOP1_BAR, f"top-1 {mine1}/{n}"
assert mine_k / n >= TUNING_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
async def test_holdout_accuracy_clears_the_bar_declared_before_it_was_measured(
transform, stock, catalog
):
_, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, HOLDOUT_CASES)
n = len(HOLDOUT_CASES)
assert mine_k / n >= HOLDOUT_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
async def test_it_beats_stock_bm25_on_the_whole_golden_set(transform, stock, catalog):
"""The A/B. Both sides run unfiltered, so this measures vocabulary and
ranking only — none of the credit goes to the risk filter.
Scored on top-1, not a top-`TOP_K` ratio. A ratio between two scores over a
shared corpus is not a property of this transform: improving an unrelated
tool's docstring adds vocabulary to *both* indexes, so a `mine_k >= 2 *
stock_k` assertion moves when stock gets **better**, which is a good outcome
being reported as a regression. It fired exactly that way when the undo/redo
docstrings were written. Top-1 is also the number that matters — a search
tool returns one answer to act on.
"""
mine1, mine_k, stock1, stock_k, _ = await _accuracy(transform, stock, catalog, ALL_CASES)
n = len(ALL_CASES)
report = (
f"top-1 mine {mine1}/{n} vs stock {stock1}/{n}; "
f"top-{TOP_K} mine {mine_k}/{n} vs stock {stock_k}/{n}"
)
assert mine1 >= 2 * stock1, report
assert mine_k > stock_k, report
async def test_stock_bm25_cannot_answer_a_fifth_of_the_set_at_all(transform, stock, catalog):
"""The thesis, stated as the thing no amount of tuning can change.
The corpus gap is *data*, not parameters: AutoCAD command names appear in
none of the tool descriptions, so those queries score df=0 and stock returns
an empty list. No serializer and no BM25 constant invents vocabulary that is
not in the index — which is why the alias corpus, not the ranking, is what
this transform actually contributes.
"""
empty_stock = [c.query for c in ALL_CASES if not await stock._search(catalog, c.query)]
empty_mine = [c.query for c in ALL_CASES if not transform.rank(catalog, c.query, limit=5).hits]
assert len(empty_stock) >= 15, f"only {len(empty_stock)} empty — has the corpus changed?"
assert empty_mine == [], f"the alias corpus must cover every case; empty: {empty_mine}"
COUNTING_CASES = [case for case in ALL_CASES if case.kind == "counting"]
@pytest.mark.parametrize("case", COUNTING_CASES, ids=str)
async def test_a_counting_question_never_answers_with_a_destructive_tool(transform, catalog, case):
outcome = transform.rank(catalog, case.query, limit=5, risk=case.risk)
assert case.risk == "read"
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
assert case.expect in _names(outcome.hits)[:TOP_K]
async def test_the_risk_ceiling_is_what_removes_the_delete_tool(transform, catalog):
"""Not the ranking — the ranking still surfaces it, and says that it did."""
query = "how many entities are on the GEOMETRY layer"
assert "entity_delete_many" in _names(transform.rank(catalog, query, limit=5).hits)
filtered = transform.rank(catalog, query, limit=5, risk="read")
assert "entity_delete_many" not in _names(filtered.hits)
assert "entity_delete_many" in filtered.notice()
# ── DISCOVERY_MODE ──────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _restore_discovery_mode():
"""Attaching a transform mutates the shared server; always put it back."""
yield
server._apply_discovery_mode("off")
async def test_discovery_is_off_by_default(monkeypatch):
"""v1.5.0 ships the flag, not the switch: turning it on is a wire-level
breaking change for every connected client and needs its own migration."""
monkeypatch.delenv("DISCOVERY_MODE", raising=False)
assert config.Settings().discovery_mode == "off"
async def test_the_env_var_selects_search_mode(monkeypatch):
monkeypatch.setenv("DISCOVERY_MODE", "SEARCH")
assert config.Settings().discovery_mode == "search"
async def test_off_leaves_the_whole_catalog_on_the_wire():
server._apply_discovery_mode("off")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
assert "search_tools" not in names
async def test_search_replaces_the_catalog_with_the_two_synthetic_tools():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert names == {"search_tools", "call_tool"}
async def test_search_mode_is_reversible_and_attaches_only_once():
server._apply_discovery_mode("search")
server._apply_discovery_mode("search")
server._apply_discovery_mode("off")
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
async def test_an_unknown_discovery_mode_falls_back_to_off():
assert server._apply_discovery_mode("semantic-vector-magic") == "off"
async with Client(server.mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert len(names) > 100
async def test_the_search_tool_answers_on_the_wire():
"""End to end: a real client, a real AutoCAD command, a real payload."""
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "BPOLY"})
assert "boundary_trace" in result.content[0].text
async def test_the_risk_notice_survives_to_the_wire():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool(
"search_tools",
{"query": "how many entities are on the GEOMETRY layer", "risk": "read"},
)
text = result.content[0].text
assert "analysis_entity_stats" in text
assert "Risk filter:" in text
assert "entity_delete_many" in text
async def test_search_cannot_surface_a_tool_the_profile_disabled():
"""Capability-aware discovery has to survive the transform.
The 3D solid tools are registered but disabled unless ENABLE_3D=true, so
searching for one must come back empty rather than advertising a call that
would only be rejected. This works because the search reads the *filtered*
catalog through _get_visible_tools, not the raw registry.
"""
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "BOX", "limit": 10})
assert "solid_box" not in result.content[0].text
async def test_limit_reaches_the_wire():
server._apply_discovery_mode("search")
async with Client(server.mcp) as client:
result = await client.call_tool("search_tools", {"query": "layer", "limit": 2})
assert len(result.content[0].text.splitlines()) == 2
async def test_search_mode_does_not_shrink_what_the_server_reports_about_itself():
"""system_about reads the untransformed registry; prove it under the transform."""
server._apply_discovery_mode("search")
assert await server._registered_tool_count() > 100
assert len(await server._tool_groups()) > 5
+25 -4
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
import pytest
import config
pytestmark = pytest.mark.asyncio
@@ -46,14 +48,33 @@ async def test_drawing_purge(backend):
async def test_drawing_audit(backend):
result = await backend.drawing_audit()
assert result.get("ok") is True
# Asserting only `ok` is why the audit debt survived to 1.5.0: the tool
# repaired documents and reported nothing about it, and this test was green
# throughout. The contract is the whole key set (see backends/base.py).
assert {"ok", "repaired", "fixes", "fix_count", "errors", "error_count"} <= result.keys()
async def test_drawing_undo(backend):
async def test_drawing_undo(backend, monkeypatch):
# `assert isinstance(result, dict)` used to be the whole test, and it passed
# for years against an undo that could only ever fail. See
# tests/test_undo_redo.py for the full contract.
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 100, 0)
result = await backend.drawing_undo()
assert isinstance(result, dict)
assert result["ok"] is True
assert len(list(backend._doc.modelspace())) == 0
async def test_drawing_redo(backend):
async def test_drawing_redo(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 100, 0)
await backend.drawing_undo()
result = await backend.drawing_redo()
assert isinstance(result, dict)
assert result["ok"] is True
assert len(list(backend._doc.modelspace())) == 1
+267
View File
@@ -0,0 +1,267 @@
"""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"])
+325
View File
@@ -0,0 +1,325 @@
"""F2 — LIST: the raw DXF attribute set for one handle.
`entity_get` reports a *curated* view: the handful of derived fields the drafting
tools need, already flattened to WCS xy. `entity_measure` reports lengths and
areas. Neither answers the question AutoCAD's LIST answers — "what is actually
stored on this object?" — and that is the only reason this tool exists. It must
not restate the other two.
Four measured facts shape every test below.
* **The dump is not JSON-serialisable as it comes out of ezdxf.**
`entity.dxfattribs()` returns `Vec3` for `start` / `end` / `insert` / `center`.
Measured: `json.dumps` raises ``Object of type Vec3 is not JSON serializable``
for LINE, CIRCLE, TEXT and INSERT. A tool that hands that dict back either dies
at the wire boundary or gets silently coerced into `"Vec3(0.0, 0.0, 0.0)"` —
a string where the caller expected a point. So `json.dumps(result)` is asserted
outright, for all five point-carrying types, and the values are asserted to
still be *numbers* afterwards: stringifying the Vec3 would satisfy `json.dumps`
and destroy the payload.
* **The dump is WCS, like everything else crossing a tool boundary.**
A mirrored CIRCLE stores `center = (30, 20)` with `extrusion = (0, 0, -1)`;
it is drawn at (-30, 20), which is what `entity_get` reports. A raw
`dxfattribs()` echo therefore contradicts `entity_get` in the same payload by
60 mm — the exact defect T0.4 was opened to remove. The dump still reports the
`extrusion` it translated *from*, because a caller who cannot see the frame
cannot tell a translated point from an untranslated one.
* **`dxfattribs()` on an LWPOLYLINE contains no geometry at all.**
Measured: the keys are `flags`, `handle`, `layer`, `owner` — the vertices live
in `get_points()`, not in the attribute dict. A tool that dumps `dxfattribs()`
and stops reports a polyline with no points and calls it a full property dump.
The vertices must carry their **bulge** too: `entity_get` gives bare `[x, y]`
pairs, and reconstructing a bulged outline from those loses 28.2% of the area.
* **Only stored attributes are reported.** `thickness` is absent from a fresh
LINE and appears once set. Padding the dump with schema defaults would make an
explicitly-set 0.0 indistinguishable from "never touched", which is the one
question a raw attribute dump exists to answer.
The refusal: an unknown or deleted handle raises `RuntimeError`, matching
`entity_get`. Returning `{}` for a handle that is not there would report "this
entity has no properties" for an entity that has no existence.
"""
from __future__ import annotations
import json
import pytest
pytestmark = pytest.mark.asyncio
#: Mirror line = the Y axis (x -> -x). This is what produces extrusion (0, 0, -1).
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
def _numbers(value):
"""Assert `value` is a real numeric sequence and return its x, y.
Accepts a 2- or 3-component point: the arity of the dump is the tool
author's call, but "it is a sequence of numbers" is not. `bool` is excluded
because `isinstance(True, int)` would otherwise wave a coerced flag through.
"""
assert isinstance(value, (list, tuple)), f"expected a point, got {value!r}"
assert len(value) in (2, 3), f"expected a 2D or 3D point, got {value!r}"
for component in value:
assert isinstance(component, (int, float)) and not isinstance(component, bool), (
f"point component {component!r} is not a number"
)
return float(value[0]), float(value[1])
async def _one_of_each(backend):
"""A LINE, LWPOLYLINE, INSERT, TEXT and CIRCLE — every Vec3-carrying type."""
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
return {
"LINE": (await backend.entity_create_line(0, 0, 100, 50)).handle,
"LWPOLYLINE": (
await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
).handle,
"INSERT": (await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)).handle,
"TEXT": (await backend.entity_create_text("PART A", 40, 15, 3.0)).handle,
"CIRCLE": (await backend.entity_create_circle(30, 20, 5)).handle,
}
# ── the wire boundary ───────────────────────────────────────────────────────
@pytest.mark.parametrize("dxftype", ["LINE", "LWPOLYLINE", "INSERT", "TEXT", "CIRCLE"])
async def test_the_dump_survives_json_encoding(backend, dxftype):
"""Measured: raw `dxfattribs()` raises `Object of type Vec3 is not JSON serializable`."""
handles = await _one_of_each(backend)
result = await backend.analysis_list_properties(handles[dxftype])
encoded = json.dumps(result)
assert json.loads(encoded) == result, "the payload changed shape on the way through JSON"
async def test_a_lines_endpoints_are_numbers_not_stringified_vectors(backend):
"""`str(Vec3(...))` would satisfy json.dumps and hand the caller a string."""
line = await backend.entity_create_line(0, 0, 100, 50)
dump = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
assert _numbers(dump["start"]) == pytest.approx((0.0, 0.0))
assert _numbers(dump["end"]) == pytest.approx((100.0, 50.0))
async def test_a_texts_insertion_point_is_numbers_not_a_stringified_vector(backend):
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
assert _numbers(dump["insert"]) == pytest.approx((40.0, 15.0))
assert dump["text"] == "PART A"
assert dump["height"] == pytest.approx(3.0)
async def test_an_inserts_insertion_point_is_numbers_not_a_stringified_vector(backend):
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
insert = await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)
dump = (await backend.analysis_list_properties(insert.handle))["dxf_attributes"]
assert _numbers(dump["insert"]) == pytest.approx((10.0, 20.0))
assert dump["name"] == "BOLT"
assert dump["xscale"] == pytest.approx(2.0)
assert dump["yscale"] == pytest.approx(3.0)
# ── coordinates are WCS ─────────────────────────────────────────────────────
async def test_a_mirrored_circles_centre_is_reported_in_wcs(backend):
"""Stored (30, 20) with extrusion -Z; drawn at (-30, 20). The dump says drawn.
Echoing `dxfattribs()` puts a 60 mm contradiction inside one payload, because
`entity_get` on the same handle reports (-30, 20).
"""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
assert backend._doc.entitydb.get(mirrored.handle).dxf.center.x == pytest.approx(30.0), (
"precondition: the DXF really does store the un-negated x"
)
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
assert _numbers(dump["center"]) == pytest.approx((-30.0, 20.0))
info = await backend.entity_get(mirrored.handle)
assert _numbers(dump["center"]) == pytest.approx(tuple(info.properties["center"]))
async def test_the_dump_still_names_the_frame_it_translated_from(backend):
"""Drop the extrusion and a caller cannot tell a translated point from a raw one."""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
assert list(dump["extrusion"]) == pytest.approx([0.0, 0.0, -1.0])
assert dump["radius"] == pytest.approx(5.0), "radius is frame-invariant in a flat frame"
# ── what entity_get deliberately does not carry ─────────────────────────────
async def test_a_polylines_vertices_are_in_the_dump(backend):
"""Measured: `dxfattribs()` for an LWPOLYLINE is flags/handle/layer/owner only.
Dumping it verbatim describes a polyline without saying where it is.
"""
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
assert "points" not in backend._doc.entitydb.get(poly.handle).dxfattribs(), (
"precondition: ezdxf keeps the vertices out of the attribute dict"
)
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
assert [_numbers(pt) for pt in dump["points"]] == pytest.approx(
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
)
async def test_a_polyline_vertex_carries_its_bulge(backend):
"""`entity_get` gives bare [x, y]; rebuilding a bulged outline from those
loses 28.2% of the area. The full dump is where the bulge has to live."""
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
backend._doc.entitydb.get(poly.handle).set_points(
[(0, 0, 0.0), (10, 0, 0.5), (10, 10, 0.0)], format="xyb"
)
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
bulges = [pt[2] for pt in dump["points"]]
assert len(bulges) == 3, "every vertex reports a bulge, including the straight ones"
assert bulges == pytest.approx([0.0, 0.5, 0.0])
async def test_text_style_and_obliquity_reach_the_caller(backend):
"""Neither appears anywhere in `entity_get`, and both change what is plotted."""
backend._doc.styles.new("ISO", dxfattribs={"font": "isocp.shx"})
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
entity = backend._doc.entitydb.get(text.handle)
entity.dxf.style = "ISO"
entity.dxf.oblique = 15.0
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
assert dump["style"] == "ISO"
assert dump["oblique"] == pytest.approx(15.0)
info = await backend.entity_get(text.handle)
assert "style" not in info.properties, "precondition: entity_get is the curated view"
async def test_the_dump_reports_what_is_stored_not_schema_defaults(backend):
"""A dump padded with defaults cannot answer "did the drafter set this?".
That is the only question a raw attribute dump exists to answer, so an
unset `thickness` must be absent rather than reported as 0.0.
"""
line = await backend.entity_create_line(0, 0, 100, 50)
before = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
assert "thickness" not in before
assert "ltscale" not in before
entity = backend._doc.entitydb.get(line.handle)
entity.dxf.thickness = 2.0
entity.dxf.ltscale = 0.5
after = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
assert after["thickness"] == pytest.approx(2.0)
assert after["ltscale"] == pytest.approx(0.5)
# ── the top level ───────────────────────────────────────────────────────────
async def test_the_type_and_layer_sit_at_the_top_level(backend):
"""A caller asking "what is this?" should not have to dig into the dump."""
circle = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
result = await backend.analysis_list_properties(circle.handle)
assert result["ok"] is True
assert result["handle"] == circle.handle
assert result["type"] == "CIRCLE"
assert result["layer"] == "GEOMETRY"
assert result["dxf_attributes"]["layer"] == "GEOMETRY", "the two must not disagree"
async def test_the_summary_does_not_contradict_entity_get(backend):
"""`properties` restates the curated view, so it must at least agree with it.
This is the test that argues for deleting the key: a second copy of
`entity_get`'s output can only ever be identical or wrong.
"""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
result = await backend.analysis_list_properties(mirrored.handle)
info = await backend.entity_get(mirrored.handle)
assert result["properties"]["center"] == pytest.approx(info.properties["center"])
# ── the refusal ─────────────────────────────────────────────────────────────
async def test_an_unknown_handle_is_refused_not_answered_with_an_empty_dump(backend):
"""`{}` would read as "this entity has no properties" for a nonexistent entity."""
with pytest.raises(RuntimeError, match="FFFFFF"):
await backend.analysis_list_properties("FFFFFF")
async def test_a_deleted_handle_is_refused_too(backend):
"""`entitydb.get` does not filter destroyed entities, so the guard must."""
line = await backend.entity_create_line(0, 0, 100, 50)
await backend.entity_delete(line.handle)
with pytest.raises(RuntimeError, match=line.handle):
await backend.analysis_list_properties(line.handle)
# ── round trip ──────────────────────────────────────────────────────────────
async def test_the_dump_describes_what_actually_lands_in_the_file(backend, tmp_path):
"""The dump is a translation of the file, not a snapshot of in-memory state.
The reloaded DXF still stores the OCS centre; the dump reported the WCS one.
Both readings of the same circle must remain consistent after a save.
"""
import ezdxf
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
path = tmp_path / "listed.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(mirrored.handle)
assert reloaded.dxf.center.x == pytest.approx(30.0), "the file keeps the OCS x"
assert list(reloaded.dxf.extrusion) == pytest.approx(list(dump["extrusion"]))
assert reloaded.dxf.radius == pytest.approx(dump["radius"])
assert _numbers(dump["center"]) == pytest.approx((-reloaded.dxf.center.x, 20.0))
async def test_a_texts_dump_round_trips_through_a_saved_file(backend, tmp_path):
import ezdxf
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
path = tmp_path / "listed_text.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(text.handle)
assert reloaded.dxf.text == dump["text"]
assert reloaded.dxf.height == pytest.approx(dump["height"])
assert _numbers(dump["insert"]) == pytest.approx((reloaded.dxf.insert.x, reloaded.dxf.insert.y))
+371
View File
@@ -0,0 +1,371 @@
"""Honesty guarantees for the headless ezdxf backend.
T0.1 — the DWG lie. ezdxf has no DWG writer, so any save that would put DXF
bytes behind a ``.dwg`` name must refuse with the machine-readable
``{"ok": False, "error": ..., "capability": ...}`` shape instead, and the
boundary must be discoverable up-front through the capability map.
T0.3 — the audit lie, which ran the *other* way. ``Drawing.audit()`` repairs the
document in place, so ``auditor.fixes`` is a log of mutations already
applied. ``drawing_audit`` threw that list away, never marked the document
dirty (so ``drawing_close(save=True)`` discarded the repairs it had just
made), and rendered the errors it did report as ``<ErrorEntry object at
0x...>``. The tool said "0 errors" about a file it had silently altered.
T0.6 — no per-call timeout. Every ezdxf method funnels through ``_async``, which
serialises on a single ``asyncio.Lock``; one hung sync call used to hold
that lock forever and deadlock every later tool call on the server.
"""
from __future__ import annotations
import asyncio
import threading
import time
import types
import pytest
import config
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
# ── T0.1 — DWG refusal ──────────────────────────────────────────────────────
async def test_save_as_dwg_refuses_with_capability_tagged_shape(backend, tmp_path):
target = tmp_path / "part.dwg"
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save_as(str(target), "dwg")
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert payload["capability"] == "dwg"
assert excinfo.value.capability == "dwg"
message = payload["error"]
assert message == str(excinfo.value)
lowered = message.lower()
assert "dwg" in lowered
assert ".dxf" in lowered # points at the headless alternative
assert "com" in lowered # ...and at the live backend
assert not target.exists(), "refusal must not leave a mislabelled file behind"
async def test_save_as_refuses_dwg_extension_even_when_fmt_says_dxf(backend, tmp_path):
"""The bytes must match the name: a .dwg path is a DWG request, whatever fmt says."""
target = tmp_path / "sneaky.dwg"
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save_as(str(target), "dxf")
assert excinfo.value.capability == "dwg"
assert not target.exists()
async def test_drawing_save_refuses_dwg_path(backend, tmp_path):
"""drawing_save(path) took any path and wrote DXF bytes into it."""
target = tmp_path / "saved.dwg"
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save(str(target))
assert excinfo.value.capability == "dwg"
assert not target.exists()
async def test_dxf_save_still_works(backend, tmp_path):
import ezdxf
await backend.entity_create_line(0, 0, 10, 0)
as_path = tmp_path / "ok_saveas.dxf"
result = await backend.drawing_save_as(str(as_path), "dxf")
assert result["ok"] is True
assert result["format"] == "dxf"
assert ezdxf.readfile(str(as_path)) is not None
save_path = tmp_path / "ok_save.dxf"
assert (await backend.drawing_save(str(save_path)))["ok"] is True
assert save_path.exists()
export_path = tmp_path / "ok_export.dxf"
assert (await backend.drawing_export_dxf(str(export_path)))["ok"] is True
assert export_path.exists()
async def test_capability_map_reports_the_dwg_boundary(backend):
features = backend.capabilities().to_dict()["features"]
assert features["dxf"]["supported"] is True
assert features["dwg"]["supported"] is False
assert features["dwg"]["reason"]
async def test_both_backends_declare_the_same_capability_keys():
"""A capability only helps clients if both maps answer the same question."""
ezdxf_keys = set(EzdxfBackend().capabilities().features)
com_keys = set(ComBackend().capabilities().features)
assert ezdxf_keys == com_keys
assert "dwg" in com_keys
assert ComBackend().capabilities().features["dwg"].supported is True
# ── T0.6 — per-call timeout / anti-deadlock ─────────────────────────────────
async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
entered = threading.Event()
def _hang():
entered.set()
time.sleep(2.0)
return "too late"
started = time.monotonic()
with pytest.raises(RuntimeError) as excinfo:
await backend._async(_hang)
assert entered.is_set()
assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out"
message = str(excinfo.value)
assert "EZDXF_CALL_TIMEOUT" in message
assert "0.2" in message
# The anti-deadlock guarantee: the NEXT call must not queue behind the
# abandoned worker thread (which is still sleeping).
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10)
info = await asyncio.wait_for(backend.drawing_info(), timeout=3)
assert info.backend == "ezdxf"
line = await asyncio.wait_for(backend.entity_create_line(0, 0, 1, 1), timeout=3)
assert line.handle
async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(backend, monkeypatch):
"""A live call owns its lock: queueing past the timeout must not abandon it."""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
live = backend._lock
await live.acquire() # stand in for a call that is still running
try:
started = time.monotonic()
with pytest.raises(RuntimeError, match="busy"):
await backend._async(lambda: "never runs")
assert time.monotonic() - started < 1.0
assert backend._lock is live, "the in-flight call's lock must not be swapped out"
finally:
live.release()
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5)
assert await asyncio.wait_for(backend._async(lambda: "after"), timeout=2) == "after"
async def test_zero_timeout_disables_the_check(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0)
def _slow():
time.sleep(0.3)
return "finished"
assert await backend._async(_slow) == "finished"
async def test_async_still_serialises_calls(backend):
"""The timeout rewrite must not drop mutual exclusion on the ezdxf document."""
trace: list[str] = []
def _work(tag):
trace.append(f"enter:{tag}")
time.sleep(0.05)
trace.append(f"exit:{tag}")
return tag
results = await asyncio.gather(*(backend._async(_work, tag) for tag in "abc"))
assert sorted(results) == ["a", "b", "c"]
assert trace == [step for tag in results for step in (f"enter:{tag}", f"exit:{tag}")]
async def test_a_call_raising_TimeoutError_is_not_reported_as_the_deadline(backend, monkeypatch):
"""A TimeoutError *from the call* is a normal failure, not our deadline.
``TimeoutError`` is ``asyncio.TimeoutError`` on 3.11+ and an ``OSError``
subclass, so an ezdxf read off a dead network share raises exactly what
``wait_for`` raises. Blaming the deadline for it would tell the user their
drawing "may be left mid-write" and orphan a lock, for a call that never
ran long at all.
"""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 30)
live = backend._lock
def _boom():
raise TimeoutError("the network share went away")
with pytest.raises(TimeoutError, match="network share"):
await backend._async(_boom)
assert backend._lock is live, "a call that merely failed must not abandon its lock"
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
async def test_errors_release_the_lock(backend, monkeypatch):
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5)
def _boom():
raise ValueError("nope")
with pytest.raises(ValueError):
await backend._async(_boom)
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
# ── T0.3 — audit reports what it repaired, and keeps it ─────────────────────
def _write_damaged_dxf(path) -> None:
"""A DXF referencing a linetype and a text style that were never defined.
Both are defects ezdxf's ``Auditor`` *repairs* — UNDEFINED_LINETYPE (100)
and UNDEFINED_TEXT_STYLE (102) — by discarding the dangling attribute. The
names are chosen to survive as literal bytes in the saved file so a test can
prove the repair reached disk.
"""
import ezdxf
doc = ezdxf.new("R2010")
msp = doc.modelspace()
msp.add_line((0, 0), (10, 0), dxfattribs={"linetype": "GHOSTLINE"})
msp.add_text("hello", dxfattribs={"style": "NOSUCHSTYLE"})
doc.saveas(str(path))
def _write_block_cycle_dxf(path) -> None:
"""Two blocks that insert each other — a defect the Auditor CANNOT repair.
This is the only way to get entries into ``auditor.errors`` (as opposed to
``auditor.fixes``), which is what makes the error-rendering assertion
possible at all.
"""
import ezdxf
doc = ezdxf.new("R2010")
block_a = doc.blocks.new("BLK_A")
block_b = doc.blocks.new("BLK_B")
block_a.add_blockref("BLK_B", (0, 0))
block_b.add_blockref("BLK_A", (0, 0))
doc.modelspace().add_blockref("BLK_A", (0, 0))
doc.saveas(str(path))
async def test_audit_reports_the_repairs_it_applied(backend, tmp_path):
"""``doc.audit()`` mutates; a tool that hides those mutations is lying."""
target = tmp_path / "damaged.dxf"
_write_damaged_dxf(target)
await backend.drawing_open(str(target))
result = await backend.drawing_audit()
assert result["repaired"] is True
assert result["fix_count"] == 2
assert {fix["code"] for fix in result["fixes"]} == {100, 102}
for fix in result["fixes"]:
assert isinstance(fix["message"], str) and fix["message"]
assert fix["name"]
async def test_audit_marks_the_document_dirty_when_it_repaired(backend, tmp_path):
"""The load-bearing half: a reported repair that close() throws away is worse.
``drawing_close`` saves only ``if save and self._dirty``. Reporting the fixes
without marking the document dirty would promise three repairs and then lose
every one of them.
"""
target = tmp_path / "damaged.dxf"
_write_damaged_dxf(target)
before = target.read_bytes()
assert b"GHOSTLINE" in before and b"NOSUCHSTYLE" in before
await backend.drawing_open(str(target))
await backend.drawing_audit()
info = await backend.drawing_info()
assert info.saved is False, "audit mutated the document; it is no longer 'saved'"
await backend.drawing_close(save=True)
after = target.read_bytes()
assert after != before, "the repair must reach disk, not be discarded on close"
assert b"GHOSTLINE" not in after
assert b"NOSUCHSTYLE" not in after
async def test_audit_clean_document_reports_no_repair(backend):
"""The opposite direction of the same lie: never claim a repair not made."""
result = await backend.drawing_audit()
assert result["repaired"] is False
assert result["fix_count"] == 0
assert result["fixes"] == []
assert (await backend.drawing_info()).saved is True
async def test_audit_errors_are_readable_messages_not_object_reprs(backend, tmp_path):
"""``ErrorEntry`` has no ``__str__``; ``str(e)`` yielded a memory address."""
target = tmp_path / "cycle.dxf"
_write_block_cycle_dxf(target)
await backend.drawing_open(str(target))
result = await backend.drawing_audit()
assert result["error_count"] >= 2
for err in result["errors"]:
assert isinstance(err, dict)
assert err["code"] == 104
assert "block reference cycle" in err["message"].lower()
assert "ErrorEntry object at" not in err["message"]
async def test_audit_detail_capability_is_declared_per_backend():
"""A client must be able to tell "nothing was wrong" from "we cannot see"."""
assert EzdxfBackend().capabilities().features["audit_detail"].supported is True
com_audit = ComBackend().capabilities().features["audit_detail"]
assert com_audit.supported is False
assert com_audit.reason == "audit_result_not_readable_over_com"
async def test_audit_discloses_an_entity_it_deleted(backend):
"""The most destructive repair is the one that names no handle.
``Auditor.check_owner_exist`` *deletes* an entity whose owner handle points
nowhere, and calls ``fixed_error`` without ``dxf_entity=`` — so ``handle`` is
``None`` for exactly the fix a user most needs to know about. Before the
dirty-flag fix the missing ``_mark_dirty()`` accidentally shielded people
from this; now the deletion reaches disk on the next save, so the report has
to carry it.
"""
await backend.entity_create_line(0, 0, 5, 5)
await backend.entity_create_circle(1, 1, 2)
doomed = next(iter(backend._doc.modelspace()))
doomed.dxf.owner = "DEAD" # the damage a truncated write leaves behind
result = await backend.drawing_audit()
assert result["repaired"] is True
deletions = [fix for fix in result["fixes"] if "Deleted" in fix["message"]]
assert deletions, f"a deletion must be reported, got {result['fixes']}"
assert "LINE" in deletions[0]["message"]
assert deletions[0]["handle"] is None, (
"ezdxf reports this fix without the entity, so the handle is genuinely "
"unknown — inventing one would be worse than admitting it"
)
assert len(list(backend._doc.modelspace())) == 1, "the entity really is gone"
assert (await backend.drawing_info()).saved is False
async def test_audit_entry_names_an_unknown_code_honestly():
"""A code outside AuditError must not be dressed up as a name."""
from backends.ezdxf_backend import _audit_entry
entry = types.SimpleNamespace(code=999, message="something new", entity=None)
assert _audit_entry(entry) == {
"code": 999,
"name": "UNKNOWN",
"message": "something new",
"handle": None,
}
+211 -2
View File
@@ -65,8 +65,10 @@ def test_parse_fit_code():
@pytest.mark.parametrize(
"code,nominal",
[
("K7", 20.0), # transition hole letters need the delta-rule — excluded
("P7", 20.0),
# K7/P7 used to be here: the delta rule derives them from tables already
# in the module, so they are authored as of 1.5.0 and pinned against
# published values above.
("t6", 20.0), # ISO 286 does not define shaft t below 24 mm
("x6", 20.0), # letter outside the authored subset (regex rejects)
("H3", 20.0), # grade below authored range
("H12", 20.0), # grade above authored range
@@ -116,3 +118,210 @@ def test_fit_passthrough_without_code():
"deviation",
"x",
)
# ── ISO 286 transition / interference HOLES (K, M, N, P) ────────────────────
#
# These were refused by name until 1.5.0. They are not new table data: ISO 286
# derives an upper-letter hole from the same-letter shaft by the delta rule,
#
# ES = -ei + Δ, Δ = IT(n) − IT(n−1)
#
# so every value below comes from tables already authored in this module. The
# expected numbers are the published ISO 286 ones, cross-checked by hand — the
# point of pinning them is that the *rule* is right, not that the arithmetic runs.
@pytest.mark.parametrize(
("code", "nominal", "upper_um", "lower_um"),
[
("K7", 20.0, 6, -15),
("M7", 20.0, 0, -21),
("N7", 20.0, -7, -28),
("P7", 20.0, -14, -35),
("K7", 50.0, 7, -18),
("N7", 50.0, -8, -33),
("P7", 50.0, -17, -42),
("K6", 20.0, 2, -11),
("N6", 20.0, -11, -24),
("P6", 20.0, -18, -31),
],
)
def test_transition_hole_letters_match_published_iso286(code, nominal, upper_um, lower_um):
fit = fit_lookup(code, nominal)
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
def test_h7_over_p6_is_a_genuine_interference_fit():
"""The pairing check the tables exist for: the shaft must be bigger."""
hole = fit_lookup("H7", 20.0)
shaft = fit_lookup("p6", 20.0)
assert shaft.lower_mm > hole.upper_mm - hole.it_value_mm, "min shaft > min hole"
assert shaft.lower_mm > 0 and hole.lower_mm == 0
def test_the_delta_collapses_above_the_grade_the_rule_covers():
"""ISO 286 applies Δ only to IT<=8 for K/M/N and IT<=7 for P; then ES = -ei."""
from engineering.fits import _SHAFT_EI, _size_index
index = _size_index(20.0)
n9 = fit_lookup("N9", 20.0)
assert n9.upper_mm == pytest.approx(-_SHAFT_EI["n"][index] / 1000.0, abs=1e-9)
p8 = fit_lookup("P8", 20.0)
assert p8.upper_mm == pytest.approx(-_SHAFT_EI["p"][index] / 1000.0, abs=1e-9)
def test_grade_4_is_refused_rather_than_guessed():
"""Δ needs IT3, which is outside the authored IT4-IT11 range."""
with pytest.raises(ValueError, match="IT5"):
fit_lookup("K4", 20.0)
# ── ISO 286 interference SHAFTS (r, s, t, u) ────────────────────────────────
#
# These need their own fundamental deviations, sub-stepped more finely than the
# main size steps above 50 mm, so unlike K/M/N/P they are table data rather than
# a rule. Authored table data is only as good as its provenance, so every value
# is cross-checked below against ISO 286-1's own derivation formulae — an
# independent source that agrees or does not. What survives is stated; what
# diverges is listed by name, so a reader with the standard checks five numbers
# instead of ninety-five.
@pytest.mark.parametrize(
("code", "nominal", "upper_um", "lower_um"),
[
# ei + IT above the nominal — these are all interference shafts.
("r6", 20.0, 41, 28),
("s6", 20.0, 48, 35),
("u6", 20.0, 54, 41),
("s7", 50.0, 68, 43),
("u6", 30.0, 61, 48), # 24-30 sub-step: u jumps 41 -> 48 inside 18-30
("s6", 60.0, 72, 53), # 50-65 sub-step, IT6 from the main 50-80 step
("t6", 60.0, 85, 66),
],
)
def test_interference_shafts_match_published_iso286(code, nominal, upper_um, lower_um):
fit = fit_lookup(code, nominal)
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
def test_the_sub_steps_are_real_and_not_flattened():
"""Above 50 mm these letters split the main steps; collapsing them is a
silent error of tens of microns."""
assert fit_lookup("u6", 55.0).lower_mm != fit_lookup("u6", 70.0).lower_mm
assert fit_lookup("u6", 20.0).lower_mm != fit_lookup("u6", 28.0).lower_mm
def test_t_is_refused_below_24_mm_because_iso_does_not_define_it():
with pytest.raises(ValueError, match="24"):
fit_lookup("t6", 20.0)
assert fit_lookup("t6", 30.0).lower_mm > 0
# -- provenance: the table against ISO 286-1's own formulae ------------------
def test_r_is_the_geometric_mean_of_p_and_s():
"""ISO 286 *defines* r that way, and p is already pinned in this module.
24/24 sub-steps agree within 1 um, which validates r and s together against
data that was trusted before either was authored.
"""
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI, _SHAFT_EI_FINE, _size_index
for index, step in enumerate(_FINE_SIZE_STEPS):
mid = (step[0] + step[1]) / 2.0
p = _SHAFT_EI["p"][_size_index(mid)]
s = _SHAFT_EI_FINE["s"][index]
r = _SHAFT_EI_FINE["r"][index]
assert abs(round(math.sqrt(p * s)) - r) <= 1, f"r at {step}: table {r}, sqrt(p*s) {p}/{s}"
#: Steps where ISO 286's rounding rules put the published value more than 1 um
#: from the formula. Listed rather than hidden: these are the only values a
#: reader with the standard needs to check by hand.
KNOWN_FORMULA_DIVERGENCES = {
("u", (250, 280)),
("u", (400, 450)),
("u", (450, 500)),
("t", (24, 30)),
("t", (450, 500)),
}
#: Below this the formulae do not describe the published tables at all — ISO
#: tabulates the small sizes directly. Measured for u: the formula is 6 um low at
#: 1-3 mm and only converges from 18 mm up (-6, -7, -5, -2, +1). Scoping the
#: check here is a statement about where the cross-check has authority, not a
#: threshold moved until the test passed.
FORMULA_VALID_FROM_MM = 18
@pytest.mark.parametrize(("letter", "coefficient"), [("t", 0.63), ("u", 1.0)])
def test_t_and_u_track_their_iso_formula(letter, coefficient):
"""ISO 286-1: ei = IT7 + k*D, D the geometric mean of the step bounds.
The standard rounds its published tables, so exact equality is not the
claim — agreement within 1 um everywhere except a listed handful is.
"""
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
checked = 0
for index, step in enumerate(_FINE_SIZE_STEPS):
ei = _SHAFT_EI_FINE[letter][index]
if ei is None or step[0] < FORMULA_VALID_FROM_MM:
continue
checked += 1
mid = (step[0] + step[1]) / 2.0
it7 = _it_um(7, _size_index(mid))
formula = it7 + coefficient * math.sqrt(step[0] * step[1])
drift = abs(round(formula) - ei)
if (letter, step) in KNOWN_FORMULA_DIVERGENCES:
assert 1 < drift <= 3, f"{letter} at {step} drifted {drift} um — re-check the table"
else:
assert drift <= 1, f"{letter} at {step}: table {ei}, formula {formula:.1f}"
assert checked >= 18, f"only {checked} steps cross-checked for {letter}"
def test_s_above_50_tracks_its_iso_formula():
import math
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
for index, step in enumerate(_FINE_SIZE_STEPS):
if step[0] < 50:
continue
mid = (step[0] + step[1]) / 2.0
formula = _it_um(7, _size_index(mid)) + 0.4 * math.sqrt(step[0] * step[1])
assert abs(round(formula) - _SHAFT_EI_FINE["s"][index]) <= 1, f"s at {step}"
def test_s_below_50_sits_in_the_iso_band_above_it8():
"""ISO 286: for D <= 50, s is IT8 plus a small step. Monotone, 0..4 um."""
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
deltas = []
for index, step in enumerate(_FINE_SIZE_STEPS):
if step[0] >= 50:
break
mid = (step[0] + step[1]) / 2.0
deltas.append(_SHAFT_EI_FINE["s"][index] - _it_um(8, _size_index(mid)))
assert all(0 <= d <= 4 for d in deltas), deltas
assert deltas == sorted(deltas), f"the step above IT8 must not decrease: {deltas}"
def test_h7_over_s6_is_tighter_than_h7_over_p6():
"""The reason these letters exist: progressively heavier interference."""
p6 = fit_lookup("p6", 20.0).lower_mm
r6 = fit_lookup("r6", 20.0).lower_mm
s6 = fit_lookup("s6", 20.0).lower_mm
u6 = fit_lookup("u6", 20.0).lower_mm
assert p6 < r6 < s6 < u6
+364
View File
@@ -0,0 +1,364 @@
"""F4 — hatch depth: gradient fill, in-place edit, curved boundary edges.
`entity_create_hatch` can do exactly one thing: stamp a pattern fill into a
closed *polyline* boundary. Everything a section drawing actually needs after
that — restyling the fill, changing the island rule, bounding the fill with an
arc — has no tool. These three fill that gap, and each carries a measured trap:
* **Gradient fill is a replacement, not a layer.** `set_gradient()` flips
`solid_fill` to 1, rewrites `pattern_name` to `SOLID` and drops `.pattern` to
``None``. A tool that reported "gradient applied" while leaving the caller
believing the ANSI31 hatching is still underneath would be describing a
drawing that does not exist. Every gradient assertion here goes through
saveas/readfile, because gradient data lives in its own DXF subrecord and a
gradient that vanishes on save is the classic failure mode.
* **`pattern_scale` is two numbers, not one.** ezdxf stores the scale twice:
as `dxf.pattern_scale` *and* baked into the pattern definition lines' offset
vectors. Setting only the DXF attribute is the perfect silent no-op — every
report says scale 4.0, every renderer draws scale 2.0. So the scale and angle
tests measure the pattern line *pitch* and *angle*, not the attribute.
Measured here: ANSI31 at scale 1.0 has a 3.175 mm pitch; scale 2.0 is 6.35.
ezdxf's `set_pattern_scale`/`set_pattern_angle` are absolute, not relative.
* **A partial edit must stay partial.** `hatch_edit` takes five optional
parameters; the data-loss bug is the implementation that rebuilds the hatch
from defaults and quietly resets the four the caller did not name. Note that
a hatch born from `entity_create_hatch` already has `hatch_style == 1`
(OUTER), because that is ezdxf's `set_pattern_fill` default — so "normal" is
a real change, not a no-op, and `changed` has to distinguish a parameter that
was *supplied* from an attribute that actually *moved*.
* **Flat vertex lists straighten curves.** This is the fidelity point of
`hatch_add_boundary`. A hatch boundary API that only accepts points turns
every fillet and bore into a chord and still reports a boundary. So the arc
test asserts the reloaded path is an `EdgePath` holding an `ArcEdge` with its
radius and sweep intact — a flattened arc would come back as a `PolylinePath`
and pass any "did a path appear?" check.
"""
from __future__ import annotations
import math
import pytest
pytestmark = pytest.mark.asyncio
SQUARE = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]]
RED = [255, 0, 0]
BLUE = [0, 0, 255]
def _entity(backend, handle):
return backend._doc.entitydb.get(handle)
async def _reloaded(backend, tmp_path, handle, name="hatch.dxf"):
"""Round-trip the whole document and hand back the same entity."""
import ezdxf
path = tmp_path / name
await backend.drawing_save_as(str(path))
return ezdxf.readfile(str(path)).entitydb.get(handle)
def _pattern_pitch(hatch):
"""Distance between the hatch pattern's definition lines, in drawing units."""
offset = hatch.pattern.lines[0].offset
return math.hypot(offset[0], offset[1])
def _path_types(hatch):
return [type(path).__name__ for path in hatch.paths]
# ── gradient fill ───────────────────────────────────────────────────────────
async def test_a_gradient_survives_save_and_reload(backend, tmp_path):
"""Gradient data lives in its own subrecord; losing it on save is silent."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_set_gradient(
created.handle, RED, BLUE, rotation=30.0, tint=0.4, name="CURVED"
)
assert result["ok"] is True
assert result["gradient"]["name"] == "CURVED"
assert list(result["gradient"]["color1"]) == RED
reloaded = await _reloaded(backend, tmp_path, created.handle)
gradient = reloaded.gradient
assert gradient is not None, "the gradient did not survive the round-trip"
assert tuple(gradient.color1) == (255, 0, 0)
assert tuple(gradient.color2) == (0, 0, 255)
assert gradient.rotation == pytest.approx(30.0)
assert gradient.tint == pytest.approx(0.4)
assert gradient.name == "CURVED"
async def test_a_gradient_replaces_the_pattern_fill_it_lands_on(backend, tmp_path):
"""A hatch is gradient-filled *or* pattern-filled. Never both."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
assert _entity(backend, created.handle).dxf.pattern_name == "ANSI31"
await backend.hatch_set_gradient(created.handle, RED, BLUE)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.pattern is None, "the ANSI31 line definition is still there"
assert reloaded.dxf.solid_fill == 1
assert reloaded.dxf.pattern_name == "SOLID"
async def test_a_gradient_keeps_the_boundary_it_fills(backend, tmp_path):
"""A gradient with no boundary paints nothing at all."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
await backend.hatch_set_gradient(created.handle, RED, BLUE)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert _path_types(reloaded) == ["PolylinePath"]
assert len(reloaded.paths[0].vertices) == len(SQUARE)
async def test_a_one_colour_gradient_stores_its_tint(backend, tmp_path):
"""one_color+tint is a distinct DXF mode; dropping the tint flattens it."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_set_gradient(
created.handle, [10, 20, 30], [0, 0, 0], one_color=True, tint=0.75, name="SPHERICAL"
)
assert result["ok"] is True
gradient = (await _reloaded(backend, tmp_path, created.handle)).gradient
assert int(gradient.one_color) == 1
assert gradient.tint == pytest.approx(0.75)
assert gradient.name == "SPHERICAL"
async def test_a_gradient_on_a_line_is_refused_naming_the_type(backend):
"""Only a HATCH carries gradient data; a LINE would swallow it silently."""
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.hatch_set_gradient(line.handle, RED, BLUE)
assert result["ok"] is False
assert "LINE" in result["error"]
assert "HATCH" in result["error"]
# ── hatch edit ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("style, expected", [("normal", 0), ("outer", 1), ("ignore", 2)])
async def test_each_island_style_maps_to_its_dxf_value(backend, tmp_path, style, expected):
created = await backend.entity_create_hatch("ANSI31", SQUARE)
# Start from a different style so every case is a real move, not a no-op.
_entity(backend, created.handle).dxf.hatch_style = (expected + 1) % 3
result = await backend.hatch_edit(created.handle, style=style)
assert result["ok"] is True
assert "style" in result["changed"]
reloaded = await _reloaded(backend, tmp_path, created.handle, f"style-{style}.dxf")
assert reloaded.dxf.hatch_style == expected
async def test_editing_the_scale_leaves_every_other_attribute_alone(backend):
"""A partial edit that resets what it was not asked about is data loss."""
created = await backend.entity_create_hatch(
"ANSI31", SQUARE, scale=2.0, angle=15.0, layer="HATCH", color=3
)
style_before = _entity(backend, created.handle).dxf.hatch_style
await backend.hatch_edit(created.handle, scale=4.0)
hatch = _entity(backend, created.handle)
assert hatch.dxf.pattern_name == "ANSI31"
assert hatch.dxf.pattern_angle == pytest.approx(15.0)
assert hatch.dxf.color == 3
assert hatch.dxf.layer == "HATCH"
assert hatch.dxf.hatch_style == style_before
assert len(hatch.paths) == 1
async def test_a_new_scale_rescales_the_pattern_definition(backend, tmp_path):
"""Setting only dxf.pattern_scale is the perfect silent no-op."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
assert _pattern_pitch(_entity(backend, created.handle)) == pytest.approx(6.35)
await backend.hatch_edit(created.handle, scale=4.0)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_scale == pytest.approx(4.0)
assert _pattern_pitch(reloaded) == pytest.approx(12.7), "the lines did not move apart"
async def test_a_new_angle_rotates_the_pattern_definition(backend, tmp_path):
"""Same trap as scale: the angle is stored twice and must move twice."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, angle=15.0)
assert _entity(backend, created.handle).pattern.lines[0].angle == pytest.approx(60.0)
await backend.hatch_edit(created.handle, angle=30.0)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_angle == pytest.approx(30.0)
assert reloaded.pattern.lines[0].angle == pytest.approx(75.0), "the lines did not rotate"
async def test_a_new_pattern_and_colour_round_trip(backend, tmp_path):
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, color=3)
result = await backend.hatch_edit(created.handle, pattern="ANSI32", color=5)
assert result["ok"] is True
assert set(result["changed"]) == {"pattern", "color"}
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_name == "ANSI32"
assert reloaded.dxf.color == 5
assert reloaded.dxf.pattern_scale == pytest.approx(2.0), "the scale was reset"
async def test_changed_lists_only_the_attributes_that_moved(backend):
""" "I was asked to set it" and "it is now different" are different answers."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, angle=15.0)
result = await backend.hatch_edit(created.handle, scale=4.0, angle=15.0)
assert result["ok"] is True
assert set(result["changed"]) == {"scale"}
async def test_an_edit_that_moves_nothing_reports_nothing(backend):
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
result = await backend.hatch_edit(created.handle, scale=2.0)
assert result["ok"] is True
assert result["changed"] == []
@pytest.mark.parametrize("bad", [0.0, -1.0])
async def test_a_non_positive_pattern_scale_is_refused(backend, bad):
"""Scale 0 collapses the definition offsets to zero: an infinite line count."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_edit(created.handle, scale=bad)
assert result["ok"] is False
assert "scale" in result["error"]
async def test_an_unknown_island_style_is_refused_naming_the_valid_ones(backend):
"""Silently falling back to `normal` would fill islands nobody asked to fill."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_edit(created.handle, style="outermost")
assert result["ok"] is False
for valid in ("normal", "outer", "ignore"):
assert valid in result["error"]
# ── boundary edges ──────────────────────────────────────────────────────────
ARC_EDGE = {
"type": "arc",
"center": [110.0, 0.0],
"radius": 10.0,
"start_angle": 0.0,
"end_angle": 180.0,
"ccw": True,
}
LINE_EDGE = {"type": "line", "start": [100.0, 0.0], "end": [120.0, 0.0]}
async def test_an_arc_boundary_edge_survives_as_an_arc(backend, tmp_path):
"""The fidelity point: a flattened arc comes back as a PolylinePath."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
assert result["ok"] is True
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert _path_types(reloaded) == ["PolylinePath", "EdgePath"]
edges = reloaded.paths[1].edges
assert [type(edge).__name__ for edge in edges] == ["LineEdge", "ArcEdge"]
arc = edges[1]
assert arc.radius == pytest.approx(10.0)
assert tuple(arc.center)[:2] == pytest.approx((110.0, 0.0))
assert arc.start_angle == pytest.approx(0.0)
assert arc.end_angle == pytest.approx(180.0)
assert arc.ccw is True
async def test_the_added_path_is_counted_alongside_the_existing_one(backend):
"""path_count is the hatch's total, so the original boundary is still there."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
assert result["handle"] == created.handle
assert result["path_count"] == 2
assert result["edge_types"] == ["line", "arc"]
async def test_an_ellipse_boundary_edge_keeps_its_ratio(backend, tmp_path):
"""The minor/major ratio *is* the ellipse; drop it and you have a circle."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
edge = {
"type": "ellipse",
"center": [200.0, 100.0],
"major_axis": [30.0, 0.0],
"ratio": 0.4,
"start_angle": 0.0,
"end_angle": 360.0,
"ccw": True,
}
result = await backend.hatch_add_boundary(created.handle, [edge])
assert result["edge_types"] == ["ellipse"]
reloaded = await _reloaded(backend, tmp_path, created.handle)
stored = reloaded.paths[1].edges[0]
assert type(stored).__name__ == "EllipseEdge"
assert stored.ratio == pytest.approx(0.4)
assert tuple(stored.major_axis)[:2] == pytest.approx((30.0, 0.0))
assert tuple(stored.center)[:2] == pytest.approx((200.0, 100.0))
async def test_an_unknown_edge_type_is_refused_naming_the_valid_ones(backend):
"""Skipping the edge it cannot build would leave an open boundary."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(
created.handle, [{"type": "bezier", "start": [0, 0], "end": [1, 1]}]
)
assert result["ok"] is False
assert "bezier" in result["error"]
for valid in ("line", "arc", "ellipse"):
assert valid in result["error"]
async def test_an_edge_missing_a_key_is_refused_naming_the_key(backend):
"""An arc defaulted to radius 0 is a boundary that encloses nothing."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(
created.handle,
[{"type": "arc", "center": [0.0, 0.0], "start_angle": 0.0, "end_angle": 90.0}],
)
assert result["ok"] is False
assert "radius" in result["error"]
async def test_a_boundary_with_no_edges_is_refused(backend):
"""An empty edge path bounds nothing and renders as a hatch with a hole."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [])
assert result["ok"] is False
+392
View File
@@ -0,0 +1,392 @@
"""M6 — deleting, renaming and copying sheet tabs.
`layout_list/create/set_current` shipped in v1.4; the rest of the tab lifecycle
did not, so a sheet could be made and never unmade. What makes this more than
three thin wrappers is that ezdxf's own guards stop well short of where a tool
boundary has to stop, and the gaps are all silent:
* `doc.layouts.delete("")` does not refuse — it deletes the *first* paper-space
layout and reports nothing unusual, and `doc.layouts.get("")` likewise
resolves to the active paper space. A caller who passed an empty string by
accident loses a sheet.
* `doc.layouts.rename()` skips the `is_valid_table_name` check that `new()`
performs, so `Bad/Name` and even `""` are accepted and the file still audits
clean.
* Deleting or renaming the *current* layout leaves `self._current_space`
pointing at a name that no longer exists. `_msp()` then silently falls back to
model space while `layout_list()` still reports the dead name — so
`entity_create_*` lands somewhere the caller was never told about.
* The entities on a deleted layout are destroyed but stay in `entitydb`, where
`_get_entity`'s `is None` check does not see them. Fetching a handle from a
deleted sheet used to raise `AttributeError: 'Line' object has no attribute
'dxf'` instead of a refusal.
ezdxf has no layout-copy API at all (`xref.load_paperspace` is cross-document
only), so `layout_copy` is a construction — and everything a construction can
get wrong here is invisible to the Auditor, which reports zero errors on a
duplicated tab order, a duplicated main viewport and a hatch whose boundary
still points into the layout it was copied from.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
OTHER = "A4-Sheet"
def _names(backend) -> list[str]:
return list(backend._doc.layouts.names_in_taborder())
def _entities_in(backend, layout_name: str) -> list[str]:
space = backend._doc.layouts.get(layout_name)
return sorted(e.dxftype() for e in space)
# ── layout_delete ───────────────────────────────────────────────────────────
async def test_deleting_a_layout_removes_the_tab_and_its_geometry(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
result = await backend.layout_delete(SHEET)
assert result["ok"] is True
assert result["deleted"] == SHEET
assert result["entities_destroyed"] == 1
assert SHEET not in _names(backend)
async def test_deleting_model_space_is_refused(backend):
result = await backend.layout_delete("Model")
assert result["ok"] is False
assert "Model" in result["error"]
assert "Model" in _names(backend)
async def test_a_blank_name_does_not_delete_the_first_sheet_it_finds(backend):
"""ezdxf's own delete("") destroys Layout1 and says nothing."""
before = _names(backend)
for blank in ("", " "):
result = await backend.layout_delete(blank)
assert result["ok"] is False, f"delete({blank!r}) must refuse"
assert _names(backend) == before
async def test_deleting_a_missing_layout_refuses_with_a_readable_error(backend):
"""ezdxf raises a bare KeyError here; the tool must not leak that."""
result = await backend.layout_delete("NOSUCH")
assert result["ok"] is False
assert "NOSUCH" in result["error"]
async def test_deleting_the_last_paper_space_layout_refuses(backend):
"""A DXF document must keep at least one paper-space layout."""
for name in _names(backend):
if name != "Model" and name != "Layout1":
await backend.layout_delete(name)
remaining = [n for n in _names(backend) if n != "Model"]
assert len(remaining) == 1, "fixture assumption: exactly one sheet left"
result = await backend.layout_delete(remaining[0])
assert result["ok"] is False
assert remaining[0] in _names(backend)
async def test_deleting_the_current_layout_leaves_every_report_agreeing(backend):
"""The three-way disagreement this test exists to prevent.
Before the fix: ``layout_list`` said the deleted sheet, ``$TILEMODE`` said
paper space, and ``_msp()`` quietly wrote to model space. Whatever the tool
decides the new current space is, all three must say the same thing and the
next entity must actually land there.
"""
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
result = await backend.layout_delete(SHEET)
assert result["ok"] is True
current = (await backend.layout_list())["current"]
assert current == result["current"]
assert current in _names(backend)
line = await backend.entity_create_line(0, 0, 10, 0)
landed = backend._doc.modelspace() if current == "Model" else backend._doc.layouts.get(current)
assert line.handle in {e.dxf.handle for e in landed}
async def test_a_handle_from_a_deleted_layout_refuses_instead_of_crashing(backend):
"""The destroyed entity stays in entitydb; `is None` does not catch it."""
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
await backend.layout_delete(SHEET)
with pytest.raises(RuntimeError, match=line.handle):
await backend.entity_get(line.handle)
# ── layout_rename ───────────────────────────────────────────────────────────
async def test_renaming_keeps_the_entities_and_their_handles(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
result = await backend.layout_rename(SHEET, OTHER)
assert result["ok"] is True
assert OTHER in _names(backend) and SHEET not in _names(backend)
info = await backend.entity_get(line.handle)
assert info.handle == line.handle
async def test_renaming_the_current_layout_keeps_the_caller_on_it(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.layout_rename(SHEET, OTHER)
assert (await backend.layout_list())["current"] == OTHER
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in {e.dxf.handle for e in backend._doc.layouts.get(OTHER)}
@pytest.mark.parametrize("bad", ["", " ", "Bad/Name", "Bad*Name", "Bad?Name"])
async def test_an_invalid_new_name_is_refused(backend, bad):
"""`rename()` accepts all of these and the file still audits clean."""
await backend.layout_create(SHEET)
result = await backend.layout_rename(SHEET, bad)
assert result["ok"] is False
assert SHEET in _names(backend)
async def test_renaming_onto_an_existing_name_is_refused(backend):
await backend.layout_create(SHEET)
await backend.layout_create(OTHER)
result = await backend.layout_rename(SHEET, OTHER)
assert result["ok"] is False
assert SHEET in _names(backend) and OTHER in _names(backend)
@pytest.mark.parametrize("args", [("Model", OTHER), (SHEET, "Model")])
async def test_model_space_cannot_be_renamed_or_shadowed(backend, args):
await backend.layout_create(SHEET)
result = await backend.layout_rename(*args)
assert result["ok"] is False
assert "Model" in _names(backend)
async def test_renaming_a_missing_layout_refuses(backend):
result = await backend.layout_rename("NOSUCH", OTHER)
assert result["ok"] is False
# ── names are case-insensitive, like the engine underneath ──────────────────
#
# ezdxf resolves layout names case-insensitively in `get`, `new`, `rename`,
# `delete` and `set_active_layout` — but `names()` returns the *stored* case, so
# every guard written as `name in doc.layouts.names()` disagrees with the engine
# it is guarding. Both directions of that disagreement are bugs: a duplicate
# slips past the check and dies on a raw `DXFValueError` from inside ezdxf, and
# an existing layout typed in another case is reported as missing.
async def test_creating_a_layout_that_differs_only_by_case_refuses_cleanly(backend):
await backend.layout_create(SHEET)
result = await backend.layout_create(SHEET.upper())
assert result["ok"] is False
assert _names(backend).count(SHEET) == 1
@pytest.mark.parametrize(
"call",
[
lambda b, name: b.layout_delete(name),
lambda b, name: b.layout_set_current(name),
lambda b, name: b.viewport_list(name),
lambda b, name: b.layout_copy(name, "Copy-Of-It"),
],
ids=["delete", "set_current", "viewport_list", "copy_source"],
)
async def test_an_existing_layout_is_found_whatever_case_it_is_typed_in(backend, call):
await backend.layout_create(SHEET)
await backend.layout_create("Spare") # so delete is not refused as the last sheet
result = await call(backend, SHEET.upper())
assert result["ok"] is True, f"{SHEET.upper()} must resolve to {SHEET}"
@pytest.mark.parametrize(
"call",
[
lambda b, name: b.layout_rename("Spare", name),
lambda b, name: b.layout_copy("Spare", name),
],
ids=["rename_target", "copy_target"],
)
async def test_a_target_name_colliding_only_by_case_refuses_cleanly(backend, call):
await backend.layout_create(SHEET)
await backend.layout_create("Spare")
result = await call(backend, SHEET.upper())
assert result["ok"] is False
assert "Spare" in _names(backend)
async def test_a_layout_can_be_renamed_to_a_different_spelling_of_itself(backend):
"""Re-casing a name is a rename onto itself, not a collision."""
await backend.layout_create(SHEET)
result = await backend.layout_rename(SHEET, SHEET.upper())
assert result["ok"] is True
assert SHEET.upper() in _names(backend)
# ── layout_copy ─────────────────────────────────────────────────────────────
async def test_a_copy_carries_the_geometry_and_leaves_the_source_alone(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.entity_create_circle(50, 50, 10)
await backend.layout_set_current("Model")
result = await backend.layout_copy(SHEET, OTHER)
assert result["ok"] is True
assert result["entities_copied"] == 2
assert result["skipped"] == []
assert _entities_in(backend, OTHER) == _entities_in(backend, SHEET)
async def test_the_copy_is_independent_of_its_source(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
await backend.layout_copy(SHEET, OTHER)
await backend.layout_delete(SHEET)
assert _entities_in(backend, OTHER) == ["LINE"]
async def test_the_copy_survives_a_save_and_reload_with_a_clean_audit(backend, tmp_path):
"""The Auditor is not evidence on its own, so check the structure too."""
import ezdxf
from ezdxf.audit import Auditor
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.entity_create_line(0, 0, 100, 0)
await backend.layout_set_current("Model")
await backend.layout_copy(SHEET, OTHER)
path = tmp_path / "copied.dxf"
await backend.drawing_save_as(str(path))
doc = ezdxf.readfile(str(path))
auditor = Auditor(doc)
auditor.run()
assert not auditor.errors and not auditor.fixes
assert OTHER in doc.layouts.names()
assert [e.dxftype() for e in doc.layouts.get(OTHER)] == ["LINE"]
async def test_no_two_layouts_end_up_sharing_a_tab_order(backend):
"""A delete leaves a gap; letting new() assign taborder duplicates it."""
await backend.layout_create(SHEET)
await backend.layout_create("Scratch")
await backend.layout_delete("Scratch")
await backend.layout_copy(SHEET, OTHER)
orders = [backend._doc.layouts.get(n).dxf.taborder for n in _names(backend) if n != "Model"]
assert len(orders) == len(set(orders)), f"duplicate taborder: {orders}"
async def test_the_copy_keeps_the_source_paper_size(backend):
await backend.layout_create(SHEET)
source = backend._doc.layouts.get(SHEET)
source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
expected = source.get_paper_limits()
await backend.layout_copy(SHEET, OTHER)
assert backend._doc.layouts.get(OTHER).get_paper_limits() == expected
async def test_copying_model_space_is_refused(backend):
"""Otherwise the construction happily dumps model geometry onto a sheet."""
result = await backend.layout_copy("Model", OTHER)
assert result["ok"] is False
assert OTHER not in _names(backend)
async def test_a_blank_source_does_not_resolve_to_whatever_sheet_is_active(backend):
"""ezdxf's `layouts.get("")` returns the active paper space."""
result = await backend.layout_copy("", OTHER)
assert result["ok"] is False
assert OTHER not in _names(backend)
async def test_copying_onto_an_existing_name_is_refused(backend):
await backend.layout_create(SHEET)
await backend.layout_create(OTHER)
result = await backend.layout_copy(SHEET, OTHER)
assert result["ok"] is False
async def test_an_associative_hatch_does_not_keep_pointing_into_the_source(backend):
"""A dangling cross-layout boundary reference the Auditor cannot see."""
from ezdxf.math import Vec3
await backend.layout_create(SHEET)
space = backend._doc.layouts.get(SHEET)
poly = space.add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10)], close=True)
hatch = space.add_hatch(color=2)
path = hatch.paths.add_polyline_path(
[(Vec3(v[0], v[1]).x, Vec3(v[0], v[1]).y) for v in [(0, 0), (10, 0), (10, 10), (0, 10)]],
is_closed=True,
)
path.source_boundary_objects = [poly.dxf.handle]
hatch.set_seed_points([(5, 5)])
result = await backend.layout_copy(SHEET, OTHER)
assert result["ok"] is True
source_handles = {e.dxf.handle for e in backend._doc.layouts.get(SHEET)}
for entity in backend._doc.layouts.get(OTHER):
if entity.dxftype() != "HATCH":
continue
for boundary in entity.paths:
leaked = set(getattr(boundary, "source_boundary_objects", [])) & source_handles
assert not leaked, f"copy still points at the source layout: {leaked}"
async def test_the_copy_does_not_end_up_with_two_main_viewports(backend):
"""Copying a page-set-up layout duplicates the id-1 viewport silently."""
await backend.layout_create(SHEET)
source = backend._doc.layouts.get(SHEET)
source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
await backend.layout_copy(SHEET, OTHER)
target = backend._doc.layouts.get(OTHER)
mains = [vp for vp in target.viewports() if vp.dxf.get("id", 0) == 1]
assert len(mains) <= 1, "a layout may have at most one main viewport"
if mains:
assert target.dxf.viewport_handle == mains[0].dxf.handle
+25 -9
View File
@@ -10,6 +10,7 @@ import pytest_asyncio
import config
import server
from backends.base import UnsupportedCapabilityError
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
@@ -100,20 +101,35 @@ async def test_export_pdf_layout(backend, tmp_path):
async def test_solids_unsupported_headlessly(backend):
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
extrude = await backend.solid_extrude("FF", 10)
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
boolean = await backend.solid_boolean("FF", "FE", "union")
for result in (box, cylinder, extrude, revolve, boolean):
assert result["ok"] is False
assert result["capability"] == "solid_3d"
"""T0.7: these refuse by *raising*, so the refusal middleware can see them.
They used to return ``{"ok": False, "capability": "solid_3d"}``, which left
``is_error`` False on a call that did nothing and put the refusal outside
the one place that turns a capability boundary into a wire payload. The
payload itself is unchanged — ``to_dict()`` still yields exactly what the
old return value did.
"""
calls = [
backend.solid_box(0, 0, 0, 10, 10, 10),
backend.solid_cylinder(0, 0, 0, 5, 10),
backend.solid_extrude("FF", 10),
backend.solid_revolve("FF", 0, 0, 0, 1),
backend.solid_boolean("FF", "FE", "union"),
]
for call in calls:
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await call
assert excinfo.value.capability == "solid_3d"
assert excinfo.value.to_dict()["ok"] is False
assert excinfo.value.to_dict()["capability"] == "solid_3d"
async def test_capability_map_reports_paper_space_and_solids(backend):
features = backend.capabilities().to_dict()["features"]
assert features["paper_space"]["supported"] is True
assert features["viewport_render"]["supported"] is False
# Not False: the headless renderer does project model content through
# viewports. See tests/test_viewport_ops.py for the measurement.
assert features["viewport_render"]["supported"] is True
assert features["solid_3d"]["supported"] is False
+200
View File
@@ -0,0 +1,200 @@
"""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)
+274
View File
@@ -0,0 +1,274 @@
"""T0.4 — coordinates that cross the MCP boundary are WCS, on both engines.
DXF does not store 2D geometry in world coordinates. A CIRCLE, ARC, LWPOLYLINE,
TEXT or INSERT lives in an Object Coordinate System derived from its ``extrusion``
vector by the Arbitrary Axis Algorithm. Mirroring flips the extrusion to
``(0, 0, -1)`` and leaves the stored x *unnegated*, so reading ``dxf.center``
raw returns a point the entity is not at.
The word "extrusion" appeared nowhere in this repository, so every such read was
wrong and silent. Measured: after the server's own ``entity_mirror`` across the Y
axis, a circle drawn at (30, 20) reported ``center [30.0, 20.0]`` while the *same
response's* ``bounding_box`` said ``min [-35, 15] / max [-25, 25]`` — the payload
contradicted itself by 60 mm, and ``point_from_snap``, ``entity_select_smart`` and
``entity_offset`` all inherited the error.
Two fences guard the obvious wrong fixes:
* ``test_wcs_entities_are_left_alone`` — LINE, MTEXT, ELLIPSE and SPLINE all carry
an ``extrusion`` attribute too, and are already correct because ezdxf makes
their ``ocs()`` a pass-through. Keying the fix off "has extrusion" breaks them.
* ``test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle`` — the read and
write paths were *consistently* wrong, so normalising reads without the write
inverse would turn a harmless no-op into a real 60 mm teleport.
"""
from __future__ import annotations
import pytest
from backends.base import UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
pytestmark = pytest.mark.asyncio
#: Mirror line = the Y axis, i.e. x -> -x. This is what produces extrusion (0,0,-1).
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
def _assert_points(actual, expected) -> None:
"""pytest.approx refuses nested sequences, so compare point by point."""
assert len(actual) == len(expected), f"{actual} != {expected}"
for got, want in zip(actual, expected, strict=True):
assert got == pytest.approx(want, abs=1e-9), f"{actual} != {expected}"
def _bbox_centre(info) -> tuple[float, float]:
"""Centre of the entity's own bounding box — an independent oracle.
``ezdxf.bbox.extents`` flattens the entity through the same renderer AutoCAD
would, so it is WCS whatever the entity's frame is. It is the field that
exposed the contradiction, and it is not computed from the OCS attributes.
"""
box = info.properties["bounding_box"]
return (
(box["min"][0] + box["max"][0]) / 2.0,
(box["min"][1] + box["max"][1]) / 2.0,
)
# ── the fence: entities that were never wrong ───────────────────────────────
async def test_wcs_entities_are_left_alone(backend):
"""LINE/MTEXT/ELLIPSE/SPLINE carry extrusion but report WCS already."""
line = await backend.entity_create_line(10, 5, 30, 5)
ellipse = await backend.entity_create_ellipse(20, 40, 10, 5)
text = await backend.entity_create_mtext("hello", 25, 60, 2.5)
for created in (line, ellipse, text):
mirrored = await backend.entity_mirror(created.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
cx, cy = _bbox_centre(info)
for key in ("start", "end", "center", "insertion"):
point = info.properties.get(key)
if point is None:
continue
assert point[0] < 0, f"{info.type}.{key} must land left of x=0 after mirroring"
if "center" in info.properties:
assert info.properties["center"] == pytest.approx([cx, cy], abs=1e-6)
# ── the defect ──────────────────────────────────────────────────────────────
async def test_entity_get_center_agrees_with_its_own_bounding_box(backend):
"""A response that contradicts itself is the clearest possible proof."""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
assert info.properties["radius"] == pytest.approx(5.0)
async def test_mirrored_arc_reports_wcs_centre_and_sweep(backend):
"""A left-handed frame reverses the sweep, so start/end also swap."""
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
mirrored = await backend.entity_mirror(arc.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
# Mirroring the first quadrant across x=0 gives the second: the bbox says the
# arc occupies x in [-35, -30], y in [20, 25], i.e. 90 deg -> 180 deg.
assert info.properties["start_angle"] == pytest.approx(90.0, abs=1e-6)
assert info.properties["end_angle"] == pytest.approx(180.0, abs=1e-6)
# Length is frame-invariant and must not change.
assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 / 2, rel=1e-9)
async def test_mirrored_polyline_points_are_wcs(backend):
poly = await backend.entity_create_polyline([[10, 0], [20, 0], [20, 10]], closed=False)
mirrored = await backend.entity_mirror(poly.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
async def test_mirrored_text_insertion_is_wcs(backend):
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
mirrored = await backend.entity_mirror(text.handle, *Y_AXIS)
info = await backend.entity_get(mirrored.handle)
assert info.properties["insertion"] == pytest.approx([-40.0, 15.0])
# ── the trap: reads must not be normalised without the write inverse ────────
async def test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle(backend):
"""Read the centre, write it straight back: the circle must not move.
Before the fix this passed for the wrong reason — read and write were
*consistently* wrong. It is here so that fixing only the read is caught.
"""
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
before = await backend.entity_get(mirrored.handle)
centre = before.properties["center"]
await backend.entity_edit_geometry(mirrored.handle, cx=centre[0], cy=centre[1])
after = await backend.entity_get(mirrored.handle)
assert after.properties["center"] == pytest.approx(centre)
assert after.properties["bounding_box"] == before.properties["bounding_box"]
async def test_entity_edit_geometry_moves_a_mirrored_circle_where_asked(backend):
circle = await backend.entity_create_circle(30, 20, 5)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
await backend.entity_edit_geometry(mirrored.handle, cx=-10.0, cy=40.0)
info = await backend.entity_get(mirrored.handle)
assert info.properties["center"] == pytest.approx([-10.0, 40.0])
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
async def test_entity_offset_on_a_mirrored_circle_is_concentric_and_shrinks(backend):
"""The offset used to land 60 mm away *and* grow instead of shrink."""
circle = await backend.entity_create_circle(30, 20, 10)
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
inner = await backend.entity_offset(mirrored.handle, 2.0, side_x=-30.0, side_y=20.0)
info = await backend.entity_get(inner.handle)
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
assert info.properties["radius"] == pytest.approx(8.0), "offset toward the centre shrinks"
# ── tilted planes: the one case 2D xy cannot address ────────────────────────
def _tilt(backend, handle, normal=(0.0, 0.6, 0.8)) -> None:
"""Put an entity in a plane that is not parallel to WCS XY."""
backend._doc.entitydb.get(handle).dxf.extrusion = normal
async def test_a_tilted_entity_omits_what_xy_cannot_express(backend):
"""A tilted circle projects to an ellipse, so `radius` has no WCS-XY meaning.
Omitting the field is silence; reporting 5.0 for a footprint of 5.0 x 3.99
would be a fresh silent wrong number of exactly the kind this release exists
to remove.
"""
circle = await backend.entity_create_circle(30, 20, 5)
_tilt(backend, circle.handle)
info = await backend.entity_get(circle.handle)
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
assert "plane_normal" in info.properties
assert info.properties["plane_normal"] == pytest.approx([0.0, 0.6, 0.8])
assert "radius" not in info.properties
async def test_a_tilted_arc_omits_its_angles(backend):
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
_tilt(backend, arc.handle)
info = await backend.entity_get(arc.handle)
assert "start_angle" not in info.properties
assert "end_angle" not in info.properties
assert "plane_normal" in info.properties
async def test_entity_edit_geometry_refuses_a_tilted_plane_with_capability_shape(backend):
circle = await backend.entity_create_circle(30, 20, 5)
_tilt(backend, circle.handle)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.entity_edit_geometry(circle.handle, cx=1.0, cy=2.0)
assert excinfo.value.capability == "ocs_tilted_plane"
payload = excinfo.value.to_dict()
assert payload["ok"] is False
assert "entity_move" in payload["error"], "a refusal must name the way forward"
# ── the boundary is pre-checkable ───────────────────────────────────────────
async def test_ocs_capability_keys_are_declared_on_both_backends():
for features in (
EzdxfBackend().capabilities().features,
ComBackend().capabilities().features,
):
assert features["ocs_normalized"].supported is True
assert features["ocs_normalized"].reason, "the residual must be named, not implied"
assert features["ocs_tilted_plane"].supported is False
# ── COM: the one genuinely wrong read on that engine ────────────────────────
def _fake_lwpolyline(coords, normal):
"""The minimum surface ``_entity_info`` touches for a lightweight polyline."""
import types
return types.SimpleNamespace(
ObjectName="AcDbLWPolyline",
Coordinates=coords,
Normal=normal,
Elevation=0.0,
Handle="2F",
Layer="0",
Color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
Length=30.0,
GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)),
)
async def test_com_lwpolyline_coordinates_are_translated_to_wcs():
"""ActiveX returns WCS for everything *except* polyline Coordinates (OCS).
Unit-tested against a fake COM object: this machine has a live AutoCAD and
connecting to it is forbidden, so this path ships verified only at this
level — the CHANGELOG says so.
"""
from backends.com_backend import _entity_info
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0, 20.0, 10.0), (0.0, 0.0, -1.0)))
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
async def test_com_lwpolyline_with_identity_normal_is_untouched():
from backends.com_backend import _entity_info
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0), (0.0, 0.0, 1.0)))
_assert_points(info.properties["points"], [[10, 0], [20, 0]])
+142
View File
@@ -0,0 +1,142 @@
"""M5 — drawing into the layout you selected.
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current": "A3-Sheet"}`
and then every entity created afterwards went into **model space** anyway,
because `_msp()` returned `doc.modelspace()` unconditionally on both engines.
The tool reported a state change that changed nothing about where geometry
landed — proven: a line drawn straight after selecting the layout was in
modelspace and not in the layout.
That is also why the title block could not go on a sheet: it draws through the
same `entity_create_*` calls, so `apply_iso_a3_titleblock` could only ever
produce a border around model-space geometry.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
SHEET = "A3-Sheet"
async def _handles_in(backend, layout_name: str) -> set[str]:
doc = backend._doc
space = doc.modelspace() if layout_name == "Model" else doc.layouts.get(layout_name)
return {e.dxf.handle for e in space}
# ── the current space is where geometry goes ────────────────────────────────
async def test_entities_land_in_the_layout_that_was_selected(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
assert line.handle in await _handles_in(backend, SHEET)
assert line.handle not in await _handles_in(backend, "Model")
async def test_switching_back_to_model_puts_geometry_back_in_model_space(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
on_sheet = await backend.entity_create_circle(10, 10, 5)
await backend.layout_set_current("Model")
in_model = await backend.entity_create_circle(20, 20, 5)
assert on_sheet.handle in await _handles_in(backend, SHEET)
assert in_model.handle in await _handles_in(backend, "Model")
async def test_a_new_drawing_starts_in_model_space(backend):
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
await backend.drawing_new()
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in await _handles_in(backend, "Model")
async def test_selecting_a_missing_layout_does_not_move_the_current_space(backend):
"""A refused switch must leave the caller where they were, not in limbo."""
result = await backend.layout_set_current("NOSUCH")
assert result["ok"] is False
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in await _handles_in(backend, "Model")
async def test_entity_get_finds_an_entity_drawn_on_a_layout(backend):
"""Lookups must follow the geometry, or the handle becomes unusable."""
await backend.layout_create(SHEET)
await backend.layout_set_current(SHEET)
line = await backend.entity_create_line(0, 0, 100, 0)
info = await backend.entity_get(line.handle)
assert info.handle == line.handle
assert info.type == "LINE"
# ── the title block, on a sheet ─────────────────────────────────────────────
async def test_titleblock_can_be_drawn_on_a_paper_space_layout(backend):
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
await backend.layout_create(SHEET)
before_model = await _handles_in(backend, "Model")
result = await apply_iso_a3_titleblock(
backend,
metadata=TitleBlockMetadata(title="BRACKET", drawing_no="D-001"),
layout=SHEET,
)
assert result["ok"] is True
assert result["layout"] == SHEET
on_sheet = await _handles_in(backend, SHEET)
assert len(on_sheet) > 5, "border, inner frame and the block's text all belong here"
assert await _handles_in(backend, "Model") == before_model, "model space untouched"
async def test_titleblock_restores_the_space_the_caller_was_in(backend):
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
await backend.layout_create(SHEET)
await apply_iso_a3_titleblock(
backend,
metadata=TitleBlockMetadata(title="X", drawing_no="1"),
layout=SHEET,
)
line = await backend.entity_create_line(0, 0, 10, 0)
assert line.handle in await _handles_in(backend, "Model"), (
"drawing a sheet border must not silently move the caller onto the sheet"
)
async def test_titleblock_without_a_layout_still_draws_in_model_space(backend):
"""The pre-1.5.0 behaviour stays the default."""
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
result = await apply_iso_a3_titleblock(
backend, metadata=TitleBlockMetadata(title="X", drawing_no="1")
)
assert result["ok"] is True
assert result["layout"] == "Model"
assert len(await _handles_in(backend, "Model")) > 5
async def test_titleblock_on_a_missing_layout_refuses(backend):
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
with pytest.raises(Exception, match="NOSUCH"):
await apply_iso_a3_titleblock(
backend,
metadata=TitleBlockMetadata(title="X", drawing_no="1"),
layout="NOSUCH",
)
+44
View File
@@ -51,6 +51,50 @@ def test_preflight_normalizes_complete_spec_and_hash_is_deterministic():
assert first.spec_hash == second.spec_hash
def test_preflight_question_text_is_english():
"""drawing_preflight returns this text to every user of a public English
package; it shipped in Turkish through v1.4."""
result = preflight_drawing("L bracket", requirements={})
by_code = {q.code: q.question for q in result.questions}
assert by_code["MISSING_UNITS"] == "What drawing units should be used?"
assert by_code["MISSING_PART_TYPE"] == "What kind of part or drawing is being produced?"
assert by_code["MISSING_DIMENSIONS"] == "What are the main production dimensions?"
assert by_code["MISSING_TOLERANCE_POLICY"] == "Which tolerance policy should apply?"
def test_preflight_conflict_message_is_english():
requirements = _complete_requirements()
requirements["constraints"] = [
{"field": "dimensions.width", "value": 80},
{"field": "dimensions.width", "value": 90},
]
result = preflight_drawing("L bracket", requirements=requirements)
assert result.conflicts[0].message == ("Conflicting values were supplied for dimensions.width.")
def test_preflight_user_facing_text_is_ascii():
"""Guard against any future non-English string leaking back into the tool
surface — codes and field names stay machine-readable, prose stays ASCII."""
requirements = _complete_requirements()
requirements["constraints"] = [
{"field": "dimensions.width", "value": 80},
{"field": "dimensions.width", "value": 90},
]
empty = preflight_drawing("L bracket", requirements={})
conflicting = preflight_drawing("L bracket", requirements=requirements)
prose = [q.question for q in empty.questions]
prose += [choice for q in empty.questions for choice in q.choices]
prose += [c.message for c in conflicting.conflicts]
assert prose, "expected some user-facing text to check"
for text in prose:
assert text.isascii(), f"non-ASCII user-facing text: {text!r}"
def test_preflight_detects_conflicting_constraints():
requirements = _complete_requirements()
requirements["constraints"] = [
+7 -3
View File
@@ -2,20 +2,24 @@ from __future__ import annotations
import pytest
import config
from engineering.refiner import RepairRegistry, refine_drawing
pytestmark = pytest.mark.asyncio
async def test_transaction_stack_is_separate_from_drawing_undo(backend):
async def test_transaction_stack_is_separate_from_drawing_undo(backend, monkeypatch):
"""An undo must not consume a transaction checkpoint, or vice versa."""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
await backend.entity_create_line(0, 0, 10, 0)
await backend.transaction_begin()
undo = await backend.drawing_undo()
status = await backend.system_status()
assert undo["ok"] is False
assert status["transaction_depth"] == 1
assert undo["ok"] is True, "undo walks its own history"
assert status["transaction_depth"] == 1, "and leaves the checkpoint standing"
await backend.transaction_rollback()
+73 -17
View File
@@ -6,13 +6,28 @@ none of them can drift again:
- pyproject version == CHANGELOG top release == README snapshot major.minor
- version.py fallback parser returns the pyproject version
- README snapshot tool/resource/prompt counts == live @mcp.* decorator counts
- README snapshot tool/resource/prompt counts == the generated tool inventory
- every ``SECTION n: ... (N tools)`` header matches the decorators below it
- server.json version == pyproject, and the README carries the mcp-name marker
- every wheel ``only-include`` entry is COPY'd into the Docker image
Two of these used to count the literal text ``@mcp.tool(`` in server.py, which
made that string load-bearing in prose: an example in a docstring inflated the
count. Neither does any more.
* The README snapshot counts now come from ``docs/tool-inventory.json``, which
``tests/test_tool_inventory.py`` holds equal to the live component registry —
so the chain README -> inventory -> registry is closed without this module
having to import the server.
* The section-header gate stays (a "SECTION n" is a fact about server.py's
layout that no runtime registry can see) but counts *parsed* decorators
instead of matched text.
"""
from __future__ import annotations
import ast
import json
import re
import tomllib
from pathlib import Path
@@ -90,31 +105,76 @@ def test_readme_snapshot_matches_pyproject_minor() -> None:
def test_readme_snapshot_counts_match_registrations() -> None:
src = _server_source()
tools = len(re.findall(r"@mcp\.tool\(", src))
resources = len(re.findall(r"@mcp\.resource\(", src))
prompts = len(re.findall(r"@mcp\.prompt\(", src))
"""README snapshot == the surface, via the generated inventory.
The counts used to be a regex tally of ``@mcp.tool(`` / ``@mcp.resource(`` /
``@mcp.prompt(`` in server.py's text. They now come from
``docs/tool-inventory.json``, which is built from the live component
registry and gated against it by tests/test_tool_inventory.py — so this
still fails when a registration is added without updating the README, but
it can no longer be moved by prose that merely *mentions* a decorator.
"""
inventory = json.loads((ROOT / "docs" / "tool-inventory.json").read_text(encoding="utf-8"))
totals = inventory["totals"]
live = (totals["tools"], totals["resources"], totals["prompts"])
_, readme_tools, readme_resources, readme_prompts = _readme_snapshot()
assert (readme_tools, readme_resources, readme_prompts) == (tools, resources, prompts), (
assert (readme_tools, readme_resources, readme_prompts) == live, (
f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} "
f"(tools/resources/prompts) but server.py registers {tools}/{resources}/{prompts}"
f"(tools/resources/prompts) but the server registers {live[0]}/{live[1]}/{live[2]} "
"(per docs/tool-inventory.json; regenerate it with "
"`python scripts/generate_tool_inventory.py` if that is the stale one)"
)
def _tool_decorator_lines(src: str) -> list[int]:
"""Line number of every ``mcp.tool`` decorator, parsed rather than matched.
Parsing is what keeps the *string* ``@mcp.tool(`` from being load-bearing:
an occurrence in a docstring or comment is not a decorator node, so it
cannot inflate a section's count.
"""
lines: list[int] = []
for node in ast.walk(ast.parse(src)):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for decorator in node.decorator_list:
# Both spellings count: `@mcp.tool` and `@mcp.tool(...)`.
target = decorator.func if isinstance(decorator, ast.Call) else decorator
if (
isinstance(target, ast.Attribute)
and target.attr == "tool"
and isinstance(target.value, ast.Name)
and target.value.id == "mcp"
):
lines.append(decorator.lineno)
return sorted(lines)
def test_section_header_counts_match_decorators() -> None:
"""Kept, and not replaceable by the inventory: a section is a source-layout
fact (which tools sit under which header comment), invisible to the live
registry, and it does not line up with the tag-derived groups the inventory
records — SECTION 8 alone spans analysis + batch + templates + validation."""
src = _server_source()
lines = src.splitlines()
decorators = _tool_decorator_lines(src)
# Segment boundaries: every SECTION header (counted or not) ends a segment.
boundaries = [
number
for number, line in enumerate(lines, start=1)
if re.search(r"#[^\n]*SECTION [\w]+:", line)
]
boundaries.append(len(lines) + 1)
headers = [
(m.start(), m.group(1), m.group(2))
for m in re.finditer(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", src)
(number, match.group(1), match.group(2))
for number, line in enumerate(lines, start=1)
if (match := re.search(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", line))
]
assert headers, "no counted SECTION headers found in server.py"
# Segment boundaries: every SECTION header (counted or not) ends a segment.
boundaries = sorted(m.start() for m in re.finditer(r"#[^\n]*SECTION [\w]+:", src))
boundaries.append(len(src))
mismatches: list[str] = []
for start, section_id, declared in headers:
end = min(b for b in boundaries if b > start)
actual = len(re.findall(r"@mcp\.tool\(", src[start:end]))
actual = sum(1 for line in decorators if start <= line < end)
if actual != int(declared):
mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}")
assert not mismatches, "; ".join(mismatches)
@@ -122,8 +182,6 @@ def test_section_header_counts_match_decorators() -> None:
def test_server_json_versions_match_pyproject() -> None:
"""MCP registry manifest must ship the same version as the package."""
import json
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
expected = _pyproject_version()
assert manifest["version"] == expected, (
@@ -141,8 +199,6 @@ def test_server_json_versions_match_pyproject() -> None:
def test_readme_contains_mcp_name_marker() -> None:
"""PyPI ownership validation for the MCP registry needs this marker in the
README (which ships to PyPI as the long description)."""
import json
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
readme = (ROOT / "README.md").read_text(encoding="utf-8")
assert f"mcp-name: {manifest['name']}" in readme
+477
View File
@@ -0,0 +1,477 @@
"""Result shaping — field projection + compact rows on the collection tools.
The release made *discovery* cheap and left the other half of the token bill
untouched: what the tools hand back. On a 300-entity drawing a single default
``entity_list(limit=100)`` is ~24 kB of JSON the model has to read, and roughly
a third of it is a ``properties.bounding_box`` nobody asked for.
ONE mechanism, two parameters, spelled identically on every collection tool
(:data:`server.SHAPED_RESULT_TOOLS`)::
fields=["handle", "type", "layer"] # projection, order preserved
compact=True # columnar envelope instead of dicts
What these tests guard is the mechanism, not the byte counts:
* **Backwards compatibility is the hard constraint.** Omitting both parameters
must return exactly the 1.4.0 value. Every pre-existing test in the suite is
the real gate; the test here only pins the intent.
* **No per-tool dialect.** ``SHAPED_RESULT_TOOLS`` is checked against the live
registry in *both* directions, and the two parameters' JSON Schemas are
compared across every tool that carries them. A tool that grows a ``columns=``
or a ``brief=`` of its own fails here.
* **A typo is an error, never an empty column.** An unknown field name must come
back naming the valid fields. This is the difference between a projection and
a footgun: ``fields=["handel"]`` returning 100 rows of ``{"handel": null}``
reads as "the drawing has no handles".
* **Truncation honesty.** A silently truncated list read as complete is a
correctness bug, not a token one. ``entity_list`` pages, and in 1.4.0 the
return value carried no signal at all that more rows existed; the two
``analysis_select_by_*`` tools capped at ``MAX_LIST_LIMIT`` and said so only
in a log notification most clients never show the model. The compact envelope
is the mode that can carry ``total``/``truncated``/``next_offset``, and
``total`` is measured against the *unfiltered* matching set rather than the
page, which is why the backends gained ``entity_count``.
"""
from __future__ import annotations
import json
import pytest
import pytest_asyncio
from fastmcp import Client
from fastmcp.exceptions import ToolError
import config
import server
from backends.base import BlockInfo, EntityInfo, LayerInfo
pytestmark = pytest.mark.asyncio
ENTITY_FIELDS = ("handle", "type", "layer", "color", "linetype", "visible", "properties")
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document.
The env var is what ``server._make_backend`` reads; setting
``config.settings.backend`` would leave a Windows box with AutoCAD open
talking to the operator's live drawing.
"""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def _draw(client, circles: int = 30, lines: int = 30) -> None:
"""A small mixed drawing: CIRCLEs carry properties LINEs do not."""
steps = []
for i in range(circles):
steps.append(
{
"tool": "entity_create_circle",
"args": {"cx": float(i), "cy": 0.0, "radius": 1.0 + i, "layer": "GEOMETRY"},
}
)
for i in range(lines):
steps.append(
{
"tool": "entity_create_line",
"args": {
"x1": 0.0,
"y1": float(i),
"x2": 10.0,
"y2": float(i),
"layer": "HIDDEN",
},
}
)
result = (await client.call_tool("cad_batch", {"steps": steps})).structured_content
assert result["failed"] == 0, result
def _rows(result) -> list | dict:
"""Unwrap FastMCP's ``{"result": ...}`` output wrapper."""
return (result.structured_content or {})["result"]
def dumps(obj) -> str:
"""Compact JSON — how a payload is actually charged for."""
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
# ---------------------------------------------------------------------------
# The mechanism is one mechanism
# ---------------------------------------------------------------------------
async def test_every_shaped_tool_carries_both_parameters():
tools = {t.name: t for t in await server._registered_tools()}
missing = [name for name in server.SHAPED_RESULT_TOOLS if name not in tools]
assert not missing, f"SHAPED_RESULT_TOOLS names unregistered tools: {missing}"
for name in server.SHAPED_RESULT_TOOLS:
properties = (tools[name].parameters or {}).get("properties", {})
for param in server.RESULT_SHAPE_PARAMS:
assert param in properties, f"{name} is listed as shaped but has no {param!r} parameter"
async def test_no_tool_carries_the_parameters_without_being_declared_shaped():
"""The closed list is checked in both directions, so it cannot silently rot."""
tools = await server._registered_tools()
carriers = {
tool.name
for tool in tools
if set(server.RESULT_SHAPE_PARAMS) & set((tool.parameters or {}).get("properties", {}))
}
assert carriers == set(server.SHAPED_RESULT_TOOLS)
async def test_a_shaped_tool_never_carries_only_half_the_mechanism():
for tool in await server._registered_tools():
properties = set((tool.parameters or {}).get("properties", {}))
present = [param for param in server.RESULT_SHAPE_PARAMS if param in properties]
assert len(present) in (0, len(server.RESULT_SHAPE_PARAMS)), (
f"{tool.name} declares {present} — the two parameters ship together or not at all"
)
async def test_the_parameter_schemas_are_identical_across_every_shaped_tool():
"""One spelling everywhere: no per-tool dialect of the same idea."""
tools = {t.name: t for t in await server._registered_tools()}
seen: dict[str, list[tuple[str, str]]] = {param: [] for param in server.RESULT_SHAPE_PARAMS}
for name in server.SHAPED_RESULT_TOOLS:
properties = (tools[name].parameters or {})["properties"]
for param in server.RESULT_SHAPE_PARAMS:
seen[param].append((name, dumps(properties[param])))
for param, entries in seen.items():
distinct = {schema for _, schema in entries}
assert len(distinct) == 1, f"{param!r} is spelled {len(distinct)} different ways: {entries}"
# ---------------------------------------------------------------------------
# Backwards compatibility
# ---------------------------------------------------------------------------
async def test_omitting_both_parameters_returns_the_untouched_record(client):
await _draw(client, circles=3, lines=0)
rows = _rows(await client.call_tool("entity_list", {}))
assert isinstance(rows, list) and len(rows) == 3
assert set(rows[0]) == set(ENTITY_FIELDS), "the default record must keep every field"
assert rows[0]["properties"]["radius"] == pytest.approx(1.0)
async def test_explicit_defaults_are_the_same_value_as_omitting_them(client):
await _draw(client, circles=4, lines=4)
omitted = _rows(await client.call_tool("entity_list", {}))
explicit = _rows(await client.call_tool("entity_list", {"fields": None, "compact": False}))
assert omitted == explicit
@pytest.mark.parametrize(
"tool,args,spec",
[
("layer_list", {}, LayerInfo),
("block_list", {}, BlockInfo),
("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, EntityInfo),
("analysis_select_by_type", {"entity_type": "CIRCLE"}, EntityInfo),
("analysis_find_in_region", {"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6}, EntityInfo),
("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, EntityInfo),
],
)
async def test_default_shape_is_a_list_of_full_records(client, tool, args, spec):
await _draw(client, circles=3, lines=3)
rows = _rows(await client.call_tool(tool, args))
assert isinstance(rows, list)
for row in rows:
assert set(row) == set(spec.__dataclass_fields__)
# ---------------------------------------------------------------------------
# Projection
# ---------------------------------------------------------------------------
async def test_projection_keeps_only_the_named_fields_in_order(client):
await _draw(client, circles=5, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "type"]}))
assert all(list(row) == ["handle", "type"] for row in rows)
assert all(row["type"] == "CIRCLE" for row in rows)
async def test_the_projection_order_is_the_callers_not_the_dataclasses(client):
await _draw(client, circles=3, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["layer", "handle"]}))
assert all(list(row) == ["layer", "handle"] for row in rows)
async def test_a_repeated_field_is_projected_once(client):
"""Repeats are a caller slip, not an error — but they must not double the column."""
await _draw(client, circles=2, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "handle", "type"]}))
assert all(list(row) == ["handle", "type"] for row in rows)
async def test_a_dict_valued_field_can_still_be_taken_whole(client):
await _draw(client, circles=2, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["properties"]}))
assert all(set(row) == {"properties"} for row in rows)
assert rows[0]["properties"]["radius"] == pytest.approx(1.0)
async def test_projection_reaches_one_nested_property(client):
await _draw(client, circles=3, lines=0)
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "properties.radius"]}))
assert [row["properties.radius"] for row in rows] == [1.0, 2.0, 3.0]
async def test_a_nested_key_absent_from_some_rows_is_null_not_an_error(client):
"""LINEs have no radius; the column exists because CIRCLEs in the set do."""
await _draw(client, circles=2, lines=2)
rows = _rows(await client.call_tool("entity_list", {"fields": ["type", "properties.radius"]}))
by_type = {row["type"]: row["properties.radius"] for row in rows}
assert by_type["CIRCLE"] is not None
assert by_type["LINE"] is None
async def test_an_unknown_field_is_an_error_naming_the_valid_fields(client):
await _draw(client, circles=2, lines=0)
with pytest.raises(ToolError) as excinfo:
await client.call_tool("entity_list", {"fields": ["handel"]})
message = str(excinfo.value)
assert "handel" in message
for field in ENTITY_FIELDS:
assert field in message, f"the error must name {field!r} as a valid field"
async def test_an_unknown_nested_key_is_an_error_naming_the_available_keys(client):
await _draw(client, circles=2, lines=0)
with pytest.raises(ToolError) as excinfo:
await client.call_tool("entity_list", {"fields": ["properties.diameter"]})
message = str(excinfo.value)
assert "diameter" in message
assert "radius" in message, "the error must list the sub-keys this result actually has"
async def test_an_empty_projection_is_an_error_not_an_empty_column(client):
await _draw(client, circles=2, lines=0)
with pytest.raises(ToolError) as excinfo:
await client.call_tool("entity_list", {"fields": []})
assert "handle" in str(excinfo.value)
async def test_the_valid_field_list_follows_the_row_dataclass_not_entityinfo(client):
"""layer_list rows are LayerInfo, so its error must name LayerInfo's fields."""
with pytest.raises(ToolError) as excinfo:
await client.call_tool("layer_list", {"fields": ["handle"]})
message = str(excinfo.value)
assert "is_frozen" in message and "lineweight" in message
assert "linetype" in message
async def test_projection_works_on_every_shaped_collection_tool(client):
await _draw(client, circles=3, lines=3)
cases = [
("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, ["handle", "type"]),
("analysis_select_by_type", {"entity_type": "LINE"}, ["handle", "layer"]),
(
"analysis_find_in_region",
{"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6},
["handle", "type"],
),
("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, ["handle"]),
("layer_list", {}, ["name", "color"]),
("block_list", {}, ["name"]),
]
for tool, args, fields in cases:
rows = _rows(await client.call_tool(tool, {**args, "fields": fields}))
assert isinstance(rows, list), tool
for row in rows:
assert list(row) == fields, tool
# ---------------------------------------------------------------------------
# Compact envelope
# ---------------------------------------------------------------------------
async def test_compact_returns_a_columnar_envelope(client):
await _draw(client, circles=4, lines=0)
envelope = _rows(await client.call_tool("entity_list", {"compact": True}))
assert isinstance(envelope, dict)
assert set(envelope) == {
"fields",
"rows",
"count",
"offset",
"total",
"truncated",
"next_offset",
}
assert envelope["fields"] == list(ENTITY_FIELDS)
assert envelope["count"] == 4
assert all(len(row) == len(envelope["fields"]) for row in envelope["rows"])
async def test_compact_rows_carry_the_same_values_as_the_dict_form(client):
await _draw(client, circles=3, lines=2)
fields = ["handle", "type", "layer"]
dicts = _rows(await client.call_tool("entity_list", {"fields": fields}))
envelope = _rows(await client.call_tool("entity_list", {"fields": fields, "compact": True}))
assert envelope["fields"] == fields
assert envelope["rows"] == [[row[name] for name in fields] for row in dicts]
async def test_compact_is_much_cheaper_than_the_default_record(client):
"""The point of the exercise, guarded as a ratio rather than a byte count."""
await _draw(client, circles=60, lines=60)
default = dumps(_rows(await client.call_tool("entity_list", {"limit": 120})))
projected = dumps(
_rows(
await client.call_tool(
"entity_list",
{"limit": 120, "fields": ["handle", "type", "layer"], "compact": True},
)
)
)
assert len(projected) < len(default) / 5, (
f"projected+compact was {len(projected)} chars against {len(default)} — "
"the mechanism is not paying for its schema cost"
)
async def test_compact_on_a_shaped_tool_that_already_returns_a_dict(client):
"""``selection_get``'s collection is nested, so its envelope lands under it."""
result = (await client.call_tool("selection_get", {"compact": True})).structured_content
assert isinstance(result["entities"], dict)
assert result["entities"]["fields"] == list(ENTITY_FIELDS)
assert result["entities"]["rows"] == []
plain = (await client.call_tool("selection_get", {})).structured_content
assert plain["entities"] == [], "the default must stay a plain list"
# ---------------------------------------------------------------------------
# Truncation honesty
# ---------------------------------------------------------------------------
async def test_a_paged_entity_list_reports_the_total_it_did_not_return(client):
await _draw(client, circles=40, lines=40)
envelope = _rows(
await client.call_tool("entity_list", {"limit": 10, "compact": True, "fields": ["handle"]})
)
assert envelope["count"] == 10
assert envelope["total"] == 80, "total counts the matching set, not the page"
assert envelope["truncated"] is True
assert envelope["next_offset"] == 10
async def test_the_last_page_is_not_reported_as_truncated(client):
await _draw(client, circles=10, lines=0)
envelope = _rows(
await client.call_tool(
"entity_list", {"limit": 10, "offset": 5, "compact": True, "fields": ["handle"]}
)
)
assert (envelope["count"], envelope["offset"], envelope["total"]) == (5, 5, 10)
assert envelope["truncated"] is False
assert envelope["next_offset"] is None
async def test_total_honours_the_same_filters_as_the_page(client):
await _draw(client, circles=20, lines=30)
envelope = _rows(
await client.call_tool(
"entity_list",
{"type_filter": "LINE", "limit": 5, "compact": True, "fields": ["handle"]},
)
)
assert envelope["total"] == 30, "a filtered page must be measured against the filtered set"
envelope = _rows(
await client.call_tool(
"entity_list",
{"layer_filter": "GEOMETRY", "limit": 5, "compact": True, "fields": ["handle"]},
)
)
assert envelope["total"] == 20
async def test_the_max_list_limit_cap_is_reported_as_truncation(client, monkeypatch):
"""``analysis_select_by_layer`` capped silently in the value before this."""
monkeypatch.setattr(config.settings, "max_list_limit", 4)
await _draw(client, circles=9, lines=0)
envelope = _rows(
await client.call_tool(
"analysis_select_by_layer",
{"layer_name": "GEOMETRY", "compact": True, "fields": ["handle"]},
)
)
assert envelope["count"] == 4
assert envelope["total"] == 9
assert envelope["truncated"] is True
assert envelope["next_offset"] == 4
async def test_an_untruncated_selection_says_so(client):
await _draw(client, circles=6, lines=0)
envelope = _rows(
await client.call_tool(
"analysis_select_by_type",
{"entity_type": "CIRCLE", "compact": True, "fields": ["handle"]},
)
)
assert (envelope["count"], envelope["total"]) == (6, 6)
assert envelope["truncated"] is False
async def test_default_mode_still_truncates_the_way_it_always_did(client, monkeypatch):
"""The compatibility contract cuts both ways: the old value, cap included."""
monkeypatch.setattr(config.settings, "max_list_limit", 4)
await _draw(client, circles=9, lines=0)
rows = _rows(await client.call_tool("analysis_select_by_layer", {"layer_name": "GEOMETRY"}))
assert isinstance(rows, list) and len(rows) == 4
# ---------------------------------------------------------------------------
# entity_count — the backend method the honest `total` needs
# ---------------------------------------------------------------------------
async def test_entity_count_matches_a_full_listing(backend):
for i in range(7):
await backend.entity_create_circle(float(i), 0.0, 1.0, layer="GEOMETRY")
for i in range(5):
await backend.entity_create_line(0.0, float(i), 1.0, float(i), layer="HIDDEN")
assert await backend.entity_count() == 12
assert await backend.entity_count(type_filter="CIRCLE") == 7
assert await backend.entity_count(layer_filter="HIDDEN") == 5
assert await backend.entity_count(type_filter="LINE", layer_filter="GEOMETRY") == 0
async def test_entity_count_filters_case_insensitively_like_entity_list(backend):
await backend.entity_create_circle(0.0, 0.0, 1.0, layer="GEOMETRY")
assert await backend.entity_count(type_filter="circle") == 1
assert await backend.entity_count(layer_filter="geometry") == 1
async def test_entity_count_is_on_the_backend_contract():
from backends.base import AutoCADBackend
assert "entity_count" in AutoCADBackend.__abstractmethods__
async def test_entity_count_never_materialises_the_rows(backend, monkeypatch):
"""A count that paged through entity_list would defeat its own purpose."""
async def _forbidden(*args, **kwargs):
raise AssertionError("entity_count must not call entity_list")
monkeypatch.setattr(backend, "entity_list", _forbidden)
await backend.entity_create_circle(0.0, 0.0, 1.0)
assert await backend.entity_count() == 1
+94
View File
@@ -0,0 +1,94 @@
"""M5 — handle grounding: connecting the picture to the handles.
A screenshot shows the model what the drawing looks like and nothing it can act
on. Every modify tool takes a handle, and there was no way to tell which of the
hex strings from `entity_list` is the circle in the top-left — so the model
either guessed or re-derived the mapping from coordinates, which is exactly what
CLAUDE.md's no-coordinate-guessing rule exists to prevent.
The overlay labels each entity with its handle at its own bounding-box centre,
so "the circle at the top-left" and "handle 2F" become the same statement.
Off by default: labels are ink on the drawing, and on a busy sheet they would
bury the geometry they are meant to explain. When there are more entities than
labels allowed, the image says so rather than labelling a subset and letting it
read as the whole picture.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
def _png_size(data: bytes) -> tuple[int, int]:
"""Width/height straight out of the PNG IHDR — no Pillow needed."""
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
async def test_a_plain_screenshot_is_unchanged(backend):
await backend.entity_create_circle(10, 10, 5)
png = await backend.view_screenshot()
assert png is not None
assert _png_size(png)[0] > 0
async def test_the_overlay_changes_the_image(backend):
"""The labels have to actually be drawn, not merely computed."""
await backend.entity_create_circle(10, 10, 5)
await backend.entity_create_line(0, 0, 20, 0)
plain = await backend.view_screenshot()
labelled = await backend.view_screenshot(overlay_handles=True)
assert labelled is not None
assert labelled != plain, "the overlay produced a byte-identical image"
async def test_the_overlay_reports_which_handles_it_drew(backend):
circle = await backend.entity_create_circle(10, 10, 5)
line = await backend.entity_create_line(0, 0, 20, 0)
report = await backend.view_screenshot_grounded()
assert set(report["labels"]) == {circle.handle, line.handle}
assert report["labelled"] == 2
assert report["total"] == 2
assert report["truncated"] is False
assert report["png"][:8] == b"\x89PNG\r\n\x1a\n"
async def test_each_label_sits_on_its_own_entity(backend):
"""A label in the wrong place is worse than no label — it grounds a lie."""
left = await backend.entity_create_circle(-50, 0, 5)
right = await backend.entity_create_circle(50, 0, 5)
report = await backend.view_screenshot_grounded()
assert report["labels"][left.handle][0] == pytest.approx(-50, abs=1.0)
assert report["labels"][right.handle][0] == pytest.approx(50, abs=1.0)
async def test_a_crowded_drawing_says_it_only_labelled_some(backend):
for i in range(30):
await backend.entity_create_circle(i * 10, 0, 2)
report = await backend.view_screenshot_grounded(max_labels=10)
assert report["labelled"] == 10
assert report["total"] == 30
assert report["truncated"] is True, (
"labelling a subset silently would let the picture read as the whole drawing"
)
async def test_an_empty_drawing_grounds_nothing_without_failing(backend):
report = await backend.view_screenshot_grounded()
assert report["labels"] == {}
assert report["total"] == 0
assert report["png"] is not None
+117 -1
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import os
import pytest
from fastmcp.exceptions import ToolError
from security import sanitize_command, sanitize_lisp, validate_path
from security import command_verbs, sanitize_command, sanitize_lisp, validate_path
# ---------------------------------------------------------------------------
# Path validation tests
@@ -276,3 +278,117 @@ class TestSanitizerBypassVectors:
def test_lisp_legitimate_not_overblocked(self):
assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))"
# ── T0.8-A — what the guards actually guard ────────────────────────────────
class TestNonLocalPathAnchors:
r"""Windows accepts several spellings of the same file that compare unequal.
``_BLOCKED_PATH_PATTERNS`` rejects the backslash forms, but ``Path.resolve()``
normalises the *forward-slash* spellings into them afterwards — so
``//?/C:/Windows/win.ini`` slipped past the pattern check and then compared
unequal to ``C:/Windows`` in the system-directory loop. Proven: it returned a
handle that ``os.path.samefile``'d ``C:\Windows\win.ini``.
"""
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_extended_length_prefix_is_rejected(self):
with pytest.raises(ToolError, match="not a plain local drive path"):
validate_path("//?/C:/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_unc_forward_slash_form_is_rejected(self):
with pytest.raises(ToolError, match="not a plain local drive path"):
validate_path("//localhost/C$/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_device_form_still_reaches_the_system_directory_check(self):
"""``//./C:/...`` normalises to a plain drive path — it must be caught
by the system-directory rule, not by the anchor rule, or the anchor
check would be masking rather than adding a guard."""
with pytest.raises(ToolError, match="system directory"):
validate_path("//./C:/Windows/win.ini")
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
def test_an_ordinary_path_is_unaffected(self, tmp_path):
assert validate_path(str(tmp_path / "part.dxf"))
class TestLispStringLiteralsAreData:
"""A quoted literal is drawing text, not code.
Reading inside quotes made the server refuse its own domain vocabulary: an
engineering CAD server that rejects ``"Max load 500 kg"`` because the word
"load" appears in a note is broken for the job it exists to do.
"""
@pytest.mark.parametrize(
"expression",
[
'(setq desc "Max load 500 kg")',
'(entmakex (list (cons 0 "TEXT") (cons 1 "LOAD CASE 1")))',
'(setq layer "C:CENTER")',
'(setq note "Design load = 12 kN")',
],
)
def test_engineering_text_is_not_mistaken_for_code(self, expression):
assert sanitize_lisp(expression) == expression
@pytest.mark.parametrize(
"expression",
[
'(load "malicious.lsp")',
'(vl-catch-all-apply (quote command) (list "ERASE"))',
'(startapp "cmd")',
"(eval (read user-input))",
],
)
def test_the_dangerous_vectors_still_block(self, expression):
with pytest.raises(ToolError):
sanitize_lisp(expression)
class TestCommandVerbScoping:
"""Match the command, not the characters.
``\b(ERASE|...)\b`` over the whole string refused ``MTEXT 0,0 10,10 Do not
erase this part`` — annotation text, not a command — while missing spellings
AutoCAD accepts. SendCommand takes a macro: segments split on newline or
``;``, and only a segment's FIRST token is a verb.
"""
def test_the_repos_own_safe_fixture_still_passes(self):
assert sanitize_command("_ZOOM E") == "_ZOOM E"
def test_zoom_extents_as_a_macro_passes(self):
"""``E`` here is the Extents *option*, not the ERASE alias. Blocking the
canonical zoom-extents macro is why single-letter aliases stay out of
the verb set."""
assert sanitize_command("_ZOOM\nE\n") == "_ZOOM\nE\n"
def test_annotation_text_mentioning_a_verb_passes(self):
text = "MTEXT 0,0 10,10 Do not erase this part"
assert sanitize_command(text) == text
def test_a_linetype_load_macro_passes(self):
macro = "_-LINETYPE _LOAD CENTER acad.lin\n\n"
assert sanitize_command(macro) == macro
@pytest.mark.parametrize(
"command",
["ERASE ALL", "_ZOOM E\n_ERASE", "SAVE", "VBASTMT", "ARX", ".SHELL", "'SCRIPT x.scr"],
)
def test_dangerous_verbs_are_blocked_in_every_decoration(self, command):
with pytest.raises(ToolError):
sanitize_command(command)
def test_the_refusal_names_the_offending_verb(self):
"""Without this a user whose annotation was refused cannot tell why."""
with pytest.raises(ToolError, match="ERASE"):
sanitize_command("_ZOOM E\n_ERASE")
def test_command_verbs_strips_every_decorator(self):
assert command_verbs("_-PURGE") == ["PURGE"]
assert command_verbs("'_ZOOM E;.REGEN") == ["ZOOM", "REGEN"]
+546
View File
@@ -0,0 +1,546 @@
"""F1 — selection filters: window, polygon and property selection.
Three tools that answer "which entities do you mean?" without the caller having
to memorise handles. Each carries a failure mode that is silent unless it is
pinned here:
* **window vs crossing** is the whole point of the family. AutoCAD's *window*
returns only entities that are *fully contained*; *crossing* also returns the
ones that merely straddle the edge. Under the hood these are
``select.bbox_inside`` and ``select.bbox_overlap`` — one character apart in a
call, and getting them backwards produces a plausible-looking handle list that
is quietly the wrong set. Every mode test below builds one entity inside, one
straddling and one outside, and pins exactly which of the three comes back.
* **a zero-area box** (``x1 == x2`` or ``y1 == y2``) can never contain anything.
``select.Window((0, 0), (0, 100))`` returns ``[]`` — measured — so a tool that
passed it through would answer "nothing is there" to a question that was
really "you asked for nothing". It is refused instead.
* **a polygon is not its bounding box.** A triangle and a square with identical
bounding boxes must not select the same entities; the triangle test uses
exactly that pair so a lazy bbox implementation cannot pass.
* **mirrored geometry** is where stored coordinates and real position diverge. A
mirrored CIRCLE keeps ``center = (30, 20)`` on disk and carries
``extrusion = (0, 0, -1)``; it is *drawn* at (-30, 20), which is what
``entity_get`` reports. Selection must agree with the reported position, or
the caller would have to window an entity where nothing is visible.
* **``selection_filter`` takes named parameters, not a query string,** on
purpose. ``layout.query("LINE[nosuchattr=='x']")`` returns ``[]`` silently —
measured — so a typo is indistinguishable from "no matches". Named parameters
make the typo a ``TypeError`` at the tool boundary. The response carries
``filtered_by`` for the same reason: a caller must be able to tell "I filtered
by layer and found none" from "I never filtered by layer at all".
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
# The selection box every window/polygon test uses.
BOX = (0.0, 0.0, 100.0, 100.0)
SQUARE_POLY = [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]]
# Same bounding box as SQUARE_POLY, half the area.
TRIANGLE_POLY = [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0]]
async def _three_positions(backend):
"""One entity fully inside BOX, one straddling its edge, one fully outside.
Plus a second contained entity on another layer, so a layer filter has
something to remove without emptying the result.
"""
contained = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY")
contained_other_layer = await backend.entity_create_circle(20, 80, 5, layer="HIDDEN")
straddling = await backend.entity_create_line(90, 50, 110, 50, layer="GEOMETRY")
outside = await backend.entity_create_circle(200, 200, 5, layer="HIDDEN")
return {
"contained": contained.handle,
"contained_other_layer": contained_other_layer.handle,
"straddling": straddling.handle,
"outside": outside.handle,
}
async def _mixed_properties(backend):
"""Three entities that differ in type, layer, colour, linetype and area."""
circle = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY", color=1)
square = await backend.entity_create_polyline(
[[0, 0], [20, 0], [20, 20], [0, 20]], closed=True, layer="GEOMETRY", color=3
)
line = await backend.entity_create_line(0, 0, 50, 0, layer="HIDDEN", color=0, linetype="HIDDEN")
return {"circle": circle.handle, "square": square.handle, "line": line.handle}
# ── selection_window: the mode contract ─────────────────────────────────────
async def test_a_window_returns_only_fully_contained_entities(backend):
"""The straddling line is the one that must NOT come back."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="window")
assert result["ok"] is True
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
async def test_a_crossing_also_returns_entities_that_straddle_the_edge(backend):
"""Crossing = window + the straddlers. Never fewer, never the outside one."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
handles["straddling"],
}
async def test_neither_mode_reaches_an_entity_outside_the_box(backend):
handles = await _three_positions(backend)
window = await backend.selection_window(*BOX, mode="window")
crossing = await backend.selection_window(*BOX, mode="crossing")
assert handles["outside"] not in window["handles"]
assert handles["outside"] not in crossing["handles"]
async def test_crossing_is_a_strict_superset_of_window(backend):
"""If these two ever come back equal for this drawing, one of them is wrong."""
await _three_positions(backend)
window = set((await backend.selection_window(*BOX, mode="window"))["handles"])
crossing = set((await backend.selection_window(*BOX, mode="crossing"))["handles"])
assert window < crossing
assert len(crossing) - len(window) == 1
async def test_window_is_the_default_mode(backend):
"""The safer of the two: contained-only never over-selects."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX)
assert result["mode"] == "window"
assert handles["straddling"] not in result["handles"]
async def test_the_reported_count_matches_the_handles(backend):
await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing")
assert result["count"] == len(result["handles"]) == 3
assert result["mode"] == "crossing"
async def test_the_corners_may_be_given_in_any_order(backend):
"""(x2, y2) top-right is a convention, not a requirement of the geometry."""
await _three_positions(backend)
normal = await backend.selection_window(0, 0, 100, 100, mode="crossing")
flipped = await backend.selection_window(100, 100, 0, 0, mode="crossing")
assert set(flipped["handles"]) == set(normal["handles"])
assert flipped["count"] == normal["count"] == 3
# ── selection_window: refusals ──────────────────────────────────────────────
@pytest.mark.parametrize("bad_mode", ["inside", "fence", "WINDOWS", "", "all"])
async def test_an_unknown_window_mode_is_refused(backend, bad_mode):
"""Falling back to a default mode would answer a question nobody asked."""
await _three_positions(backend)
result = await backend.selection_window(*BOX, mode=bad_mode)
assert result["ok"] is False
assert "mode" in result["error"]
@pytest.mark.parametrize(
"box",
[
(10.0, 0.0, 10.0, 100.0), # zero width
(0.0, 40.0, 100.0, 40.0), # zero height
(5.0, 5.0, 5.0, 5.0), # a point
],
)
async def test_a_zero_area_window_is_refused(backend, box):
"""It can never contain anything, so [] would read as "nothing is there"."""
await _three_positions(backend)
result = await backend.selection_window(*box, mode="crossing")
assert result["ok"] is False
assert "area" in result["error"] or "zero" in result["error"]
# ── selection_window: filters compose with the mode ─────────────────────────
async def test_an_entity_type_filter_narrows_the_crossing_set(backend):
"""The type filter must cut the crossing set, not replace it."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing", entity_type="CIRCLE")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
assert handles["straddling"] not in result["handles"]
async def test_an_entity_type_filter_can_keep_the_straddler_alone(backend):
"""Same box, same mode, LINE instead of CIRCLE: the complement comes back."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing", entity_type="LINE")
assert set(result["handles"]) == {handles["straddling"]}
async def test_a_layer_filter_composes_with_crossing(backend):
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="crossing", layer="GEOMETRY")
assert set(result["handles"]) == {handles["contained"], handles["straddling"]}
async def test_a_layer_filter_composes_with_window(backend):
"""Same layer, window mode: the straddler drops out and only one is left."""
handles = await _three_positions(backend)
result = await backend.selection_window(*BOX, mode="window", layer="GEOMETRY")
assert set(result["handles"]) == {handles["contained"]}
async def test_type_and_layer_filters_compose_with_each_other(backend):
handles = await _three_positions(backend)
result = await backend.selection_window(
*BOX, mode="crossing", entity_type="CIRCLE", layer="HIDDEN"
)
assert set(result["handles"]) == {handles["contained_other_layer"]}
async def test_an_empty_selection_is_ok_with_zero_handles(backend):
"""Nothing there is an answer, not an error — unlike a zero-area box."""
await _three_positions(backend)
result = await backend.selection_window(500, 500, 600, 600, mode="crossing")
assert result["ok"] is True
assert result["handles"] == []
assert result["count"] == 0
# ── selection_window: mirrored geometry ─────────────────────────────────────
async def _mirrored_circle(backend):
"""A CIRCLE stored at (30, 20) but drawn at (-30, 20).
Mirroring about the Y axis gives extrusion (0, 0, -1) and leaves the stored
centre untouched — measured. entity_get resolves it to WCS (-30, 20).
"""
source = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
return (await backend.entity_mirror(source.handle, 0, 0, 0, 10, delete_original=True)).handle
async def test_a_mirrored_entity_is_selected_where_entity_get_reports_it(backend):
handle = await _mirrored_circle(backend)
reported = await backend.entity_get(handle)
assert reported.properties["center"] == [-30.0, 20.0]
result = await backend.selection_window(-40, 10, -20, 30, mode="window")
assert set(result["handles"]) == {handle}
async def test_a_mirrored_entity_is_not_selected_at_its_stored_coordinates(backend):
"""Selecting at (30, 20) would select an entity nobody can see there."""
handle = await _mirrored_circle(backend)
result = await backend.selection_window(20, 10, 40, 30, mode="crossing")
assert handle not in result["handles"]
assert result["count"] == 0
async def test_the_mirrored_coordinates_really_do_diverge_on_disk(backend, tmp_path):
"""Proof the divergence is stored, not an artefact of the in-memory copy."""
import ezdxf
handle = await _mirrored_circle(backend)
path = tmp_path / "mirrored.dxf"
await backend.drawing_save_as(str(path))
reloaded = ezdxf.readfile(str(path)).entitydb.get(handle)
assert tuple(reloaded.dxf.extrusion) == (0.0, 0.0, -1.0)
assert tuple(reloaded.dxf.center)[:2] == (30.0, 20.0)
selected = await backend.selection_window(-40, 10, -20, 30, mode="window")
assert selected["handles"] == [handle]
# ── selection_polygon ───────────────────────────────────────────────────────
async def test_a_polygon_window_returns_only_fully_contained_entities(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="window")
assert result["ok"] is True
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
async def test_a_polygon_crossing_also_returns_the_straddler(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="crossing")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
handles["straddling"],
}
assert result["mode"] == "crossing"
async def test_a_polygon_is_not_its_bounding_box(backend):
"""The triangle has exactly SQUARE_POLY's bounding box and half its area.
Measured: both contained circles sit inside the square but poke out of the
triangle's hypotenuse, so a bbox-only implementation returns two here and
fails.
"""
await _three_positions(backend)
square = await backend.selection_polygon(SQUARE_POLY, mode="window")
triangle = await backend.selection_polygon(TRIANGLE_POLY, mode="window")
assert square["count"] == 2
assert triangle["handles"] == []
async def test_the_triangle_still_reaches_them_in_crossing_mode(backend):
"""They poke out of it — which is exactly what crossing is for."""
handles = await _three_positions(backend)
result = await backend.selection_polygon(TRIANGLE_POLY, mode="crossing")
assert set(result["handles"]) == {
handles["contained"],
handles["contained_other_layer"],
}
@pytest.mark.parametrize(
"bad",
[[], [[0.0, 0.0]], [[0.0, 0.0], [100.0, 100.0]]],
)
async def test_a_polygon_needs_at_least_three_points(backend, bad):
"""Two points enclose no area; a zero-area lasso catches nothing."""
await _three_positions(backend)
result = await backend.selection_polygon(bad, mode="crossing")
assert result["ok"] is False
assert "3" in result["error"] or "three" in result["error"].lower()
@pytest.mark.parametrize("bad_mode", ["inside", "fence", ""])
async def test_an_unknown_polygon_mode_is_refused(backend, bad_mode):
await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode=bad_mode)
assert result["ok"] is False
assert "mode" in result["error"]
async def test_a_polygon_filter_composes_with_the_mode(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="crossing", entity_type="LINE")
assert set(result["handles"]) == {handles["straddling"]}
async def test_a_polygon_layer_filter_composes_with_the_mode(backend):
handles = await _three_positions(backend)
result = await backend.selection_polygon(SQUARE_POLY, mode="window", layer="HIDDEN")
assert set(result["handles"]) == {handles["contained_other_layer"]}
# ── selection_filter: what was actually filtered ────────────────────────────
async def test_no_filters_returns_everything_and_says_it_filtered_by_nothing(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter()
assert result["ok"] is True
assert set(result["handles"]) == set(handles.values())
assert result["count"] == 3
assert list(result["filtered_by"]) == []
async def test_filtering_by_layer_names_the_layer_filter_in_the_response(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(layer="GEOMETRY")
assert set(result["handles"]) == {handles["circle"], handles["square"]}
assert set(result["filtered_by"]) == {"layer"}
async def test_an_empty_result_is_distinguishable_from_an_unfiltered_one(backend):
"""The honesty case: "found none" and "never looked" must not both be [].
DIM exists and holds nothing, so the handle list is empty either way; only
``filtered_by`` tells the caller a layer filter ran at all.
"""
await _mixed_properties(backend)
await backend.layer_create("DIM")
filtered = await backend.selection_filter(layer="DIM")
unfiltered = await backend.selection_filter()
assert filtered["ok"] is True
assert filtered["count"] == 0
assert set(filtered["filtered_by"]) == {"layer"}
assert list(unfiltered["filtered_by"]) == []
assert unfiltered["count"] == 3
async def test_a_typo_in_a_filter_name_is_a_type_error_not_an_empty_list(backend):
"""Why this tool takes named parameters instead of a query string.
``query("LINE[nosuchattr=='x']")`` answers [] — measured — and the caller
reads it as "no matches". A wrong keyword must fail loudly instead.
"""
await _mixed_properties(backend)
with pytest.raises(TypeError):
await backend.selection_filter(layar="GEOMETRY")
async def test_every_applied_filter_is_reported_not_just_the_first(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(layer="GEOMETRY", entity_type="CIRCLE")
assert set(result["handles"]) == {handles["circle"]}
assert set(result["filtered_by"]) == {"layer", "entity_type"}
# ── selection_filter: the individual filters ────────────────────────────────
async def test_filtering_by_entity_type(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(entity_type="LWPOLYLINE")
assert set(result["handles"]) == {handles["square"]}
assert set(result["filtered_by"]) == {"entity_type"}
async def test_filtering_by_colour(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(color=3)
assert set(result["handles"]) == {handles["square"]}
assert set(result["filtered_by"]) == {"color"}
async def test_colour_zero_is_byblock_not_no_filter(backend):
"""ACI 0 is ByBlock, a real colour. ``if color:`` would drop this filter.
The failure it guards is the worst kind: the caller asks for ByBlock and
gets the entire drawing back, reported as a successful colour filter.
"""
handles = await _mixed_properties(backend)
result = await backend.selection_filter(color=0)
assert set(result["handles"]) == {handles["line"]}
assert set(result["filtered_by"]) == {"color"}
async def test_filtering_by_linetype(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(linetype="HIDDEN")
assert set(result["handles"]) == {handles["line"]}
assert set(result["filtered_by"]) == {"linetype"}
async def test_min_area_keeps_only_the_shapes_that_are_big_enough(backend):
"""Measured: the square encloses 400, the circle 314.16."""
handles = await _mixed_properties(backend)
result = await backend.selection_filter(min_area=350.0)
assert set(result["handles"]) == {handles["square"]}
assert set(result["filtered_by"]) == {"min_area"}
async def test_min_area_admits_both_shapes_when_the_threshold_is_low(backend):
handles = await _mixed_properties(backend)
result = await backend.selection_filter(min_area=100.0)
assert set(result["handles"]) == {handles["circle"], handles["square"]}
async def test_min_area_excludes_entities_that_enclose_no_area(backend):
"""A LINE has no area at all — it is not an entity of area zero to be kept
by a zero threshold, it is an entity the question does not apply to."""
handles = await _mixed_properties(backend)
result = await backend.selection_filter(min_area=0.0)
assert handles["line"] not in result["handles"]
assert set(result["filtered_by"]) == {"min_area"}
async def test_a_negative_min_area_is_refused(backend):
"""No area is negative, so the threshold is either a typo or a unit error."""
await _mixed_properties(backend)
result = await backend.selection_filter(min_area=-1.0)
assert result["ok"] is False
assert "min_area" in result["error"]
async def test_the_filter_count_matches_the_handles(backend):
await _mixed_properties(backend)
result = await backend.selection_filter(layer="GEOMETRY")
assert result["count"] == len(result["handles"]) == 2
+8 -1
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
import pytest
import config
pytestmark = pytest.mark.asyncio
@@ -96,8 +98,13 @@ async def test_zoom_window_reports_not_applied(backend):
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
async def test_undo_empty_stack_returns_error(backend):
async def test_undo_empty_stack_returns_error(backend, monkeypatch):
"""With history on but nothing done yet, undo declines rather than raising."""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
await backend.drawing_new()
res = await backend.drawing_undo()
assert res["ok"] is False
assert "undo" in res["error"].lower()

Some files were not shown because too many files have changed in this diff Show More