16 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 e27071ab48 fix(com): except pywintypes.com_error raised NameError without pywin32
Release / test (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Python evaluates an `except` clause on the way out of every failed call,
not only the ones it is meant to catch. `pywintypes` is bound only when
`sys.platform == "win32"` and the import succeeded, so on any host
without pywin32 -- all of Linux, and Windows installed without the
`[com]` extra -- an ordinary exception leaving `ComBackend._run` hit that
clause and became `NameError: name 'pywintypes' is not defined`, burying
the real error.

Every Linux CI leg failed on exactly this, in
test_ordinary_errors_are_unchanged. The timeout tests survived only
because they matched an earlier `except`; nothing else in the suite sends
a non-timeout exception through `_run`.

Fixed with a module-level `_COM_ERROR` tuple: `(pywintypes.com_error,)`
where the import worked, `()` where it did not. An empty tuple catches
nothing, which is correct when no com_error type exists to be raised -- a
placeholder Exception subclass would be worse than the bug, silently
swallowing unrelated errors.

Reproduced on Windows before fixing by deleting the module attribute,
which resolves the name the same way the absent import does; that is now
a test, alongside one pinning `_COM_ERROR` as a tuple. Verified through
the locked environment CI uses: 1244 passed, 1 xfailed.

README test-count snapshot 1243 -> 1245.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:22:49 +03:00
Umut CANandClaude Opus 5 4e0f8a90fd fix(ci): uv.lock recorded 1.4.0, so every locked lane died before testing
The v1.5.0 lock was generated before the version bump, so its own
[[package]] entry said autocad-mcp-pro 1.4.0 while pyproject said 1.5.0.
No dependency differed -- the transitive graph is byte-identical -- but
`uv sync --locked` will not re-resolve, so lint, test-linux 3.11,
test-linux 3.12 and test-windows all failed at install before running a
single test.

`uv lock` fixes it in one line. The gate that should have caught it did
not exist: test_release_consistency chains pyproject -> version.py ->
CHANGELOG -> README -> server.json, and the lockfile was the one release
artifact carrying a version string nothing checked. Added
test_uv_lock_records_the_pyproject_version, verified RED against the
broken lock and green against the fixed one.

Reproduced the CI lanes locally into a throwaway environment before
pushing this time: `uv sync --locked --only-group dev`, then
`--locked --extra pdf`, then ruff and pytest under `uv run --no-sync`.
1242 passed, 1 xfailed.

README test-count snapshot 1242 -> 1243 for the new gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:03:29 +03:00
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests.

The release exists for two measured reasons. A 149-tool catalog costs a client
~40,300 tokens before it has asked for anything, and a drafter searching the way
drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock
index, because those words were simply not in the data.

Discovery and batching
- DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool,
  ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on
  top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised.
- cad_batch runs a step list in one round trip; fields= projects result-heavy
  responses. The `core` profile is gone -- discovery replaced it.

Honesty debt (Tier 0)
- OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and
  rotated geometry reported coordinates that were quietly wrong. backends/ocs.py
  translates at the backend boundary; every tool coordinate is WCS on both
  engines, and a tilted plane refuses rather than answering with a number that
  looks fine.
- drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never
  repaired. analysis_measure_area shoelaced points the caller typed in.
- ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a
  server whose document lock is a single asyncio.Lock.

Architecture
- The 1854-line backend ABC is 19 contract modules; @capability(key, reason=)
  makes optional methods declarative and a gate fails on undeclared holes.
- 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch.
- Wave A: selection filters, boundary tracing, hatch depth, annotation objects,
  measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was
  cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann
  substitute loses 28.2% of the area on a square with one semicircular edge.

Verified against a live AutoCAD 2026, which found four COM defects no amount of
reading would have caught -- AcadPViewport has no ViewCenter, and the line that
set it had been swallowed by a bare except since v1.4.

Benchmark v3 (15 tasks) and what it caught
- v2 scored this server 10/10, which carried no information: every task in it
  exercised something the server was built around. Five tasks were added because
  they can fail, and three did while being written -- a hatch could not be
  measured though the refusal listed HATCH as measurable, the boundary tools and
  the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a
  circle stored as two bulged vertices measured 0.0.
- Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and
  were never asked these five questions; the chart shows those rows `not run`.
- Correctness A/B: 21/24 -> 24/24, zero regressions.

Performance, stated plainly
The published v1.4 perf report was recorded on CPython 3.14 and the new one on
3.11.15, so the apparent 2-6x speedup is the interpreter, not this release.
Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22%
slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores
v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is
documented, and winning it back is on the 1.6 roadmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 09:45:20 +03:00
Umutcan EdizsalanandCursor d5a0ff5109 style: ruff-format the leaderboard renderer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:17:08 +03:00
Umutcan EdizsalanandCursor bae0750d4c docs(bench): dark leaderboard styling for all benchmark charts
Leaderboard restyled to the LLM-benchmark look: dark background, rank-numbered labels, per-rank color palette, 'NN / 100 - grade' annotations. The live-run, task-matrix and performance charts share the same dark theme for a consistent README identity. Charts regenerated deterministically from their data files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:14:04 +03:00
Umutcan EdizsalanandCursor af4813e7b6 docs(bench): nine-server public AutoCAD MCP leaderboard as the README hero visual
source_review.json extended with three projects reviewed 2026-07-23 (LokmenoWer/best-cad-mcp B/74, daobataotie/CAD-MCP B/35, zh19980811/Easy-MCP-AutoCad C/30) and our entry updated to the released v1.4.0 (95: CI matrix, PyPI packaging, A/B gate, paper space, ISO 286, opt-in solids). Chart regenerated via render_chart; README benchmark section now opens with the leaderboard + 9-row table, source-review lane merged into it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:08:01 +03:00
Umutcan EdizsalanandCursor 47a90dbc54 docs(bench): add the v1.3.0 -> v1.4.0 correctness A/B gate to the README benchmark lanes
Ran benchmarks/compare_versions.py against the v1.3.0 tag: 21/21 on both versions, 0 fixed / 0 regressed - the v1.4.0 feature+infra work introduced no correctness regressions. Main README gains lane 4 (version A/B, with the historical v1.0.0 -> v1.1.0 contrast); benchmarks/README records the release-gate result and drops the stale 'performance coming' stub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:00:58 +03:00
Umutcan EdizsalanandCursor d3e5fe4542 chore: drop stale uv.lock from the repo and gitignore it (regenerate deliberately if adopting uv)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:53:40 +03:00
Umutcan EdizsalanandCursor 21cbc62df5 feat(bench): performance lane + task-matrix chart; docs: README rebuilt around four evidence lanes
- benchmarks/perf_suite.py: fixed headless workloads (2k creates, 10k build/export/reopen, 10k region query, premium quality pass) timed through the same backend methods the tools call; machine fingerprint recorded; self-measurement only
- benchmarks/render_perf_chart.py -> docs/assets/autocad-mcp-perf.svg (log-scale wall time)
- benchmarks/render_matrix_chart.py -> docs/assets/autocad-mcp-taskmatrix.svg (tasks x servers status heatmap from published reports)
- render_live_chart now tolerates other lanes sharing results/published/
- README: PyPI-first install, four-lane benchmark section with Python-generated visuals, v1.4 feature table, updated client/config/roadmap
- published perf report: benchmarks/results/published/perf-ezdxf.json

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:53:04 +03:00
Umutcan EdizsalanandCursor 67f2c3b575 ci(release): publish to PyPI with the PYPI_API_TOKEN secret instead of OIDC trusted publishing
Release / test (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:37:17 +03:00
Umutcan EdizsalanandCursor d15068efc5 fix(tests): inject win32gui mock with raising=False so the hwnd tests run on the Linux CI leg
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:22:49 +03:00
Umutcan EdizsalanandCursor e04c814cb8 docs(release): v1.4.0 - changelog, README live-benchmark section, CLAUDE.md sync, consistency gates
- CHANGELOG 1.4.0 section (infra + features), Unreleased now lists the 1.5 openers
- README: v1.4 snapshot (131 tools / 6 resources / 5 prompts / 474 tests), CI + PyPI badges, live-run benchmark table + chart, mcp-name registry marker
- CLAUDE.md: sections 15 (layouts) + 16 (solids), tool profiles, ISO 286 fits; counts synced to the live surface
- tests/test_release_consistency.py: locks pyproject/version.py/CHANGELOG/README/server.json versions, README counts vs registrations, section-header counts, Dockerfile COPY vs wheel includes, mcp-name marker

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:16:32 +03:00
Umutcan EdizsalanandCursor 3cbe30df54 feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids
- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false
- layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it
- ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal
- tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved
- solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary
- capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d
- 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:45 +03:00
Umutcan EdizsalanandCursor 6d7c39e012 feat(bench): live competitor lane - black-box MCP stdio adapters, pinned checkouts, published results
- adapters/mcp_stdio.py: drives competitors over MCP stdio like a real host; payload-level error detection, fastmcp output-wrapper normalization, cold-start warmup, artifact-based verification (harness re-opens every exported DXF with ezdxf)
- competitors_env.py: reproducible pinned-SHA checkout + isolated venv per competitor
- adapters for puran-water/autocad-mcp (95476a3) and beiming183-cloud/AutoCAD-MCP (11f7c47) mapped to their own documented tool contracts; unmapped tasks report 'unsupported' with reasons
- run_competitors.py: registry-driven --server from competitors.yaml
- published reports (pro 100.0 / beiming 50.0 / puran 45.0) + render_live_chart.py -> docs/assets/autocad-mcp-livebench.svg
- benchmark.yml: weekly + manual dispatch matrix

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:27 +03:00
Umutcan EdizsalanandCursor 2f56a73093 feat(release): CI matrix, PyPI trusted-publishing pipeline, registry manifest, Docker fix
- ci.yml: ruff lint/format, Linux 3.11/3.12 tests + coverage, Windows mocked-COM leg, package build + clean-venv wheel smoke, Docker smoke, server.json schema validation
- release.yml: tag -> test gate -> tag/version check -> build -> PyPI trusted publishing (OIDC) -> GitHub Release
- pyproject: SPDX license, authors, keywords, classifiers, project URLs, autocad-mcp-pro console alias; version 1.4.0
- Dockerfile: ship engineering/ + version.py (image previously missed both -> engineering tools failed in containers); healthcheck imports engineering
- server.json: MCP registry manifest (io.github.u-c4n/autocad-mcp, pypi package)
- docs/RELEASE-DISTRIBUTION.md: one-time owner actions (PyPI trusted publisher, mcp-publisher login) + directory submissions runbook

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:07 +03:00
Umutcan EdizsalanandCursor 9ec6c4a989 style: apply ruff format across the repository
ruff format was never run repo-wide; CI now enforces 'ruff format --check', so normalize every module once. No behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:14:51 +03:00
157 changed files with 45155 additions and 3578 deletions
+58
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
@@ -17,3 +46,32 @@ LOG_LEVEL=INFO
# Enable dangerous AutoCAD commands (ERASE ALL, QUIT, etc.)
# Set to true only if you trust all MCP clients
DANGEROUS_COMMANDS_ENABLED=false
# 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
+53
View File
@@ -0,0 +1,53 @@
name: Benchmark
# Headless competitor benchmark (v2 fixed-task matrix). Manual or weekly:
# clones the pinned competitor SHAs, runs the black-box MCP stdio lane and
# uploads the machine-readable reports as artifacts. Results committed to
# benchmarks/results/ are produced from these runs.
on:
workflow_dispatch:
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
strategy:
fail-fast: false
matrix:
server:
- autocad-mcp-pro
- puran-water-autocad-mcp
- beiming183-autocad-mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.12"
- 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: |
uv run --no-sync python -m benchmarks.run_competitors \
--server "${{ matrix.server }}" \
--backend ezdxf \
--artifact-dir "benchmarks/results/ci/${{ matrix.server }}" \
> "report-${{ matrix.server }}.json"
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: benchmark-${{ matrix.server }}
path: |
report-${{ matrix.server }}.json
benchmarks/results/ci/${{ matrix.server }}
+140
View File
@@ -0,0 +1,140 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
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
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- 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
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: ${{ matrix.python-version }}
- 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: 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
with:
name: coverage-report
path: coverage.xml
test-windows:
# Windows leg exercises the mocked-COM suite on the platform the COM
# backend actually targets (pywin32 installs; no live AutoCAD needed).
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.11"
- 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: uv run --no-sync pytest -v
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build sdist and wheel
run: |
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
/tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/autocad-mcp --help
/tmp/smoke/bin/python -c "import server, config, security, version, backends, engineering; print('wheel ok', version.__version__)"
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t autocad-mcp:ci .
- name: Smoke test image
run: docker run --rm autocad-mcp:ci python -c "import server, engineering.gear, version; print('docker ok', version.__version__)"
registry-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate server.json against MCP registry schema
if: ${{ hashFiles('server.json') != '' }}
run: |
pip install check-jsonschema
check-jsonschema --schemafile https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json server.json
+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
+97
View File
@@ -0,0 +1,97 @@
name: Release
# Tag-driven release: run the test gate, build, publish to PyPI, then cut the
# GitHub Release with the built artifacts attached.
#
# Auth: the PYPI_API_TOKEN repository secret (an API token from
# https://pypi.org/manage/account/token/). Once the project exists on PyPI,
# prefer replacing it with a project-scoped token — or switch back to trusted
# publishing (OIDC) by restoring `environment: pypi` + `id-token: write` and
# registering the publisher on PyPI.
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"
- 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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Verify tag matches package version
run: |
pip install tomli-w 2>/dev/null || true
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "Tag $GITHUB_REF_NAME does not match pyproject version $PKG_VERSION" >&2
exit 1
fi
- name: Build distributions
run: |
pip install build twine
python -m build
twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish-pypi:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
github-release:
needs: publish-pypi
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--generate-notes
+11
View File
@@ -43,6 +43,17 @@ desktop.ini
out/
benchmarks/results/*
!benchmarks/results/.gitkeep
!benchmarks/results/published
!benchmarks/results/published/**
benchmarks/.competitors/
*.dxf
*.dwg
*.bak
# Local editor/agent state
.cursor/
# 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.
+823 -2
View File
@@ -5,9 +5,830 @@ 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
_Next: capability-group schema routing, paper-space/layout production depth, and ISO 286 fit-table lookup._
### 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
pass) measured through the same backend methods the MCP tools call;
machine fingerprint recorded, self-measurement only.
- **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/`.
### 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
Release infrastructure + the roadmap features shipped together. **474 tests,
Ruff lint- and format-clean. Tool count: 122 → 131.**
### Added
- **CI restored and extended** (`.github/workflows/ci.yml`): ruff lint/format,
Linux test matrix (3.11/3.12) with coverage, a **Windows leg** running the
mocked-COM suite on the platform the COM backend targets, a package job
(build + twine check + clean-venv wheel smoke incl. `autocad-mcp --help`),
a Docker build/smoke job, and MCP `server.json` schema validation.
- **Release pipeline** (`.github/workflows/release.yml`): tag `v*` → test gate
→ tag/version match check → build → **PyPI trusted publishing** (OIDC) →
GitHub Release with artifacts.
- **MCP registry manifest** `server.json` (schema 2025-12-11, name
`io.github.u-c4n/autocad-mcp`, PyPI package) plus the `mcp-name` ownership
marker in the README and an `autocad-mcp-pro` console alias so
`uvx autocad-mcp-pro` works directly.
- **Live competitor benchmark lane**: a generic black-box MCP-stdio driver
(`benchmarks/adapters/mcp_stdio.py`), reproducible pinned checkouts
(`benchmarks/competitors_env.py`), and adapters for
**puran-water/autocad-mcp** and **beiming183-cloud/AutoCAD-MCP** that run
the same 10-task matrix over stdio with harness-side DXF verification.
Published results (`benchmarks/results/published/`): autocad-mcp-pro
**100.0**, beiming183 **50.0**, puran-water **45.0**; rendered to
`docs/assets/autocad-mcp-livebench.svg`. Weekly/dispatch CI workflow.
- **Tool profiles** (`TOOL_PROFILE=lean|core|full`): capability-aware
discovery applied in the server lifespan — `lean` is a curated ~46-tool
drafting core, `core` hides raw escape hatches and long-tail tools, `full`
(default) exposes everything. Reported by `system_about`.
- **Paper space / layouts**: `layout_list`, `layout_create`,
`layout_set_current`, `viewport_create` (scaled model viewports) on both
backends, and `drawing_export_pdf(layout=...)` for plotting a layout.
Headless limitation is explicit: viewport model-content projection is
COM-only (`viewport_render` capability).
- **ISO 286 limits and fits** (`engineering/fits.py`): authored table data
(IT4–IT11, sizes 1–500 mm; shafts d/e/f/g/h/js/k/m/n/p, holes D/E/F/G/H/JS)
with `fit_lookup("H7", 20.0)`; `dimension_linear/radius/diameter` gained a
`fit` parameter that resolves deviations from the measured nominal and
appends the fit code to the dimension text. Out-of-scope letters raise a
clear error naming the supported set.
- **Opt-in native 3D solids** (`ENABLE_3D=true`, COM only): `solid_box`,
`solid_cylinder`, `solid_extrude`, `solid_revolve`, `solid_boolean`.
Hidden from discovery and rejected while disabled; ezdxf reports the honest
`solid_3d` capability boundary (ACIS cannot be generated headlessly).
- **Release-consistency test suite** (`tests/test_release_consistency.py`):
pyproject ↔ version.py ↔ CHANGELOG ↔ README snapshot ↔ server.json versions,
README tool/resource/prompt counts vs live registrations, per-section header
counts vs decorators, Dockerfile COPY vs wheel `only-include`, and the
README `mcp-name` marker.
### Changed
- **pyproject metadata completed** for PyPI: SPDX license + license file,
authors, keywords, classifiers, project URLs.
- **`build_dim_override`**: `deviation`/`limit` modes now pass DIMTM signed
(`-lower`) instead of `abs()` — double-positive ISO 286 fits (e.g. p6
+0.035/+0.022) render correctly; the legacy "positive magnitude = minus
deviation" contract is unchanged.
- **Capability maps**: `paper_space` is now native on both backends;
new `viewport_render` feature (COM native / ezdxf unsupported);
`solid_3d` reflects the ENABLE_3D gate on COM.
- README rebuilt around the live-run benchmark lane; CI/PyPI badges added;
`ruff format` applied repo-wide and enforced in CI.
### Fixed
- **Dockerfile shipped a broken image**: `engineering/` and `version.py` were
never copied, so engineering tools failed in containers. Both are now
copied (plus README/LICENSE for metadata) and the healthcheck imports the
engineering package.
- `server.py` section-header tool counts and the CLAUDE.md inventory were
stale (six sections drifted); both now match the live surface and are
locked by tests. Removed the reference to the non-existent
`.claude/skills/` directory.
## [1.3.0] — 2026-07-17
+35 -16
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 ~110 tools, 5 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.
@@ -69,29 +71,41 @@ The `mcp` FastMCP instance is configured with:
- **Middleware stack**: `ErrorHandlingMiddleware` → `AuditMiddleware` (custom timing/audit log) → `TimingMiddleware` → `LoggingMiddleware`
- **`_backend(ctx)`** helper: retrieves the backend from lifespan context, raises `ToolError` if not ready
Tools are organized into 12 sections:
1. Drawing Management (11 tools): `drawing_*`
2. Entity Creation (13 tools): `entity_create_*`
Tools are organized into sections (counts are indicative — `system_about` is authoritative):
1. Drawing Management (12 tools): `drawing_*` (includes `drawing_redo`)
2. Entity Creation (14 tools): `entity_create_*` (includes `entity_create_table`, `leader_create_mleader`)
3. Dimensions (5 tools): `dimension_*`
4. Entity Modification (12 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
5. Entity Query (3 tools): `entity_get`, `entity_list`, `entity_delete_many`
6. Layer Management (12 tools + 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
4. Entity Modification (16 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, corner ops (`entity_trim/extend/fillet/chamfer`), plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
5. Entity Query (4 tools): `entity_get`, `entity_list`, `entity_delete_many`, `selection_get`
6. Layer Management (14 tools incl. 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
7. Block Operations (7 tools): `block_*`
8. Analysis & Query (8 tools): `analysis_*`
9. View & Screenshot (5 tools — includes `view_zoom_and_screenshot`): `view_*`
8. Analysis & Query (8 tools): `analysis_*` — plus Batch (2), Templates (2), Validation (1)
9. View & Screenshot (4 tools — includes `view_zoom_and_screenshot`): `view_*`
10. Transactions (3 tools): `transaction_begin/commit/rollback`
11. System (7 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables)
12. Engineering / Deterministic CAD (7 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
13. Premium meta-tools: `drawing_plan`, `drawing_critique`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
11. System (8 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about/capabilities`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables)
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 (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|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.
**ISO 286 fits:** the same dimension tools take `fit="H7"` (mutually exclusive with `tol_*`) — deviations are resolved from authored ISO 286 tables (`engineering/fits.py`; shafts d/e/f/g/h/js/k/m/n/p, holes D/E/F/G/H/JS, sizes 1–500 mm) against the measured nominal and the fit code is appended to the dimension text.
**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(...)`
@@ -101,6 +115,12 @@ Tools are organized into 12 sections:
- **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.
@@ -110,8 +130,7 @@ Tools are organized into 12 sections:
### Premium Drawing Rules
These rules are non-negotiable for production engineering output. The full
discipline + workflow templates live in `.claude/skills/autocad-mcp-premium/`.
These rules are non-negotiable for production engineering output.
1. **Plan before draw**: Call `drawing_plan(intent, scale, sheet)` *before* any
`entity_create_*`. The returned PlanSpec is held by the backend and replayed
+5 -3
View File
@@ -4,9 +4,11 @@ WORKDIR /app
RUN useradd --create-home --shell /bin/bash mcp
COPY pyproject.toml ./
COPY server.py config.py security.py ./
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 .
@@ -15,6 +17,6 @@ USER mcp
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import server; print('ok')" || exit 1
CMD python -c "import server, engineering.gear; print('ok')" || exit 1
CMD ["python", "server.py"]
+379 -219
View File
@@ -1,128 +1,295 @@
# AutoCAD MCP Pro
Production-grade AutoCAD automation for AI agents—live through COM on Windows,
or headless through ezdxf on any platform.
Production-grade AutoCAD automation for AI agents — live through COM on
Windows, or headless through ezdxf on any platform.
[![CI](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/autocad-mcp-pro?color=1f6feb)](https://pypi.org/project/autocad-mcp-pro/)
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-1f6feb?logo=python&logoColor=white)](pyproject.toml)
[![FastMCP 3](https://img.shields.io/badge/MCP-FastMCP%203-8957e5)](https://github.com/jlowin/fastmcp)
[![Stars](https://img.shields.io/github/stars/U-C4N/Autocad-MCP?style=flat&color=e3b341)](https://github.com/U-C4N/Autocad-MCP/stargazers)
One typed MCP contract controls two execution engines. Build and edit drawings,
query exact geometry, apply engineering standards, refine quality issues inside
transactions, and deliver hashed artifacts with validation evidence.
query exact geometry, apply engineering standards (ISO 128/129/286/1101),
refine quality issues inside transactions, and deliver hashed artifacts with
validation evidence.
> **v1.3 release snapshot:** 122 tools · 6 resources · 5 prompt templates ·
> 415 collected tests. Runtime discovery through `system_about` is authoritative.
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates ·
> 1245 collected tests. Runtime discovery through `system_about` is authoritative.
## Start in 60 seconds
## Install in 10 seconds
```bash
git clone https://github.com/U-C4N/Autocad-MCP.git
cd Autocad-MCP
python -m venv .venv
pip install autocad-mcp-pro # or: uvx autocad-mcp-pro
autocad-mcp # stdio MCP server, backend auto-selected
```
Activate the environment and start the stdio server:
```powershell
# Windows
.venv\Scripts\Activate.ps1
pip install -e .
python server.py
```
Headless (no AutoCAD required, any OS) or live AutoCAD on Windows:
```bash
# macOS / Linux
source .venv/bin/activate
pip install -e .
python server.py
AUTOCAD_MCP_BACKEND=ezdxf autocad-mcp # portable DXF engine
AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (pip install "autocad-mcp-pro[com]")
```
That starts the headless-capable server with automatic backend selection. For
live AutoCAD control on Windows:
Extras: `[com]` live AutoCAD (pywin32 + Pillow), `[pdf]` PDF/screenshot
rendering (matplotlib), `[full]` everything. Working from a checkout is the
same commands with `pip install -e ".[full]"` and `python server.py`.
```powershell
pip install -e ".[com]"
$env:AUTOCAD_MCP_BACKEND = "com"
python server.py
```
## Benchmarks
No AutoCAD installation is needed for the ezdxf backend:
Every number below is generated by a script in [`benchmarks/`](benchmarks/)
and rendered to SVG with Python (matplotlib). No hand-edited charts, no
adjectives.
```powershell
$env:AUTOCAD_MCP_BACKEND = "ezdxf"
python server.py
```
### The leaderboard — nine public AutoCAD MCP servers, one rubric
## Evidence, not adjectives
![Public AutoCAD MCP capability leaderboard](docs/assets/autocad-mcp-benchmark.svg)
![Source-reviewed AutoCAD MCP capability benchmark](docs/assets/autocad-mcp-benchmark.svg)
| # | Project | Score | Evidence |
|--:|---|---:|:---:|
| 1 | **[U-C4N/Autocad-MCP](https://github.com/U-C4N/Autocad-MCP)** (this repo, v1.4.0) | **95** | A |
| 2 | [varavista/autocad-mcp](https://github.com/varavista/autocad-mcp) | 84 | A |
| 3 | [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | 83 | A |
| 4 | [LokmenoWer/best-cad-mcp](https://github.com/LokmenoWer/best-cad-mcp) | 74 | B |
| 5 | [AnCode666/multiCAD-mcp](https://github.com/AnCode666/multiCAD-mcp) | 73 | A |
| 6 | [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | 70 | A |
| 7 | [NCO-1986/AutoCAD_mcp](https://github.com/NCO-1986/AutoCAD_mcp) | 68 | B |
| 8 | [daobataotie/CAD-MCP](https://github.com/daobataotie/CAD-MCP) | 35 | B |
| 9 | [zh19980811/Easy-MCP-AutoCad](https://github.com/zh19980811/Easy-MCP-AutoCad) | 30 | C |
The graphic is generated with Matplotlib from
[`benchmarks/source_review.json`](benchmarks/source_review.json). A fixed,
public 100-point rubric covers functional CAD breadth, correctness and delivery,
backend reach, engineering production, tests, and security.
Fixed public 100-point rubric — CAD breadth (25), correctness and delivery
(20), backends/platforms (15), engineering production (15), tests and
maintenance (15), security and operations (10) — applied to the public source
and documentation of each project
([`benchmarks/source_review.json`](benchmarks/source_review.json), dated).
Stars and raw tool counts score nothing. Evidence grade **A** = source plus
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.
| Project | Score | Evidence |
|---|---:|:---:|
| **[U-C4N/Autocad-MCP](https://github.com/U-C4N/Autocad-MCP)** | **93** | A |
| [varavista/autocad-mcp](https://github.com/varavista/autocad-mcp) | 84 | A |
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | 83 | A |
| [AnCode666/multiCAD-mcp](https://github.com/AnCode666/multiCAD-mcp) | 73 | A |
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | 70 | A |
| [NCO-1986/AutoCAD_mcp](https://github.com/NCO-1986/AutoCAD_mcp) | 68 | B |
This is a dated source review—not a claim that every project completed a shared
live AutoCAD run. Evidence grade **A** means source plus executable local
evidence was reviewed; **B** means source and tool surface were reviewed but the
live AutoCAD path was not fully executed.
Reproduce the chart and run the ten-task reference adapter:
**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
pip install -e ".[pdf]"
python -m benchmarks.render_chart
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json
```
The v1.3 ezdxf reference run records **10/10 tasks (100.0)**. It is a
self-check, not a competitor ranking.
### 1 · Live-run scores — same harness, pinned competitors
Cross-project runtime scores will be published only after equivalent adapters
execute the same task contract. The complete rubric, caveats, version A/B suite,
and reproduction commands live in [`benchmarks/`](benchmarks/README.md).
![Live benchmark](docs/assets/autocad-mcp-livebench.svg)
## Why this architecture holds up
| 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% |
### One contract, two engines
**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.
The COM backend controls a live AutoCAD session on Windows through a
single-threaded STA executor. The ezdxf backend creates, edits, renders, and
validates DXF drawings without AutoCAD. Both return the same typed entities,
layers, blocks, drawing metadata, and capability format.
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.
### Geometry agents do not have to guess
```bash
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
```
Tools such as `point_from_snap`, `point_intersection`, and `point_tangent`
resolve exact construction coordinates from drawing entities. Handle-preserving
edit tools and smart selection keep agents attached to real geometry instead of
reconstructing state from prose.
### 2 · Task matrix — where the score comes from
### Quality is a loop
![Task matrix](docs/assets/autocad-mcp-taskmatrix.svg)
`drawing_preflight` checks whether an engineering request is ready to draw.
`drawing_critique` reports standards and geometry issues. `drawing_refine`
repairs the supported subset inside isolated transactions and rolls back rounds
that reduce quality. `drawing_finalize` combines structural validation,
standards critique, a scalar score, and invalidity ratio.
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, blank = never run against
that server.
### Delivery carries evidence
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.
`drawing_deliver` creates the requested artifacts, computes SHA-256 hashes,
reopens the canonical DXF, compares entity/layer/type counts and bounds, and
writes `manifest.json` plus `validation.json`. A successful tool call is not
treated as proof that the drawing survived export.
| 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
```
### 3 · Headless performance — scale evidence
![Performance](docs/assets/autocad-mcp-perf.svg)
| Workload | Wall time | Throughput / result |
|---|---:|---|
| 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
extra stdio serialization cost that in-process runs do not, so no cross-server
timing claims are made. Numbers move with hardware; the machine fingerprint is
recorded in the report.
```bash
python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.json
python -m benchmarks.render_perf_chart
```
### 4 · Version A/B — every release re-proves 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.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.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**.
For contrast, the same suite caught the v1.0.0 → v1.1.0 jump:
| Version | Checks passing | Pass rate |
|---|---:|---:|
| v1.0.0 (first public release) | 8 / 21 | 38.1 % |
| v1.1.0 | 21 / 21 | 100 % |
```bash
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.5
| Area | Tools |
|---|---|
| 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) |
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 titleblock |
| **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 |
| 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
lists from this README.
## Architecture
```mermaid
flowchart LR
A[MCP host / AI agent] --> B[FastMCP server]
B --> C[Error - audit - timing - logging middleware]
C --> D[Typed tool contract + tool profiles]
D --> E[COM backend]
D --> F[ezdxf backend]
E --> G[Live AutoCAD]
F --> H[Headless DXF]
E --> I[Engineering quality + delivery]
F --> I
```
- `server.py` — FastMCP surface, lifespan, middleware, resources, prompts.
- `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
snapshot transactions.
- `engineering/` — standards-aware planning, generators, critique, scoring,
fits, refinement, validation, delivery.
- `security.py` — path validation and command/AutoLISP sanitization.
## Backend capabilities
@@ -131,210 +298,200 @@ treated as proof that the drawing survived export.
| Live AutoCAD document control | ✓ | — |
| Headless DXF creation and editing | — | ✓ |
| Cross-platform execution | — | ✓ |
| Deterministic entity/query contract | ✓ | ✓ |
| Transactions and rollback | ✓ | ✓ |
| Paper-space layouts + viewports | ✓ | ✓ |
| 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 |
| Raw AutoCAD commands / AutoLISP | Opt-in | — |
Use `system_capabilities` at runtime instead of assuming backend support. The
machine-readable response distinguishes `native`, `composite`, `emulated`, and
`unsupported` behavior where the engines differ.
`system_capabilities` returns this machine-readably (`native`, `composite`,
`rendered`, `snapshot`, `shared`, `unsupported`) — use it at runtime instead of
assuming.
## A production drawing workflow
The server exposes low-level CAD primitives, but the intended engineering path
uses deterministic orchestration and validation:
```text
drawing_preflight → drawing_plan → drawing_apply_iso_layers
→ deterministic create/edit tools → dimension_auto
→ deterministic create/edit tools → dimension_auto (+ fit="H7" callouts)
→ drawing_critique → drawing_refine
→ drawing_finalize → drawing_deliver
→ layout_create + viewport_create → drawing_finalize → drawing_deliver
```
Representative capability groups include:
| Area | Examples |
|---|---|
| Drawing lifecycle | create, open, save, export, audit, purge, undo/redo |
| Geometry | lines, arcs, polylines, hatches, splines, trim/extend/fillet/chamfer |
| Annotation | dimensions, ISO 129 tolerances, TABLE, MLEADER, text editing |
| Engineering | gears, keyed bores, title blocks, ISO 1101 GD&T and datums |
| Query | entity/layer/block inspection, selection, bounds, distance and area |
| Quality | preflight, critique, score, transactional refinement and finalization |
| Delivery | PDF/PNG/DXF output, hashes, manifests and reopen-parity checks |
The tool surface evolves. Ask `system_about` for the live grouped inventory
instead of building integrations around a copied list from this README.
## Installation profiles
| Profile | Command | Use case |
|---|---|---|
| Core | `pip install -e .` | Headless ezdxf workflows |
| COM | `pip install -e ".[com]"` | Live AutoCAD on Windows |
| PDF | `pip install -e ".[pdf]"` | PDF export and headless screenshots |
| Full | `pip install -e ".[full]"` | Both backends and all render features |
Requirements:
- Python 3.11+
- [FastMCP](https://github.com/jlowin/fastmcp) 3.0+
- [ezdxf](https://github.com/mozman/ezdxf) 1.3+
- AutoCAD, `pywin32`, and Pillow only for live COM control
- Matplotlib only for PDF/headless rendering and benchmark visualization
## Connect an MCP client
### Claude Desktop
Add a server entry to `claude_desktop_config.json` and use the absolute path to
your checkout:
### Claude Desktop / Cursor / any stdio host
```json
{
"mcpServers": {
"autocad": {
"command": "python",
"args": ["C:\\path\\to\\Autocad-MCP\\server.py"],
"command": "autocad-mcp",
"env": {
"AUTOCAD_MCP_BACKEND": "auto",
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD"
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD",
"TOOL_PROFILE": "full"
}
}
}
}
```
Cursor, Cline, Continue, Goose, and other stdio-capable MCP hosts use the same
`command` plus `args` shape. The model vendor is not part of the server
contract.
After `pip install autocad-mcp-pro` the `autocad-mcp` command is on PATH; from
a checkout use `"command": "python"`, `"args": ["path/to/server.py"]`. The
model vendor is not part of the server contract.
### Local HTTP
```bash
fastmcp run server.py:mcp --transport http --port 8000
autocad-mcp --transport http --port 8000
```
Loopback is the default. A non-loopback bind is refused unless remote HTTP is
explicitly enabled and bearer-token authentication is configured.
Loopback only by default; a non-loopback bind is refused unless remote HTTP is
explicitly enabled **and** a bearer token is configured.
## Configuration
Set variables in your shell, process manager, or MCP client configuration.
[`.env.example`](.env.example) is a reference; the server reads process
environment variables directly.
| Variable | Default | Purpose |
|---|---|---|
| `AUTOCAD_MCP_BACKEND` | `auto` | Select `auto`, `com`, or `ezdxf` |
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
| `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 the check |
| `MAX_LIST_LIMIT` | `5000` | Bound list and selection response sizes |
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout in seconds |
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP and report unsafe mode |
| `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); `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 |
Backend selection:
| Value | Behavior |
|---|---|
| `auto` | Try COM on Windows, then fall back to ezdxf |
| `com` | Require a reachable live AutoCAD instance |
| `ezdxf` | Force portable, headless DXF operation |
## Architecture
```mermaid
flowchart LR
A[MCP host / AI agent] --> B[FastMCP server]
B --> C[Error · audit · timing · logging middleware]
C --> D[Typed tool contract]
D --> E[COM backend]
D --> F[ezdxf backend]
E --> G[Live AutoCAD]
F --> H[Headless DXF]
E --> I[Engineering quality + delivery]
F --> I
```
- `server.py` owns the FastMCP surface, lifespan, middleware, resources, and
prompt templates.
- `backends/base.py` defines the shared contract and capability model.
- `backends/com_backend.py` serializes COM calls through one STA thread with
per-call timeouts.
- `backends/ezdxf_backend.py` wraps synchronous DXF work with
`asyncio.to_thread` and snapshot-based transactions.
- `engineering/` contains standards-aware planning, drawing, critique, scoring,
refinement, validation, and delivery.
- `security.py` centralizes path validation and command/LISP sanitization.
`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
The server treats tool input as untrusted:
Tool input is treated as untrusted:
- Every file path passes through traversal and allowed-root validation.
- Dangerous AutoCAD commands and AutoLISP channels are blocked by default.
- Remote HTTP requires explicit opt-in and a bearer token.
- DXF size, query-result size, undo history, and COM call duration are bounded.
- Audit middleware records tool timing and failures.
- `system_status` and `system_about` expose `unsafe_mode` when dangerous
- 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;
- `system_status` / `system_about` expose `unsafe_mode` when dangerous
operations are enabled.
`DANGEROUS_COMMANDS_ENABLED=true` is a trust decision, not an installation
shortcut. Keep it disabled for agents or clients you do not fully control.
Please avoid filing public issues for vulnerabilities. Contact the maintainer
through [GitHub](https://github.com/U-C4N) first.
Please report vulnerabilities privately via [GitHub](https://github.com/U-C4N)
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
uv sync --locked --all-extras
uv run pytest
uv run ruff check . && uv run ruff format --check .
```
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 pytest pytest-asyncio pytest-cov ruff build
python -m pytest
python -m ruff check .
python -m ruff format --check .
python -m build
pip install --group dev build
python -m pytest && python -m build
```
Focused benchmark checks:
```bash
python -m pytest tests/test_benchmark_chart.py tests/test_benchmark_v2.py -q
python -m benchmarks.render_chart
```
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:
```text
server.py FastMCP surface and orchestration
config.py Environment-driven settings
security.py Path, command, and AutoLISP guards
version.py Canonical package version
backends/ COM and ezdxf implementations
engineering/ Standards, critique, refinement, delivery
benchmarks/ Reproducible data, runners, and reports
tests/ Headless, mocked-COM, contract, and security tests
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
## Roadmap (1.6)
The next priorities are capability-aware tool discovery, paper-space/layout
depth, ISO 286 fits, Windows CI coverage, opt-in COM 3D solids, and real
cross-project runtime adapters. Features are published when their contracts and
limitations are testable—not when they make a longer checklist.
**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 for reproducible competitor adapters,
Pull requests are welcome — especially reproducible competitor adapters,
mocked/live COM coverage, engineering standards, and backend parity. For a
large change, open an issue first so the tool contract and evidence plan can be
agreed before implementation.
large change, open an issue first so the tool contract and evidence plan can
be agreed before implementation.
## Author
@@ -346,3 +503,6 @@ Built from production drawing work, then made model-agnostic through MCP.
## License
[MIT](LICENSE)
<!-- MCP registry ownership marker; must equal the "name" in server.json. -->
mcp-name: io.github.u-c4n/autocad-mcp
+1
View File
@@ -1,4 +1,5 @@
"""AutoCAD MCP Pro – backend package."""
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
from .com_backend import ComBackend
from .ezdxf_backend import EzdxfBackend
+75 -1470
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))
+1769 -141
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.
"""
...
+3147 -183
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)]
+187 -23
View File
@@ -1,15 +1,17 @@
# Benchmarks
![Public AutoCAD MCP capability leaderboard](../docs/assets/autocad-mcp-benchmark.svg)
## Two evidence layers
This repository publishes two deliberately separate forms of evidence:
1. **Source-reviewed capability benchmark.** Six named AutoCAD MCP projects are
1. **Source-reviewed capability benchmark.** Nine named AutoCAD MCP projects are
scored with a fixed 100-point rubric covering CAD breadth, correctness and
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.
@@ -24,28 +26,162 @@ 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 v1.3 ezdxf self-check is **10/10 (100.0)**. It is not a
competitor ranking. Cross-project scores become comparable only when another
adapter runs this exact task matrix; 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 | 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:
- `benchmarks/competitors_env.py` clones the pinned SHA into
`benchmarks/.competitors/<id>/` (gitignored), builds an isolated venv, and
installs the competitor from its own `pyproject.toml`.
- `benchmarks/adapters/mcp_stdio.py` drives the competitor exactly like an MCP
host (fastmcp `Client` over stdio). No competitor code is imported.
- Task playbooks call each server's **own documented tool contract**, read from
the pinned source (consolidated `operation` + payload tools; beiming's
`doc_id`/`expected_revision` discipline is honored via
`transaction(operation="context")`).
- **Verification never trusts the competitor's response**: every geometry claim
must survive `save`/`save_as_dxf` and re-opening the DXF with ezdxf inside
the harness.
- Tasks with no documented equivalent are reported `unsupported` with the
reason string; they stay in the fixed-matrix denominator at score zero.
- This lane is headless (ezdxf backends only). File-IPC / live-AutoCAD lanes
(including COM-only servers such as best-cad-mcp or daobataotie/CAD-MCP)
require a local AutoCAD session and are out of CI scope by design.
Published machine-readable reports (artifact paths sanitized to filenames)
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 --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
methods the MCP tools call (server-side overhead included): 2,000 individual
line creates, a 10,000-entity build → DXF export → reopen roundtrip, a region
query over 10,000 entities, and a full premium quality pass (ISO layers,
geometry, dimensions, complete critique). The report records the machine
fingerprint; numbers move with hardware, workload definitions do not.
**Self-measurement only** — competitor servers would pay an extra stdio
serialization cost that in-process runs do not, so no cross-server timing
claims are made.
```bash
python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.json
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
@@ -63,6 +199,37 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json results.json
```
### Result — v1.5.0 vs v1.4.0 (release gate)
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** | 21 / 21 | 100 % | 0 | **0** |
### Result — v1.1.0 vs v1.0.0 (`origin/main`, commit 15fa2bc)
21 checks, ezdxf backend, CPython 3.14, one subprocess per check.
@@ -109,8 +276,5 @@ not a cherry-picked list of failures; the baseline genuinely does basic CAD work
- COM-backend behaviour is not covered here (no live AutoCAD); see the mocked-COM
suites in `tests/`.
## Performance (throughput) — coming
Scale / throughput numbers (1k–100k entities, render timings, model-by-model
tool-call success rate) are tracked in the main README and will be published here
with a hardware footprint.
Model-by-model tool-call success rates remain future work; scale/throughput
numbers live in the headless performance lane above.
+233 -6
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"
@@ -90,10 +120,11 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
table = await self.backend.entity_create_table(
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
)
leader = await self.backend.leader_create_mleader(
[[0, 0], [10, 10]], "NOTE", layer="DIM"
)
representations = [table.properties.get("representation"), leader.properties.get("representation")]
leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM")
representations = [
table.properties.get("representation"),
leader.properties.get("representation"),
]
return all(representations), {"representations": representations}, []
async def _task_transactions(self):
@@ -137,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")
+301
View File
@@ -0,0 +1,301 @@
"""Black-box adapter for beiming183-cloud/AutoCAD-MCP (headless ezdxf lane).
Contract verified against the pinned checkout's ``server.py`` (v3.10 line):
- document identity comes from ``transaction(operation="context")`` and every
mutation must carry ``doc_id`` + ``expected_revision`` — top-level for the
``entity``/``annotation`` tools, inside ``data`` for ``drawing`` operations;
- ``entity create_line`` takes top-level ``x1..y2``; circle/dimension payloads
sit under ``data`` (same field spellings as the upstream puran-water base);
- ``transaction begin/commit/rollback`` are compatibility operations that the
headless backend may reject with ``E_UNSUPPORTED*`` — that outcome is
reported as ``unsupported`` rather than ``fail``.
Verification is artifact-based: geometry claims are checked by exporting a DXF
(``drawing save_as_dxf``) and re-reading it with ezdxf in the harness.
"""
from __future__ import annotations
import math
from typing import Any
from benchmarks.adapters.base import TaskResult
from benchmarks.adapters.mcp_stdio import (
McpStdioAdapter,
ToolCallFailed,
extract_handle,
)
from benchmarks.competitors_env import CompetitorSpec
PINNED_SHA = "11f7c47e5038796a20451b38b23032e625b5aa26"
def _find_key(payload: Any, *keys: str) -> Any:
if isinstance(payload, dict):
for key in keys:
if key in payload and payload[key] not in (None, ""):
return payload[key]
for value in payload.values():
found = _find_key(value, *keys)
if found is not None:
return found
elif isinstance(payload, (list, tuple)):
for item in payload:
found = _find_key(item, *keys)
if found is not None:
return found
return None
class BeimingAutocadAdapter(McpStdioAdapter):
name = "beiming183-autocad-mcp"
spec = CompetitorSpec(
competitor_id="beiming183-autocad-mcp",
repo_url="https://github.com/beiming183-cloud/AutoCAD-MCP.git",
pinned_sha=PINNED_SHA,
entry_candidates=(
("-m", "autocad_mcp"),
("server.py",),
("src/server.py",),
),
pip_installs=(("-e", "."),),
launch_env={"AUTOCAD_MCP_BACKEND": "ezdxf"},
pythonpath="src",
)
def extra_launch_env(self) -> dict[str, str]:
# The server redirects writes outside its managed output root; point
# the root at the benchmark artifact directory so exports land where
# the harness verifies them.
return {
"AUTOCAD_MCP_OUTPUT_ROOT": str(self.artifact_dir),
"AUTOCAD_MCP_ALLOW_EXTERNAL_OUTPUTS": "true",
}
def warmup_calls(self):
# Absorb lazy backend initialization once during setup.
return (
("system", {"operation": "status"}),
("drawing", {"operation": "create", "data": {}}),
)
async def _ctx(self) -> dict[str, Any]:
"""Read doc_id + revision from ``transaction(operation="context")``."""
try:
payload = await self.call("transaction", {"operation": "context"})
except ToolCallFailed:
return {}
doc_id = _find_key(payload, "doc_id", "document_id")
revision = _find_key(payload, "revision", "expected_revision", "document_revision")
context: dict[str, Any] = {}
if doc_id is not None:
context["doc_id"] = doc_id
if revision is not None:
context["expected_revision"] = revision
return context
async def _new_drawing(self) -> None:
await self.call("drawing", {"operation": "create", "data": {}})
async def _save_dxf(self, filename: str) -> str:
target = self.save_target(filename)
payload: dict[str, Any] = {"path": str(target)}
try:
await self.call("drawing", {"operation": "save_as_dxf", "data": payload})
except ToolCallFailed:
context = await self._ctx()
await self.call("drawing", {"operation": "save_as_dxf", "data": {**payload, **context}})
return str(target)
async def _entity(self, operation: str, **fields: Any) -> Any:
context = await self._ctx()
return await self.call("entity", {"operation": operation, **context, **fields})
async def _create_line(self, x1: float, y1: float, x2: float, y2: float):
return await self._entity("create_line", x1=x1, y1=y1, x2=x2, y2=y2)
# -- tasks ----------------------------------------------------------------
async def _task_core_geometry(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 3, 4)
await self._entity("create_circle", data={"cx": 10, "cy": 10, "radius": 2})
artifact = await self._save_dxf("beiming_core_geometry.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_core_geometry.dxf"))
lines = doc.modelspace().query("LINE")
circles = doc.modelspace().query("CIRCLE")
length = None
if lines:
start, end = lines[0].dxf.start, lines[0].dxf.end
length = math.dist((start.x, start.y), (end.x, end.y))
passed = (
len(lines) == 1
and len(circles) == 1
and length is not None
and abs(length - 5.0) < 1e-6
and abs(circles[0].dxf.radius - 2.0) < 1e-6
)
return TaskResult(
"core_geometry",
"pass" if passed else "fail",
100.0 if passed else 0.0,
metrics={"line_length": length, "lines": len(lines), "circles": len(circles)},
artifacts=[artifact],
)
async def _task_modify_query(self) -> TaskResult:
await self._new_drawing()
created = await self._create_line(0, 0, 10, 0)
handle = extract_handle(created)
if not handle:
return TaskResult(
"modify_query",
"fail",
0.0,
"could not extract created-entity handle from the create_line response",
)
await self._entity("move", entity_id=handle, data={"dx": 5, "dy": 2})
artifact = await self._save_dxf("beiming_modify_query.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_modify_query.dxf"))
lines = doc.modelspace().query("LINE")
moved = (
bool(lines)
and abs(lines[0].dxf.start.x - 5.0) < 1e-6
and (abs(lines[0].dxf.start.y - 2.0) < 1e-6)
)
return TaskResult(
"modify_query",
"pass" if moved else "fail",
100.0 if moved else 0.0,
artifacts=[artifact],
)
async def _task_layers_linetypes(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
await self.call("drawing", {"operation": "setup_mechanical", "data": {**context}})
artifact = await self._save_dxf("beiming_layers.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_layers.dxf"))
layers = {layer.dxf.name.upper() for layer in doc.layers}
linetypes = {lt.dxf.name.upper() for lt in doc.linetypes}
expected_layers = {"OUTLINE", "CENTER", "DIM"}
got_layers = expected_layers.issubset(layers)
got_linetype = "CENTER" in linetypes
if got_layers and got_linetype:
return TaskResult("layers_linetypes", "pass", 100.0, artifacts=[artifact])
if got_layers:
return TaskResult(
"layers_linetypes",
"partial",
50.0,
"GB/T layers created but CENTER linetype missing from the table",
artifacts=[artifact],
)
return TaskResult(
"layers_linetypes",
"fail",
0.0,
f"layers={sorted(layers)}",
artifacts=[artifact],
)
async def _task_dimensions(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
await self.call(
"annotation",
{
"operation": "create_dimension_linear",
**context,
"data": {"x1": 0, "y1": 0, "x2": 50, "y2": 0, "dim_x": 25, "dim_y": 10},
},
)
artifact = await self._save_dxf("beiming_dimensions.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_dimensions.dxf"))
dims = [e for e in doc.modelspace() if e.dxftype() == "DIMENSION"]
return TaskResult(
"dimensions",
"pass" if dims else "fail",
100.0 if dims else 0.0,
metrics={"dimension_count": len(dims)},
artifacts=[artifact],
)
async def _task_transactions(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
try:
begin = await self.call("transaction", {"operation": "begin", **context})
except ToolCallFailed as exc:
if "E_UNSUPPORTED" in str(exc):
return TaskResult(
"transactions",
"unsupported",
0.0,
"transaction begin is rejected as unsupported on the headless backend",
)
raise
transaction_id = _find_key(begin, "transaction_id", "txn_id")
await self._entity("create_circle", data={"cx": 0, "cy": 0, "radius": 1})
context = await self._ctx()
rollback_args: dict[str, Any] = {"operation": "rollback", **context}
if transaction_id is not None:
rollback_args["transaction_id"] = transaction_id
await self.call("transaction", rollback_args)
artifact = await self._save_dxf("beiming_transactions.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_transactions.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"transactions",
"pass" if count == 0 else "fail",
100.0 if count == 0 else 0.0,
metrics={"entity_count_after_rollback": count},
artifacts=[artifact],
)
async def _task_dxf_roundtrip(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 10, 10)
artifact = await self._save_dxf("beiming_roundtrip.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_roundtrip.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"dxf_roundtrip",
"pass" if count == 1 else "fail",
100.0 if count == 1 else 0.0,
metrics={"entity_count": count},
artifacts=[artifact],
)
# -- documented coverage gaps ----------------------------------------------
async def _task_table_mleader(self) -> TaskResult:
return TaskResult(
"table_mleader",
"unsupported",
0.0,
"no TABLE or MLEADER operation in the annotation tool (v3.10)",
)
async def _task_preflight(self) -> TaskResult:
return TaskResult("preflight", "unsupported", 0.0, "no requirements-preflight equivalent")
async def _task_quality_refiner(self) -> TaskResult:
return TaskResult(
"quality_refiner",
"unsupported",
0.0,
"audits detect issues but there is no automated repair loop",
)
async def _task_auditable_delivery(self) -> TaskResult:
return TaskResult(
"auditable_delivery",
"unsupported",
0.0,
"drawing.deliver is documented as File-IPC only (not headless)",
)
__all__ = ["BeimingAutocadAdapter"]
+304
View File
@@ -0,0 +1,304 @@
"""Generic MCP-stdio benchmark adapter.
Drives a *competitor* MCP server as a black box over the standard stdio
transport (fastmcp ``Client``), exactly the way an agent host would. No
competitor code is imported; verification never trusts the competitor's
response payload — every task that claims to have produced geometry must
save a DXF which the harness re-opens with ezdxf and inspects itself.
Subclasses provide the launch runtime (via ``benchmarks.competitors_env``)
and per-task playbooks named ``_task_<task_id>``; anything unmapped is
reported as ``unsupported`` (coverage-aware scoring already accounts for it).
"""
from __future__ import annotations
import dataclasses
import json
import re
from pathlib import Path
from typing import Any
import ezdxf
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.competitors_env import CompetitorRuntime, CompetitorSpec, ensure_competitor
from benchmarks.tasks_v2 import TaskSpec
CALL_TIMEOUT_SECONDS = 15.0
WARMUP_TIMEOUT_SECONDS = 90.0
class ToolCallFailed(RuntimeError):
"""A competitor tool call returned an error result."""
def read_dxf(path: Path):
return ezdxf.readfile(str(path))
def modelspace_types(doc) -> list[str]:
return [entity.dxftype() for entity in doc.modelspace()]
def find_dxf(artifact_dir: Path, preferred: Path | None = None) -> Path | None:
"""Locate the DXF a competitor produced.
Servers with managed output roots may relocate writes, so fall back to
the newest ``*.dxf`` anywhere under the artifact directory.
"""
if preferred is not None and preferred.is_file():
return preferred
candidates = sorted(
artifact_dir.rglob("*.dxf"), key=lambda item: item.stat().st_mtime, reverse=True
)
return candidates[0] if candidates else None
class McpStdioAdapter(BenchmarkAdapter):
"""Black-box adapter base for competitor MCP servers."""
name = "mcp-stdio"
spec: CompetitorSpec # set by subclasses
def __init__(self, *, backend: str = "ezdxf"):
if backend != "ezdxf":
raise ValueError(
f"{type(self).__name__} only benchmarks the headless ezdxf lane; "
f"got backend={backend!r}. COM lanes run locally, not in CI."
)
self.backend_name = backend
self.artifact_dir = Path()
self.runtime: CompetitorRuntime | None = None
self._client: Client | None = None
self.tool_names: set[str] = set()
# -- lifecycle ----------------------------------------------------------
def extra_launch_env(self) -> dict[str, str]:
"""Environment computed at setup time (``self.artifact_dir`` is set)."""
return {}
async def setup(self, artifact_dir: Path) -> None:
self.artifact_dir = Path(artifact_dir)
self.runtime = ensure_competitor(self.spec)
env = {**self.runtime.env, **self.extra_launch_env()}
env.setdefault("PYTHONIOENCODING", "utf-8")
transport = StdioTransport(
command=str(self.runtime.python_exe),
args=list(self.runtime.entry_args),
env=env,
cwd=str(self.runtime.repo_dir),
)
self._client = Client(transport)
await self._client.__aenter__()
self.tool_names = {tool.name for tool in await self._client.list_tools()}
await self._warmup()
def warmup_calls(self) -> tuple[tuple[str, dict[str, Any]], ...]:
"""Calls issued once after connect to absorb the competitor's lazy
backend initialization (which can exceed the per-call timeout)."""
return ()
async def _warmup(self) -> None:
for tool, arguments in self.warmup_calls():
if tool not in self.tool_names:
continue
try:
await self._client.call_tool(
tool, arguments, timeout=WARMUP_TIMEOUT_SECONDS, raise_on_error=False
)
except Exception: # cold-start absorption is best effort
continue
async def cleanup(self) -> None:
if self._client is not None:
await self._client.__aexit__(None, None, None)
self._client = None
def metadata(self) -> dict:
return {
"backend": self.backend_name,
"kind": "mcp-stdio-blackbox",
"repo": self.spec.repo_url,
"pinned_sha": self.spec.pinned_sha,
"tools_discovered": sorted(self.tool_names),
}
# -- call helpers ---------------------------------------------------------
async def call(self, tool: str, arguments: dict[str, Any]) -> Any:
"""Call one tool; raise ``ToolCallFailed`` on an error result.
Competitor servers in this family report failures *inside* the payload
(``CommandResult(ok=False, error=...)`` serialized as text) rather than
via the MCP ``isError`` flag, so the payload is inspected as well.
"""
if tool not in self.tool_names:
raise ToolCallFailed(f"tool {tool!r} is not exposed by {self.name}")
result = await self._client.call_tool(
tool, arguments, timeout=CALL_TIMEOUT_SECONDS, raise_on_error=False
)
if getattr(result, "is_error", False):
raise ToolCallFailed(f"{tool}({arguments}) -> {_result_text(result)}")
data = getattr(result, "data", None)
payload = data if data is not None else _result_text(result)
payload = _normalize_payload(payload)
error = _payload_error(payload)
if error:
raise ToolCallFailed(f"{tool}({arguments}) -> {error}")
return payload
async def call_op(
self,
tool: str,
operation: str,
data: dict[str, Any] | None = None,
*,
shapes: tuple[dict[str, Any], ...] | None = None,
) -> Any:
"""Call a consolidated ``{tool}(operation=..., ...)`` style tool.
Competitors disagree on whether payload fields live under ``data`` or
at the top level, and on field spellings. ``shapes`` supplies explicit
argument dicts to try in order; otherwise both common layouts of
``data`` are attempted. The first non-error result wins.
"""
attempts: list[dict[str, Any]] = []
if shapes:
attempts.extend({"operation": operation, **shape} for shape in shapes)
else:
payload = data or {}
attempts.append({"operation": operation, "data": payload})
if payload:
attempts.append({"operation": operation, **payload})
last_error: Exception | None = None
for arguments in attempts:
try:
return await self.call(tool, arguments)
except ToolCallFailed as exc: # try the next argument shape
last_error = exc
raise ToolCallFailed(str(last_error))
# -- task dispatch --------------------------------------------------------
async def run_task(self, task: TaskSpec) -> TaskResult:
handler = getattr(self, f"_task_{task.task_id}", None)
if handler is None:
return TaskResult(
task.task_id,
"unsupported",
0.0,
f"{self.name} has no documented equivalent for this task",
)
try:
return await handler()
except ToolCallFailed as exc:
return TaskResult(task.task_id, "fail", 0.0, str(exc)[:500])
# -- verification ---------------------------------------------------------
def save_target(self, filename: str) -> Path:
return self.artifact_dir / filename
def verify_dxf(self, preferred: Path | None = None):
located = find_dxf(self.artifact_dir, preferred)
if located is None:
raise ToolCallFailed("no DXF artifact was produced")
return located, read_dxf(located)
def _result_text(result: Any) -> str:
parts: list[str] = []
for item in getattr(result, "content", None) or []:
text = getattr(item, "text", None)
if text:
parts.append(str(text))
return " | ".join(parts)[:1000] if parts else "<no text content>"
def _normalize_payload(payload: Any) -> Any:
"""Unwrap fastmcp's dynamic output wrappers and JSON-in-text payloads.
Legacy servers return plain text; the fastmcp client may deserialize it
into a generated *dataclass* (e.g. ``entityOutput(result='<json>')``) or a
pydantic model. Normalize down to dict / list / str so verification and
handle extraction see real data.
"""
if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
payload = dataclasses.asdict(payload)
elif hasattr(payload, "model_dump"):
payload = payload.model_dump()
elif not isinstance(payload, (dict, list, str)) and hasattr(payload, "result"):
payload = payload.result
if isinstance(payload, dict) and set(payload) == {"result"}:
payload = payload["result"]
if isinstance(payload, str):
try:
parsed = json.loads(payload)
except ValueError:
parsed = None
if isinstance(parsed, (dict, list)):
payload = parsed
return payload
_TEXT_ERROR_MARKERS = ('"ok": false', "'ok': false", '"ok":false', "ok=false")
def _payload_error(payload: Any) -> str | None:
"""Detect failure reported inside a tool payload instead of MCP isError."""
if isinstance(payload, dict):
if payload.get("ok") is False or payload.get("success") is False:
return str(payload.get("error") or payload.get("message") or "ok=false")[:300]
error = payload.get("error")
if isinstance(error, str) and error.strip():
return error[:300]
return None
if isinstance(payload, str):
lowered = payload.lower()
if lowered.startswith("error") or any(m in lowered for m in _TEXT_ERROR_MARKERS):
return payload[:300]
return None
def wrapped_shapes(payload: dict[str, Any], **top_level: Any) -> tuple[dict[str, Any], ...]:
"""Produce the two common consolidated-tool argument layouts for a payload:
nested under ``data`` and flattened at the top level."""
return (
{**top_level, "data": payload},
{**top_level, **payload},
)
_HANDLE_KEYS = ("handle", "entity_id", "entity_handle", "id", "object_id")
_HANDLE_RE = re.compile(
r'"(?:handle|entity_id|entity_handle|object_id)"\s*:\s*"?([0-9A-Fa-f*][\w*-]*)"?'
)
def extract_handle(payload: Any) -> str | None:
"""Best-effort extraction of a created-entity handle from a black-box
tool response (structured dict, list, or raw text)."""
if isinstance(payload, dict):
for key in _HANDLE_KEYS:
value = payload.get(key)
if isinstance(value, (str, int)) and str(value):
return str(value)
for value in payload.values():
found = extract_handle(value)
if found:
return found
return None
if isinstance(payload, (list, tuple)):
for item in payload:
found = extract_handle(item)
if found:
return found
return None
if isinstance(payload, str):
match = _HANDLE_RE.search(payload)
return match.group(1) if match else None
return None
+224
View File
@@ -0,0 +1,224 @@
"""Black-box adapter for puran-water/autocad-mcp (headless ezdxf lane).
The competitor exposes 8 consolidated tools (``drawing``, ``entity``,
``layer``, ``block``, ``annotation``, ``pid``, ``view``, ``system``) whose
operations take an ``operation`` selector plus a payload. Argument spellings
are probed through ``wrapped_shapes`` fallbacks; every geometry claim is
verified by saving a DXF and re-reading it with ezdxf in the harness.
Tasks with no documented equivalent on this server are reported as
``unsupported`` with an explicit reason so the published JSON explains the
coverage gap instead of hiding it.
"""
from __future__ import annotations
import math
from benchmarks.adapters.base import TaskResult
from benchmarks.adapters.mcp_stdio import (
McpStdioAdapter,
ToolCallFailed,
extract_handle,
)
from benchmarks.competitors_env import CompetitorSpec
PINNED_SHA = "95476a33a1c246308326eb4709d6379ef2efdbc1"
class PuranWaterAdapter(McpStdioAdapter):
name = "puran-water-autocad-mcp"
spec = CompetitorSpec(
competitor_id="puran-water-autocad-mcp",
repo_url="https://github.com/puran-water/autocad-mcp.git",
pinned_sha=PINNED_SHA,
entry_candidates=(
("-m", "autocad_mcp"),
("server.py",),
("src/server.py",),
("main.py",),
),
pip_installs=(("-e", "."),),
launch_env={"AUTOCAD_MCP_BACKEND": "ezdxf"},
pythonpath="src",
)
# Exact v3.1 contract (verified against the pinned checkout's server.py):
# entity create_line takes top-level x1..y2; circle/dimension payloads sit
# under ``data``; modify ops take top-level ``entity_id`` + ``data``.
def warmup_calls(self):
# The first backend touch lazily initializes and can exceed the
# per-call timeout; absorb it once during setup.
return (
("system", {"operation": "status"}),
("drawing", {"operation": "create", "data": {}}),
)
async def _new_drawing(self) -> None:
await self.call_op("drawing", "create", {})
async def _save(self, filename: str) -> str:
target = self.save_target(filename)
await self.call_op("drawing", "save", {"path": str(target)})
return str(target)
async def _create_line(self, x1: float, y1: float, x2: float, y2: float):
return await self.call(
"entity",
{"operation": "create_line", "x1": x1, "y1": y1, "x2": x2, "y2": y2},
)
# -- tasks ----------------------------------------------------------------
async def _task_core_geometry(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 3, 4)
await self.call_op("entity", "create_circle", {"cx": 10, "cy": 10, "radius": 2})
preferred = await self._save("puran_core_geometry.dxf")
_, doc = self.verify_dxf(self.save_target("puran_core_geometry.dxf"))
lines = doc.modelspace().query("LINE")
circles = doc.modelspace().query("CIRCLE")
length = None
if lines:
start, end = lines[0].dxf.start, lines[0].dxf.end
length = math.dist((start.x, start.y), (end.x, end.y))
passed = (
len(lines) == 1
and len(circles) == 1
and length is not None
and abs(length - 5.0) < 1e-6
and abs(circles[0].dxf.radius - 2.0) < 1e-6
)
return TaskResult(
"core_geometry",
"pass" if passed else "fail",
100.0 if passed else 0.0,
metrics={"line_length": length, "lines": len(lines), "circles": len(circles)},
artifacts=[preferred],
)
async def _task_modify_query(self) -> TaskResult:
await self._new_drawing()
created = await self._create_line(0, 0, 10, 0)
handle = extract_handle(created)
if not handle:
return TaskResult(
"modify_query",
"fail",
0.0,
"could not extract created-entity handle from the create_line response",
)
await self.call(
"entity",
{"operation": "move", "entity_id": handle, "data": {"dx": 5, "dy": 2}},
)
artifact = await self._save("puran_modify_query.dxf")
_, doc = self.verify_dxf(self.save_target("puran_modify_query.dxf"))
lines = doc.modelspace().query("LINE")
moved = (
bool(lines)
and abs(lines[0].dxf.start.x - 5.0) < 1e-6
and (abs(lines[0].dxf.start.y - 2.0) < 1e-6)
)
return TaskResult(
"modify_query",
"pass" if moved else "fail",
100.0 if moved else 0.0,
artifacts=[artifact],
)
async def _task_layers_linetypes(self) -> TaskResult:
await self._new_drawing()
try:
await self.call_op(
"layer",
"create",
{"name": "GEOMETRY", "color": 3, "linetype": "CENTER"},
)
except ToolCallFailed:
await self.call_op("layer", "create", {"name": "GEOMETRY", "color": 3})
artifact = await self._save("puran_layers.dxf")
_, doc = self.verify_dxf(self.save_target("puran_layers.dxf"))
has_layer = "GEOMETRY" in {layer.dxf.name.upper() for layer in doc.layers}
has_center = "CENTER" in {lt.dxf.name.upper() for lt in doc.linetypes}
if has_layer and has_center:
return TaskResult("layers_linetypes", "pass", 100.0, artifacts=[artifact])
if has_layer:
return TaskResult(
"layers_linetypes",
"partial",
50.0,
"layer created; no linetype-loading operation is exposed",
artifacts=[artifact],
)
return TaskResult("layers_linetypes", "fail", 0.0, artifacts=[artifact])
async def _task_dimensions(self) -> TaskResult:
await self._new_drawing()
await self.call_op(
"annotation",
"create_dimension_linear",
{"x1": 0, "y1": 0, "x2": 50, "y2": 0, "dim_x": 25, "dim_y": 10},
)
artifact = await self._save("puran_dimensions.dxf")
_, doc = self.verify_dxf(self.save_target("puran_dimensions.dxf"))
dims = [e for e in doc.modelspace() if e.dxftype() == "DIMENSION"]
return TaskResult(
"dimensions",
"pass" if dims else "fail",
100.0 if dims else 0.0,
metrics={"dimension_count": len(dims)},
artifacts=[artifact],
)
async def _task_dxf_roundtrip(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 10, 10)
artifact = await self._save("puran_roundtrip.dxf")
_, doc = self.verify_dxf(self.save_target("puran_roundtrip.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"dxf_roundtrip",
"pass" if count == 1 else "fail",
100.0 if count == 1 else 0.0,
metrics={"entity_count": count},
artifacts=[artifact],
)
# -- documented coverage gaps ----------------------------------------------
async def _task_table_mleader(self) -> TaskResult:
return TaskResult(
"table_mleader",
"unsupported",
0.0,
"no TABLE or MLEADER operation in the 8-tool surface (v3.1)",
)
async def _task_transactions(self) -> TaskResult:
return TaskResult(
"transactions",
"unsupported",
0.0,
"no transaction begin/commit/rollback tool (undo/redo are File-IPC only)",
)
async def _task_preflight(self) -> TaskResult:
return TaskResult("preflight", "unsupported", 0.0, "no requirements-preflight equivalent")
async def _task_quality_refiner(self) -> TaskResult:
return TaskResult(
"quality_refiner", "unsupported", 0.0, "no critique/repair loop equivalent"
)
async def _task_auditable_delivery(self) -> TaskResult:
return TaskResult(
"auditable_delivery",
"unsupported",
0.0,
"no hashed delivery-manifest equivalent (plot_pdf is File-IPC only)",
)
__all__ = ["PuranWaterAdapter", "ToolCallFailed"]
+26 -13
View File
@@ -11,6 +11,7 @@ recorded as a miss instead of taking down the whole run.
The older ref is checked out into a throwaway git worktree, benchmarked, and
removed. The "new" side is always the current repository checkout.
"""
from __future__ import annotations
import json
@@ -27,8 +28,7 @@ PY = sys.executable
def _run(cmd, cwd, repo_on_path):
env = {**os.environ, "PYTHONPATH": repo_on_path}
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True,
text=True, timeout=120)
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
def list_checks():
@@ -43,9 +43,14 @@ def list_checks():
def run_one(repo, suite, name):
try:
r = subprocess.run([PY, suite, name], cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True, text=True, timeout=120)
r = subprocess.run(
[PY, suite, name],
cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
return "timeout"
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
@@ -82,22 +87,30 @@ def main():
op = sum(r["old"] == "pass" for r in rows)
npass = sum(r["new"] == "pass" for r in rows)
summary = {
"baseline_ref": ref, "total": total,
"old_pass": op, "new_pass": npass,
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1),
"baseline_ref": ref,
"total": total,
"old_pass": op,
"new_pass": npass,
"old_pct": round(100 * op / total, 1),
"new_pct": round(100 * npass / total, 1),
"fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows),
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
}
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
print("-" * 66)
for r in rows:
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else (
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
mark = (
" ->FIXED"
if (r["old"] != "pass" and r["new"] == "pass")
else (" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
)
print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
print("-" * 66)
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
print(
f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}"
)
if json_out:
with open(json_out, "w") as fh:
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
+16
View File
@@ -5,6 +5,22 @@
"id": "autocad-mcp-pro",
"adapter": "benchmarks.adapters.autocad_mcp_pro:AutoCADMCPProAdapter",
"backends": ["ezdxf", "com"]
},
{
"id": "puran-water-autocad-mcp",
"adapter": "benchmarks.adapters.puran_water:PuranWaterAdapter",
"backends": ["ezdxf"],
"repo": "https://github.com/puran-water/autocad-mcp",
"pinned_sha": "95476a33a1c246308326eb4709d6379ef2efdbc1",
"notes": "Black-box MCP stdio lane; File-IPC (live AutoCAD) features run in the local COM lane only."
},
{
"id": "beiming183-autocad-mcp",
"adapter": "benchmarks.adapters.beiming_autocad:BeimingAutocadAdapter",
"backends": ["ezdxf"],
"repo": "https://github.com/beiming183-cloud/AutoCAD-MCP",
"pinned_sha": "11f7c47e5038796a20451b38b23032e625b5aa26",
"notes": "Black-box MCP stdio lane; deliver/undo/redo are documented File-IPC only and reported unsupported."
}
]
}
+121
View File
@@ -0,0 +1,121 @@
"""Reproducible checkout + venv management for competitor MCP servers.
Every competitor is pinned to an exact commit so a benchmark run is
reproducible: same tasks, same tree, same interpreter isolation. The cache
lives under ``benchmarks/.competitors/<id>/`` (gitignored) as::
repo/ the pinned git checkout
venv/ an isolated interpreter with the competitor installed
Nothing here talks MCP — it only prepares a runnable ``python`` + entry args
for :class:`benchmarks.adapters.mcp_stdio.McpStdioAdapter`.
"""
from __future__ import annotations
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
CACHE_ROOT = Path(__file__).resolve().parent / ".competitors"
@dataclass(frozen=True)
class CompetitorSpec:
"""Everything needed to fetch and launch one competitor server."""
competitor_id: str
repo_url: str
pinned_sha: str
# Candidate stdio entrypoints, resolved in order against the checkout.
# ("-m", "pkg") entries are accepted when repo/pkg or repo/src/pkg exists.
entry_candidates: tuple[tuple[str, ...], ...]
# pip arguments executed inside the venv, relative to the checkout.
pip_installs: tuple[tuple[str, ...], ...] = (("-e", "."),)
# Extra environment applied when launching the server.
launch_env: dict[str, str] = field(default_factory=dict)
# Set PYTHONPATH to this repo-relative directory (e.g. "src") if not None.
pythonpath: str | None = None
@dataclass(frozen=True)
class CompetitorRuntime:
"""A resolved, launchable competitor installation."""
python_exe: Path
repo_dir: Path
entry_args: tuple[str, ...]
env: dict[str, str]
def _run(cmd: list[str], cwd: Path | None = None, timeout: float = 600.0) -> None:
completed = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
if completed.returncode != 0:
raise RuntimeError(
f"command failed ({' '.join(cmd)}):\n{completed.stdout}\n{completed.stderr}"
)
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _ensure_checkout(spec: CompetitorSpec, repo_dir: Path) -> None:
if not (repo_dir / ".git").exists():
repo_dir.parent.mkdir(parents=True, exist_ok=True)
_run(["git", "clone", "--quiet", spec.repo_url, str(repo_dir)])
head = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo_dir, capture_output=True, text=True
).stdout.strip()
if head != spec.pinned_sha:
_run(["git", "fetch", "--quiet", "origin"], cwd=repo_dir)
_run(["git", "checkout", "--quiet", spec.pinned_sha], cwd=repo_dir)
def _ensure_venv(spec: CompetitorSpec, venv_dir: Path, repo_dir: Path) -> Path:
python_exe = _venv_python(venv_dir)
marker = venv_dir / ".installed-sha"
if python_exe.exists() and marker.exists() and marker.read_text().strip() == spec.pinned_sha:
return python_exe
_run([sys.executable, "-m", "venv", str(venv_dir)])
_run([str(python_exe), "-m", "pip", "install", "--quiet", "--upgrade", "pip"])
for install in spec.pip_installs:
_run([str(python_exe), "-m", "pip", "install", "--quiet", *install], cwd=repo_dir)
marker.write_text(spec.pinned_sha)
return python_exe
def _resolve_entry(spec: CompetitorSpec, repo_dir: Path) -> tuple[str, ...]:
for candidate in spec.entry_candidates:
if candidate[0] == "-m":
package = candidate[1].replace(".", "/")
roots = [repo_dir, repo_dir / "src"]
if any(
(root / package).is_dir() or (root / f"{package}.py").is_file() for root in roots
):
return candidate
elif (repo_dir / candidate[0]).is_file():
return tuple([str(repo_dir / candidate[0]), *candidate[1:]])
raise RuntimeError(
f"{spec.competitor_id}: no stdio entrypoint found "
f"(tried {[c[0] for c in spec.entry_candidates]})"
)
def ensure_competitor(spec: CompetitorSpec, cache_root: Path | None = None) -> CompetitorRuntime:
"""Clone, pin, install and resolve one competitor. Idempotent."""
root = (cache_root or CACHE_ROOT) / spec.competitor_id
repo_dir = root / "repo"
_ensure_checkout(spec, repo_dir)
python_exe = _ensure_venv(spec, root / "venv", repo_dir)
entry_args = _resolve_entry(spec, repo_dir)
env = dict(spec.launch_env)
if spec.pythonpath is not None:
env["PYTHONPATH"] = str(repo_dir / spec.pythonpath)
return CompetitorRuntime(
python_exe=python_exe, repo_dir=repo_dir, entry_args=entry_args, env=env
)
+74
View File
@@ -12,6 +12,7 @@ The same file is executed against both the baseline worktree and the current
checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a
method the old version lacks simply raises AttributeError -> miss.
"""
from __future__ import annotations
import asyncio
@@ -23,6 +24,7 @@ import tempfile
async def _b():
from backends.ezdxf_backend import EzdxfBackend
be = EzdxfBackend()
await be.connect()
await be.drawing_new()
@@ -31,53 +33,62 @@ async def _b():
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
async def core_line_length():
b = await _b()
ln = await b.entity_create_line(0, 0, 3, 4)
info = await b.entity_get(ln.handle)
return abs(float(info.properties["length"]) - 5.0) < 1e-6
async def core_circle_radius():
b = await _b()
c = await b.entity_create_circle(0, 0, 10)
info = await b.entity_get(c.handle)
return abs(float(info.properties["radius"]) - 10.0) < 1e-6
async def core_layer_create():
b = await _b()
await b.layer_create("BENCH", color=1)
names = {lyr.name for lyr in await b.layer_list()}
return "BENCH" in names
async def core_polyline_closed():
b = await _b()
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
info = await b.entity_get(p.handle)
return bool(info.properties.get("closed"))
async def core_linear_dim():
b = await _b()
d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
info = await b.entity_get(d.handle)
return "DIM" in info.type.upper()
async def core_save_dxf_roundtrip():
b = await _b()
await b.entity_create_line(0, 0, 10, 10)
path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
await b.drawing_save_as(path, "dxf")
import ezdxf
ezdxf.readfile(path) # raises if not a valid DXF
return os.path.getsize(path) > 0
# ── Dimensions (I16) ──────────────────────────────────────────────────────────
async def dim_aligned_no_error():
b = await _b()
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
return d is not None
async def dim_angular_no_error():
b = await _b()
d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60)
@@ -86,6 +97,7 @@ async def dim_angular_no_error():
# ── Polar array full circle (R9) ──────────────────────────────────────────────
async def array_polar_360_distinct():
b = await _b()
c = await b.entity_create_circle(10, 0, 1)
@@ -101,6 +113,7 @@ async def array_polar_360_distinct():
# ── Deterministic geometry (I9) ────────────────────────────────────────────────
async def point_intersection_line_line():
b = await _b()
l1 = await b.entity_create_line(0, 0, 10, 0)
@@ -108,6 +121,7 @@ async def point_intersection_line_line():
pt = await b.point_intersection(l1.handle, l2.handle)
return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6
async def point_tangent_external():
b = await _b()
c = await b.entity_create_circle(0, 0, 3)
@@ -120,18 +134,21 @@ async def point_tangent_external():
# ── Selection / property parity (N3, N5) ───────────────────────────────────────
async def arc_has_length():
b = await _b()
a = await b.entity_create_arc(0, 0, 10, 0, 90)
info = await b.entity_get(a.handle)
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
async def arc_select_by_length():
b = await _b()
await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7
sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
return len(sel) == 1
async def ezdxf_bounding_box():
b = await _b()
ln = await b.entity_create_line(0, 0, 30, 40)
@@ -139,6 +156,7 @@ async def ezdxf_bounding_box():
bb = info.properties.get("bounding_box")
return isinstance(bb, dict) and set(bb) == {"min", "max"}
async def mtext_rotation_roundtrip():
b = await _b()
mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0)
@@ -148,6 +166,7 @@ async def mtext_rotation_roundtrip():
# ── Rendering (screenshot Agg / SIGSEGV) ───────────────────────────────────────
async def screenshot_png():
b = await _b()
await b.entity_create_line(0, 0, 100, 100)
@@ -157,6 +176,7 @@ async def screenshot_png():
# ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
async def dim_overlap_critique_fires():
b = await _b()
await b.drawing_apply_iso_layers("mech")
@@ -165,6 +185,7 @@ async def dim_overlap_critique_fires():
issues = await b.drawing_critique(focus=["dim_overlap"])
return len(issues) >= 1
async def iso13567_dim_layer():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
@@ -172,6 +193,7 @@ async def iso13567_dim_layer():
dims = await b.dimension_auto([ln.handle], style="chain")
return dims[0].layer == "M-DIMEN-T-N"
async def construction_left_iso_caught():
b = await _b()
await b.drawing_apply_iso_layers("iso13567")
@@ -182,8 +204,10 @@ async def construction_left_iso_caught():
# ── Engineering (gear) ──────────────────────────────────────────────────────────
async def gear_no_self_overlap():
from engineering.gear import generate_full_gear_outline
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
root_r = (2 * 50 / 2.0) - 1.25 * 2
return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6
@@ -191,6 +215,7 @@ async def gear_no_self_overlap():
# ── Linetype on demand (R24) ───────────────────────────────────────────────────
async def center_linetype_applied():
b = await _b()
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
@@ -198,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"),
@@ -220,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"),
}
+210
View File
@@ -0,0 +1,210 @@
"""Headless performance suite (v3 lane): wall-time on real drawing workloads.
Complements the correctness/coverage lanes with *scale* evidence: how long the
ezdxf engine takes to create, roundtrip, query and quality-check real geometry.
Workloads drive the backend exactly like the MCP tools do (same async methods),
so the numbers include the server-side call overhead, not just raw ezdxf.
Honesty boundaries:
- Self-measurement only — no competitor timing claims (their servers would pay
an extra stdio serialization cost ours does not pay in-process).
- Wall-clock on the machine recorded in the report; absolute numbers move with
hardware, the workload definitions do not.
Run:
python -m benchmarks.perf_suite # human summary
python -m benchmarks.perf_suite --json # machine-readable report
python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import platform
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from backends.ezdxf_backend import EzdxfBackend
@dataclass
class WorkloadResult:
workload_id: str
description: str
wall_ms: float
metric: str
metric_value: float
detail: dict
async def _timed(coro) -> tuple[float, object]:
started = time.perf_counter()
result = await coro
return (time.perf_counter() - started) * 1000.0, result
async def workload_create_lines_2k(backend: EzdxfBackend) -> WorkloadResult:
"""2,000 individual entity_create_line calls (per-call tool overhead included)."""
await backend.drawing_new()
started = time.perf_counter()
for index in range(2_000):
y = float(index % 100)
await backend.entity_create_line(0.0, y, 100.0, y + 1.0)
wall_ms = (time.perf_counter() - started) * 1000.0
count = (await backend.drawing_info()).entity_count
return WorkloadResult(
"create_lines_2k",
"Create 2,000 lines through individual backend calls",
round(wall_ms, 1),
"entities_per_second",
round(2_000 / (wall_ms / 1000.0), 1),
{"entity_count": count},
)
async def workload_roundtrip_10k(backend: EzdxfBackend, scratch: Path) -> WorkloadResult:
"""Build a 10,000-entity drawing, export DXF, reopen, read info."""
await backend.drawing_new()
build_started = time.perf_counter()
for index in range(10_000):
x = float(index % 500)
y = float(index // 500)
await backend.entity_create_line(x, y, x + 0.8, y + 0.8)
build_ms = (time.perf_counter() - build_started) * 1000.0
target = scratch / "perf_roundtrip_10k.dxf"
export_ms, _ = await _timed(backend.drawing_export_dxf(str(target)))
reopened = EzdxfBackend()
await reopened.connect()
try:
reopen_ms, _ = await _timed(reopened.drawing_open(str(target)))
count = (await reopened.drawing_info()).entity_count
finally:
await reopened.disconnect()
total = build_ms + export_ms + reopen_ms
return WorkloadResult(
"roundtrip_10k",
"Create 10,000 lines, export DXF, reopen and verify",
round(total, 1),
"entities_per_second",
round(10_000 / (total / 1000.0), 1),
{
"build_ms": round(build_ms, 1),
"export_ms": round(export_ms, 1),
"reopen_ms": round(reopen_ms, 1),
"file_bytes": target.stat().st_size,
"entity_count": count,
},
)
async def workload_region_query_10k(backend: EzdxfBackend) -> WorkloadResult:
"""Rectangular region query against the 10,000-entity drawing."""
# backend still holds the 10k drawing from the roundtrip workload builder;
# rebuild deterministically to stay order-independent.
await backend.drawing_new()
for index in range(10_000):
x = float(index % 500)
y = float(index // 500)
await backend.entity_create_line(x, y, x + 0.8, y + 0.8)
wall_ms, found = await _timed(backend.analysis_entities_in_region(0.0, 0.0, 250.0, 10.0))
return WorkloadResult(
"region_query_10k",
"Crossing-selection region query over 10,000 entities",
round(wall_ms, 1),
"entities_matched",
float(len(found)),
{"entity_count": 10_000},
)
async def workload_premium_pass(backend: EzdxfBackend) -> WorkloadResult:
"""Small end-to-end quality loop: layers, geometry, dimensions, critique."""
started = time.perf_counter()
await backend.drawing_new()
await backend.drawing_apply_iso_layers("mech")
plate = [
(0.0, 0.0, 120.0, 0.0),
(120.0, 0.0, 120.0, 80.0),
(120.0, 80.0, 0.0, 80.0),
(0.0, 80.0, 0.0, 0.0),
]
for x1, y1, x2, y2 in plate:
await backend.entity_create_line(x1, y1, x2, y2, layer="GEOMETRY")
await backend.entity_create_circle(60.0, 40.0, 12.0, layer="GEOMETRY")
await backend.dimension_linear(0, 0, 120, 0, 60, -15, layer="DIM")
await backend.dimension_linear(0, 0, 0, 80, -15, 40, 90, layer="DIM")
issues = await backend.drawing_critique(None)
wall_ms = (time.perf_counter() - started) * 1000.0
return WorkloadResult(
"premium_pass",
"Plate with hole: ISO layers, geometry, dimensions and full critique",
round(wall_ms, 1),
"critique_issues",
float(len(issues)),
{"entities": (await backend.drawing_info()).entity_count},
)
async def run_suite(scratch: Path) -> dict:
backend = EzdxfBackend()
await backend.connect()
try:
results = [
await workload_create_lines_2k(backend),
await workload_roundtrip_10k(backend, scratch),
await workload_region_query_10k(backend),
await workload_premium_pass(backend),
]
finally:
await backend.disconnect()
return {
"schema_version": "perf/1.0",
"backend": "ezdxf",
"environment": {
"python": sys.version.split()[0],
"implementation": platform.python_implementation(),
"platform": platform.platform(),
"machine": platform.machine(),
},
"workloads": [asdict(item) for item in results],
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
parser.add_argument("--out", type=Path, default=None, help="Also write the report to a file")
parser.add_argument("--scratch", type=Path, default=Path("benchmarks/results/perf-scratch"))
return parser
def main() -> int:
args = build_parser().parse_args()
args.scratch.mkdir(parents=True, exist_ok=True)
report = asyncio.run(run_suite(args.scratch))
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.out is not None:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(rendered + "\n", encoding="utf-8")
if args.json:
print(rendered)
else:
for item in report["workloads"]:
print(
f"{item['workload_id']:>18} {item['wall_ms']:>10.1f} ms "
f"{item['metric']}={item['metric_value']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+31 -17
View File
@@ -66,24 +66,38 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-source-review-v1"
projects = sorted(data["projects"], key=lambda item: item["score"])
labels = [item["repository"] for item in projects]
total = len(projects)
# rank 1 = highest score (drawn topmost by barh with ascending sort)
labels = [f"{total - index}. {item['repository']}" for index, item in enumerate(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]
# leaderboard palette by rank (rank 1 first), canvas/LLM-benchmark style
rank_palette = [
"#3fb950", # 1 green
"#58a6ff", # 2 sky
"#6e7ce0", # 3 indigo
"#d29922", # 4 amber
"#c4622d", # 5 rust
"#e3b341", # 6 yellow
"#bf4b8a", # 7 magenta
"#f0876e", # 8 salmon
"#8957e5", # 9 purple
]
colors = [rank_palette[(total - 1 - index) % len(rank_palette)] for index in range(total)]
fig, ax = plt.subplots(figsize=(12, 6.4), constrained_layout=True)
fig.patch.set_facecolor("#f6f8fa")
ax.set_facecolor("#f6f8fa")
fig, ax = plt.subplots(figsize=(12, 0.62 * total + 2.4), constrained_layout=True)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
bars = ax.barh(labels, scores, color=colors, height=0.62)
ax.set_xlim(0, 100)
ax.set_xlabel("Weighted capability score / 100", color="#24292f", labelpad=10)
ax.set_xlabel("Weighted capability score / 100", color="#e6edf3", labelpad=10)
ax.set_title(
"Source-reviewed capability benchmark",
"Public AutoCAD MCP leaderboard — Source-reviewed capability benchmark",
loc="left",
fontsize=18,
fontsize=17,
fontweight="bold",
color="#24292f",
color="#e6edf3",
pad=28,
)
ax.text(
@@ -91,17 +105,17 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
1.02,
f"Fixed rubric · reviewed {data['reviewed_at']} · evidence grade shown per project",
transform=ax.transAxes,
color="#57606a",
color="#8b949e",
fontsize=10,
)
for bar, score, grade in zip(bars, scores, grades, strict=True):
ax.text(
min(score + 1.2, 96),
min(score + 1.2, 88),
bar.get_y() + bar.get_height() / 2,
f"{score} · {grade}",
f"{score} / 100 · {grade}",
va="center",
color="#24292f",
color="#e6edf3",
fontweight="bold",
)
@@ -110,13 +124,13 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
-0.17,
"Shared live-run results are not implied. Scores reflect public-source and recorded test evidence.",
transform=ax.transAxes,
color="#57606a",
color="#8b949e",
fontsize=9,
)
ax.grid(axis="x", color="#d0d7de", linewidth=0.8, alpha=0.8)
ax.grid(axis="x", color="#30363d", linewidth=0.8, alpha=0.9)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#57606a")
ax.tick_params(axis="y", colors="#24292f", length=0, pad=8)
ax.tick_params(axis="x", colors="#8b949e")
ax.tick_params(axis="y", colors="#e6edf3", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
+136
View File
@@ -0,0 +1,136 @@
"""Render the live-run benchmark lane (v2 fixed-task matrix) as a deterministic SVG.
Input: the published per-server reports under ``benchmarks/results/published/``
(produced by ``benchmarks.run_competitors``, artifact paths sanitized to
filenames). Output: a GitHub-readable horizontal bar chart showing the
weighted score and task coverage per server.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-livebench.svg"
def load_reports(input_dir: Path) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(input_dir.glob("*.json")):
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "2.0":
continue # other lanes (e.g. perf/1.0) share the published folder
summary = data.get("summary") or {}
if "score" not in summary or "coverage_percent" not in summary:
raise ValueError(f"{path.name}: summary is missing score/coverage")
reports.append(data)
if not reports:
raise SystemExit(f"no published reports found in {input_dir}")
return reports
def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-livebench-v2"
reports = sorted(reports, key=lambda item: item["summary"]["score"])
labels = [item["adapter"] for item in reports]
scores = [item["summary"]["score"] for item in reports]
coverage = [item["summary"]["coverage_percent"] for item in reports]
passed = [item["summary"]["passed"] for item in reports]
attempted = [item["summary"]["attempted"] for item in reports]
colors = ["#58a6ff" if label == "autocad-mcp-pro" else "#484f58" for label in labels]
fig, ax = plt.subplots(figsize=(12, 4.8), constrained_layout=True)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
bars = ax.barh(labels, scores, color=colors, height=0.58)
ax.set_xlim(0, 100)
ax.set_xlabel(
"Weighted live-run score / 100 (fixed 10-task matrix)", color="#e6edf3", labelpad=10
)
ax.set_title(
"Live-run benchmark - headless ezdxf lane",
loc="left",
fontsize=18,
fontweight="bold",
color="#e6edf3",
pad=28,
)
ax.text(
0,
1.02,
"Same 10 tasks, same harness, pinned competitor commits; artifacts verified by re-opening the DXF",
transform=ax.transAxes,
color="#8b949e",
fontsize=10,
)
for bar, score, cov, ok, total in zip(bars, scores, coverage, passed, attempted, strict=True):
ax.text(
min(score + 1.2, 82),
bar.get_y() + bar.get_height() / 2,
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage)",
va="center",
color="#e6edf3",
fontweight="bold",
)
ax.text(
0,
-0.22,
"Unsupported tasks score 0 in the fixed matrix; per-task statuses and reasons are in benchmarks/results/published/.",
transform=ax.transAxes,
color="#8b949e",
fontsize=9,
)
ax.grid(axis="x", color="#30363d", linewidth=0.8, alpha=0.8)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#8b949e")
ax.tick_params(axis="y", colors="#e6edf3", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro live benchmark renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_DIR)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_reports(args.input_dir), args.output)
print(f"Rendered live benchmark chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+180
View File
@@ -0,0 +1,180 @@
"""Render the live-run task matrix (tasks x servers) as a deterministic SVG.
Input: the published per-server reports under ``benchmarks/results/published/``
(the same files the score chart uses). Output: a status heatmap — every task
against every server — so coverage gaps are visible per capability instead of
being folded into one number.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from benchmarks.tasks_v3 import TASKS_V3
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-taskmatrix.svg"
STATUS_COLORS = {
"pass": "#238636",
"partial": "#d29922",
"fail": "#da3633",
"timeout": "#a40e26",
"unsupported": "#30363d",
"not_run": "#161b22",
}
STATUS_GLYPHS = {
"pass": "P",
"partial": "~",
"fail": "F",
"timeout": "T",
"unsupported": "-",
"not_run": "?",
}
def load_reports(input_dir: Path) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(input_dir.glob("*.json")):
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "2.0":
continue # perf reports and other lanes live in the same folder
reports.append(data)
if not reports:
raise SystemExit(f"no task-matrix reports found in {input_dir}")
# our server first, then alphabetical
reports.sort(key=lambda item: (item["adapter"] != "autocad-mcp-pro", item["adapter"]))
return reports
def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch, Rectangle
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1"
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"]}
for report in reports
}
fig, ax = plt.subplots(
figsize=(3.4 + 2.4 * len(servers), 1.8 + 0.52 * len(task_ids)),
constrained_layout=True,
)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
for row, task_id in enumerate(task_ids):
for col, server in enumerate(servers):
status = status_by_server[server].get(task_id, "not_run")
color = STATUS_COLORS.get(status, "#f6f8fa")
ax.add_patch(
Rectangle(
(col + 0.06, row + 0.08),
0.88,
0.84,
facecolor=color,
edgecolor="#30363d",
linewidth=0.8,
)
)
glyph_color = "#ffffff" if status in ("pass", "fail", "timeout") else "#e6edf3"
ax.text(
col + 0.5,
row + 0.5,
STATUS_GLYPHS.get(status, "?"),
ha="center",
va="center",
fontsize=11,
fontweight="bold",
color=glyph_color,
)
ax.set_xlim(0, len(servers))
ax.set_ylim(len(task_ids), 0)
ax.set_xticks([index + 0.5 for index in range(len(servers))])
ax.set_xticklabels(servers, color="#e6edf3", fontsize=10, fontweight="bold")
ax.xaxis.set_ticks_position("top")
ax.set_yticks([index + 0.5 for index in range(len(task_ids))])
ax.set_yticklabels(task_labels, color="#e6edf3", fontsize=9)
ax.tick_params(length=0)
for spine in ax.spines.values():
spine.set_visible(False)
ax.set_title(
"Task matrix — live headless lane",
loc="left",
fontsize=17,
fontweight="bold",
color="#e6edf3",
pad=34,
)
legend_handles = [
Patch(facecolor=STATUS_COLORS["pass"], label="pass"),
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=5,
frameon=False,
fontsize=9,
labelcolor="#e6edf3",
)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro task-matrix renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_DIR)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_reports(args.input_dir), args.output)
print(f"Rendered task-matrix chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+146
View File
@@ -0,0 +1,146 @@
"""Render the headless performance suite as a deterministic SVG.
Input: the report produced by ``benchmarks.perf_suite --out ...``
(default: ``benchmarks/results/published/perf-ezdxf.json``).
Output: a GitHub-readable log-scale wall-time chart per workload.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT = ROOT / "benchmarks" / "results" / "published" / "perf-ezdxf.json"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-perf.svg"
_LABELS = {
"create_lines_2k": "Create 2,000 lines (individual calls)",
"roundtrip_10k": "10,000 lines: build + DXF export + reopen",
"region_query_10k": "Region query over 10,000 entities",
"premium_pass": "Premium pass: layers + part + dims + full critique",
}
def load_report(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "perf/1.0":
raise ValueError("unsupported perf report schema_version")
if not data.get("workloads"):
raise ValueError("perf report contains no workloads")
return data
def _annotation(item: dict[str, Any]) -> str:
wall_s = item["wall_ms"] / 1000.0
if item["metric"] == "entities_per_second":
return f"{wall_s:.2f} s · {item['metric_value']:,.0f} entities/s"
if item["metric"] == "entities_matched":
return f"{wall_s:.2f} s · {item['metric_value']:,.0f} matched"
if item["metric"] == "critique_issues":
return f"{wall_s:.2f} s · {item['metric_value']:.0f} issues"
return f"{wall_s:.2f} s"
def render_chart(data: dict[str, Any], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-perf-v1"
workloads = sorted(data["workloads"], key=lambda item: item["wall_ms"])
labels = [_LABELS.get(item["workload_id"], item["workload_id"]) for item in workloads]
values = [item["wall_ms"] for item in workloads]
fig, ax = plt.subplots(figsize=(12, 4.6), constrained_layout=True)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
bars = ax.barh(labels, values, color="#58a6ff", height=0.58)
ax.set_xscale("log")
ax.set_xlabel("Wall time in ms (log scale) — lower is better", color="#e6edf3", labelpad=10)
ax.set_title(
"Headless performance — ezdxf engine",
loc="left",
fontsize=18,
fontweight="bold",
color="#e6edf3",
pad=28,
)
env = data.get("environment", {})
ax.text(
0,
1.02,
f"Fixed workloads via the same backend methods the MCP tools call · "
f"Python {env.get('python', '?')} · {env.get('platform', '?')}",
transform=ax.transAxes,
color="#8b949e",
fontsize=9,
)
for bar, item in zip(bars, workloads, strict=True):
ax.text(
bar.get_width() * 1.12,
bar.get_y() + bar.get_height() / 2,
_annotation(item),
va="center",
color="#e6edf3",
fontweight="bold",
fontsize=9,
)
ax.set_xlim(right=max(values) * 8)
ax.text(
0,
-0.24,
"Self-measurement only: competitor servers would pay extra stdio serialization cost in-process runs do not. "
"Reproduce: python -m benchmarks.perf_suite",
transform=ax.transAxes,
color="#8b949e",
fontsize=9,
)
ax.grid(axis="x", color="#30363d", linewidth=0.8, alpha=0.8)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#8b949e")
ax.tick_params(axis="y", colors="#e6edf3", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro perf renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_report(args.input), args.output)
print(f"Rendered perf chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,140 @@
{
"summary": {
"baseline_ref": "v1.3.0",
"total": 21,
"old_pass": 21,
"new_pass": 21,
"old_pct": 100.0,
"new_pct": 100.0,
"fixed": 0,
"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"
}
]
}
@@ -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"
}
]
}
@@ -0,0 +1,432 @@
{
"adapter": "autocad-mcp-pro",
"adapter_metadata": {
"backend": "ezdxf",
"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,
"supported": true
},
"drawing_2d": {
"mode": "native",
"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
},
"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": "2d_xy_cannot_address_a_tilted_plane",
"supported": false
},
"paper_space": {
"mode": "native",
"reason": null,
"supported": true
},
"pdf": {
"mode": "rendered",
"reason": null,
"supported": true
},
"png": {
"mode": "rendered",
"reason": null,
"supported": true
},
"preflight": {
"mode": "shared",
"reason": null,
"supported": true
},
"refiner": {
"mode": "shared",
"reason": null,
"supported": true
},
"revcloud": {
"mode": "native",
"reason": "ezdxf_revcloud_helper",
"supported": true
},
"solid_3d": {
"mode": null,
"reason": "acis_generation_requires_live_autocad",
"supported": false
},
"table": {
"mode": "composite",
"reason": null,
"supported": true
},
"transactions": {
"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
}
}
}
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"generated_at": "2026-08-06T06:27:59.080169+00:00",
"git_sha": "d5a0ff5109ddbd37d88371c10a1ed087201de4b5",
"matrix": "v3",
"results": [
{
"artifacts": [],
"duration_ms": 2.49,
"message": "",
"metrics": {
"line_length": 5.0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [],
"duration_ms": 1.41,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [],
"duration_ms": 3.31,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [],
"duration_ms": 17.55,
"message": "",
"metrics": {
"entity_type": "DIMENSION"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 3.7,
"message": "",
"metrics": {
"representations": [
"composite",
"composite"
]
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 29.09,
"message": "",
"metrics": {
"entity_count": 0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 1.2,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 8.42,
"message": "",
"metrics": {
"final_score": 100.0,
"initial_score": 92.0,
"remaining_issues": [],
"rounds": [
{
"actions": [
{
"detail": {
"deleted_handle": "3E",
"kept_handle": "3D"
},
"fingerprint": "c1b87eac3cfdf11312c9b18ccf4c062e672791f7a9a6dfa3c45b6db7f9e5835e",
"focus": "duplicate_entities",
"handles": [
"3D",
"3E"
],
"status": "applied"
}
],
"error_count_after": 0,
"error_count_before": 0,
"number": 1,
"score_after": 100.0,
"score_before": 92.0,
"transaction": "committed"
}
],
"status": "threshold_met"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "roundtrip.dxf",
"sha256": "d11dfce5eddaf849075c62854a8526efaaa3bbd779d374e4c287de77f361caf7",
"size_bytes": 18440
}
],
"duration_ms": 32.29,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [
{
"filename": "manifest.json",
"sha256": "a74b2e96dbe7ea373717f26a8d26e58fe079a61370eb5d646f04a09f1b7cca2f",
"size_bytes": 5662
}
],
"duration_ms": 305.81,
"message": "",
"metrics": {
"score": {
"errors": 0,
"grade": "A",
"info": 0,
"invalidity_ratio": 0.0,
"score": 100.0,
"warnings": 0
}
},
"score": 100.0,
"status": "pass",
"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": 15,
"coverage_percent": 100.0,
"passed": 15,
"score": 100.0,
"supported": 15,
"unsupported": 0
},
"timeout_seconds": 180.0
}
@@ -0,0 +1,191 @@
{
"adapter": "beiming183-autocad-mcp",
"adapter_metadata": {
"backend": "ezdxf",
"kind": "mcp-stdio-blackbox",
"pinned_sha": "11f7c47e5038796a20451b38b23032e625b5aa26",
"repo": "https://github.com/beiming183-cloud/AutoCAD-MCP.git",
"tools_discovered": [
"annotation",
"block",
"drawing",
"entity",
"job",
"layer",
"pid",
"product",
"solid",
"system",
"transaction",
"view"
]
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:38:36.969519+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [
{
"filename": "beiming_core_geometry.dxf",
"sha256": "d972b29da994b1ea8f3887eef84f2ed892a486409385b56cd444b1fe9794e337",
"size_bytes": 18587
}
],
"duration_ms": 96.24,
"message": "",
"metrics": {
"circles": 1,
"line_length": 5.0,
"lines": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [
{
"filename": "beiming_modify_query.dxf",
"sha256": "298534d8512ce62833551ab6e54c0c2a0baf18bdf7a96bd956cdf06280fb4fb8",
"size_bytes": 18473
}
],
"duration_ms": 108.09,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [
{
"filename": "beiming_layers.dxf",
"sha256": "b401e145e97aca7011d03097190b3d0ece0a89b47d38022a81c6c4c7e250ed3d",
"size_bytes": 19887
}
],
"duration_ms": 69.86,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [
{
"filename": "beiming_dimensions.dxf",
"sha256": "8996d2ba42da66f46e79d1655405a4bf2ba72301861eeea58509cba44c408fb4",
"size_bytes": 20972
}
],
"duration_ms": 75.55,
"message": "",
"metrics": {
"dimension_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 0.12,
"message": "no TABLE or MLEADER operation in the annotation tool (v3.10)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 18.99,
"message": "transaction begin is rejected as unsupported on the headless backend",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.04,
"message": "no requirements-preflight equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 0.02,
"message": "audits detect issues but there is no automated repair loop",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "beiming_roundtrip.dxf",
"sha256": "79d7bdc1e66e506dd2ecf66fbf030839242c77870021ccbfe3c819ad394695ce",
"size_bytes": 18474
}
],
"duration_ms": 69.95,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [],
"duration_ms": 0.06,
"message": "drawing.deliver is documented as File-IPC only (not headless)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 50.0,
"passed": 5,
"score": 50.0,
"supported": 5,
"unsupported": 5
},
"timeout_seconds": 30.0
}
@@ -0,0 +1,56 @@
{
"backend": "ezdxf",
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"schema_version": "perf/1.0",
"workloads": [
{
"description": "Create 2,000 lines through individual backend calls",
"detail": {
"entity_count": 2000
},
"metric": "entities_per_second",
"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": 1574.1,
"entity_count": 10000,
"export_ms": 261.8,
"file_bytes": 1369663,
"reopen_ms": 320.2
},
"metric": "entities_per_second",
"metric_value": 4637.9,
"wall_ms": 2156.1,
"workload_id": "roundtrip_10k"
},
{
"description": "Crossing-selection region query over 10,000 entities",
"detail": {
"entity_count": 10000
},
"metric": "entities_matched",
"metric_value": 2500.0,
"wall_ms": 890.8,
"workload_id": "region_query_10k"
},
{
"description": "Plate with hole: ISO layers, geometry, dimensions and full critique",
"detail": {
"entities": 7
},
"metric": "critique_issues",
"metric_value": 0.0,
"wall_ms": 176.5,
"workload_id": "premium_pass"
}
]
}
@@ -0,0 +1,187 @@
{
"adapter": "puran-water-autocad-mcp",
"adapter_metadata": {
"backend": "ezdxf",
"kind": "mcp-stdio-blackbox",
"pinned_sha": "95476a33a1c246308326eb4709d6379ef2efdbc1",
"repo": "https://github.com/puran-water/autocad-mcp.git",
"tools_discovered": [
"annotation",
"block",
"drawing",
"entity",
"layer",
"pid",
"system",
"view"
]
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:32:07.977232+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [
{
"filename": "puran_core_geometry.dxf",
"sha256": "bc8185404c8344134859293a0e28ebf821cd5968553cdd96b7029e46e1ca0c99",
"size_bytes": 18587
}
],
"duration_ms": 205.92,
"message": "",
"metrics": {
"circles": 1,
"line_length": 5.0,
"lines": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [
{
"filename": "puran_modify_query.dxf",
"sha256": "2113a59d0a9f624b6134e89f992d153bc8e085495e956f4887b8ba7b54f3c005",
"size_bytes": 18473
}
],
"duration_ms": 156.79,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [
{
"filename": "puran_layers.dxf",
"sha256": "7144467e22e2e8c8d18f1fc4f177f078f9ce450bb73f4e9ca8f74c16d67e1b53",
"size_bytes": 18498
}
],
"duration_ms": 189.98,
"message": "layer created; no linetype-loading operation is exposed",
"metrics": {},
"score": 50.0,
"status": "partial",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [
{
"filename": "puran_dimensions.dxf",
"sha256": "dde808c5e504c5e6b7837f5f5c14ef096711812a6239dd284906efed5c9e3614",
"size_bytes": 20972
}
],
"duration_ms": 215.28,
"message": "",
"metrics": {
"dimension_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 0.12,
"message": "no TABLE or MLEADER operation in the 8-tool surface (v3.1)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 0.02,
"message": "no transaction begin/commit/rollback tool (undo/redo are File-IPC only)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.01,
"message": "no requirements-preflight equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 0.03,
"message": "no critique/repair loop equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "puran_roundtrip.dxf",
"sha256": "11335953641be32f3f47c23fbc13f79876111c6f84f8874af8f5e04d5e398cf6",
"size_bytes": 18474
}
],
"duration_ms": 143.4,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [],
"duration_ms": 0.06,
"message": "no hashed delivery-manifest equivalent (plot_pdf is File-IPC only)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 50.0,
"passed": 4,
"score": 45.0,
"supported": 5,
"unsupported": 5
},
"timeout_seconds": 30.0
}
+89 -13
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import importlib
import json
import platform
import subprocess
@@ -13,8 +14,22 @@ 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]:
"""Read competitors.yaml (JSON content) into an id -> entry registry."""
path = Path(__file__).with_name("competitors.yaml")
data = json.loads(path.read_text(encoding="utf-8"))
return {entry["id"]: entry for entry in data.get("competitors", [])}
def _make_adapter(entry: dict, backend: str) -> BenchmarkAdapter:
module_name, _, attr = entry["adapter"].partition(":")
adapter_cls = getattr(importlib.import_module(module_name), attr)
return adapter_cls(backend=backend)
def _git_sha() -> str | None:
@@ -64,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)
@@ -76,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)
@@ -91,12 +113,14 @@ async def run_tasks(
total_weight = sum(weight_by_id.values())
supported_weight = sum(weight_by_id[item.task_id] for item in supported)
weighted_score = sum(
weight_by_id[item.task_id] * item.score
for item in results
if item.status != "unsupported"
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(),
@@ -122,33 +146,85 @@ async def run_tasks(
}
def _parser() -> argparse.ArgumentParser:
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("--server", default="autocad-mcp-pro", choices=["autocad-mcp-pro"])
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",
choices=sorted(registry),
help="Adapter id from benchmarks/competitors.yaml",
)
parser.add_argument("--backend", default="ezdxf", choices=["ezdxf", "com"])
parser.add_argument("--task", action="append", help="Run only this task id (repeatable)")
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:
args = _parser().parse_args()
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
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2)
adapter = AutoCADMCPProAdapter(backend=args.backend)
entry = registry[args.server]
supported_backends = entry.get("backends", ["ezdxf"])
if args.backend not in supported_backends:
raise SystemExit(
f"{args.server} supports backends {supported_backends}, not {args.backend!r}"
)
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)
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)
+28 -4
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"reviewed_at": "2026-07-17",
"reviewed_at": "2026-07-23",
"title": "AutoCAD MCP source-reviewed capability benchmark",
"evidence_boundary": "source_review_not_shared_live_run",
"methodology": "Public source, documented behavior, and locally recorded test evidence scored with a fixed 100-point rubric. Repository stars and raw tool counts do not score points.",
@@ -16,10 +16,10 @@
{
"repository": "U-C4N/Autocad-MCP",
"url": "https://github.com/U-C4N/Autocad-MCP",
"reviewed_version": "1.3.0 working tree",
"score": 93,
"reviewed_version": "1.4.0 (PyPI release)",
"score": 95,
"evidence_grade": "A",
"note": "Dual COM/ezdxf backends, standards-aware quality gates, deterministic geometry, closed-loop refinement, and auditable delivery."
"note": "Dual COM/ezdxf backends, paper space, ISO 129/286/1101 annotation, closed-loop quality gates, auditable delivery; v1.4 adds CI matrix incl. Windows, PyPI packaging, and a version A/B correctness gate (21/21, 0 regressions)."
},
{
"repository": "varavista/autocad-mcp",
@@ -60,6 +60,30 @@
"score": 68,
"evidence_grade": "B",
"note": ".NET main-thread integration with a broad live AutoCAD operation surface."
},
{
"repository": "LokmenoWer/best-cad-mcp",
"url": "https://github.com/LokmenoWer/best-cad-mcp",
"reviewed_version": "v1.5.0 (2026-07-23 snapshot)",
"score": 74,
"evidence_grade": "B",
"note": "Very broad live-AutoCAD operation surface with CAD-IR understanding, visual grounding, and guarded CADPlan dry-run/rollback; COM-only (no headless lane), no standards-based generators or toleranced annotation."
},
{
"repository": "daobataotie/CAD-MCP",
"url": "https://github.com/daobataotie/CAD-MCP",
"reviewed_version": "2026-07-23 snapshot",
"score": 35,
"evidence_grade": "B",
"note": "Multi-CAD reach (AutoCAD/GstarCAD/ZWCAD) over Windows COM with a small basic drawing surface; no verification, transactions, tests, or delivery evidence documented."
},
{
"repository": "zh19980811/Easy-MCP-AutoCad",
"url": "https://github.com/zh19980811/Easy-MCP-AutoCad",
"reviewed_version": "2026-07-23 snapshot",
"score": 30,
"evidence_grade": "C",
"note": "Basic COM drawing tool set with SQLite persistence; reviewed from the documented tool surface only, execution path not exercised locally."
}
]
}
+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
+76 -1
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,11 +73,50 @@ 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) 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.
self.enable_3d: bool = os.environ.get("ENABLE_3D", "false").lower().strip() == "true"
settings = Settings()
+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)
+51
View File
@@ -0,0 +1,51 @@
# Release & Distribution Runbook (v1.4.0+)
The code side of a release is fully automated; three account-bound steps need
a human once, plus the directory submissions. Track them here.
## One-time setup (owner action)
- [ ] **PyPI trusted publisher** — on <https://pypi.org/manage/account/publishing/>
add a *pending publisher* for project `autocad-mcp-pro`:
repository `U-C4N/Autocad-MCP`, workflow `release.yml`, environment `pypi`.
(No API token is stored anywhere; `release.yml` uses OIDC.)
- [ ] **GitHub environment** — create the `pypi` environment in the repo
settings (Settings → Environments) so the publish job can bind to it.
- [ ] **MCP registry login** — install `mcp-publisher`, then
`mcp-publisher login github` (authenticates the `io.github.u-c4n` namespace).
## Every release
1. Bump `pyproject.toml` + `server.json` versions, add the CHANGELOG section,
update the README snapshot line — `tests/test_release_consistency.py`
enforces all of it.
2. Push `main`, wait for CI to go green (lint, linux, windows, package,
docker, registry-validate).
3. Tag and push: `git tag v1.4.0 && git push origin v1.4.0`.
`release.yml` runs the test gate, verifies tag == package version, builds,
publishes to PyPI via trusted publishing and cuts the GitHub Release.
4. Verify: `pip install autocad-mcp-pro==<version>` in a clean venv;
`uvx autocad-mcp-pro --help`.
5. Publish the registry manifest: `mcp-publisher publish` (validates
`server.json`, checks the `mcp-name` marker in the PyPI README).
## Directory listings (submit once, then keep fresh)
- [ ] Glama — <https://glama.ai/mcp/servers> (auto-indexes GitHub; claim the
server page and verify metadata).
- [ ] mcpservers.org — submit via their GitHub repo PR flow.
- [ ] awesome-mcp-servers — PR adding the server under CAD/engineering.
- [ ] Smithery — optional; requires a `smithery.yaml` (evaluate demand first).
## Benchmark refresh (per release)
```bash
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
# sanitize + copy reports into benchmarks/results/published/, then:
python -m benchmarks.render_live_chart
```
Bump the competitor pins in `benchmarks/competitors.yaml` deliberately (new
SHA = re-read their tool contracts before trusting the mappings).
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 69 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because it is too large Load Diff

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
+95 -72
View File
@@ -29,8 +29,13 @@ _EXPECTED_LAYER_COLOR: dict[str, int] = {
# Dimension entity types that should sit on the DIM layer.
_DIM_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
"DIMENSION",
"DIMLINEAR",
"DIMALIGNED",
"DIMANGULAR",
"DIMRADIUS",
"DIMDIAMETER",
"DIMORDINATE",
}
@@ -57,15 +62,17 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
# (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept
# either the raw mm value or hundredths/100.
if lw not in allowed and (lw / 100.0) not in allowed:
issues.append(Issue(
severity="warning",
focus="iso128",
message=(
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
f"ISO 128 set {sorted(allowed)}."
),
detail={"layer": lyr.name, "lineweight": lw},
))
issues.append(
Issue(
severity="warning",
focus="iso128",
message=(
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
f"ISO 128 set {sorted(allowed)}."
),
detail={"layer": lyr.name, "lineweight": lw},
)
)
return issues
@@ -79,17 +86,19 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
for lyr in layers:
expected = _EXPECTED_LAYER_COLOR.get(lyr.name)
if expected is None:
continue # unknown layer — caller's responsibility
continue # unknown layer — caller's responsibility
if int(lyr.color) != int(expected):
issues.append(Issue(
severity="warning",
focus="layer_color",
message=(
f"Layer '{lyr.name}' has color {lyr.color}; "
f"engineering standard expects {expected}."
),
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
))
issues.append(
Issue(
severity="warning",
focus="layer_color",
message=(
f"Layer '{lyr.name}' has color {lyr.color}; "
f"engineering standard expects {expected}."
),
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
)
)
return issues
@@ -107,17 +116,21 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
if not leftover:
return []
return [Issue(
severity="error",
focus="construction_left",
message=(
f"{len(leftover)} entity/entities still on a construction layer. "
"Call construction_clear() before drawing_finalize."
),
handles=[e.handle for e in leftover[:50]],
detail={"count": len(leftover),
"layers": sorted({e.layer for e in leftover if e.layer})},
)]
return [
Issue(
severity="error",
focus="construction_left",
message=(
f"{len(leftover)} entity/entities still on a construction layer. "
"Call construction_clear() before drawing_finalize."
),
handles=[e.handle for e in leftover[:50]],
detail={
"count": len(leftover),
"layers": sorted({e.layer for e in leftover if e.layer}),
},
)
]
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
@@ -150,16 +163,18 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
if key in seen:
continue
seen.add(key)
issues.append(Issue(
severity="warning",
focus="untrimmed_corner",
message=(
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
"likely an untrimmed corner (use entity_trim or entity_fillet)."
),
handles=[h1, h2],
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
))
issues.append(
Issue(
severity="warning",
focus="untrimmed_corner",
message=(
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
"likely an untrimmed corner (use entity_trim or entity_fillet)."
),
handles=[h1, h2],
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
)
)
return issues
@@ -180,12 +195,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
k2 = (k1[2], k1[3], k1[0], k1[1])
for key in (k1, k2):
if key in seen:
issues.append(Issue(
severity="warning",
focus="duplicate_entities",
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
handles=[seen[key], ln.handle],
))
issues.append(
Issue(
severity="warning",
focus="duplicate_entities",
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
handles=[seen[key], ln.handle],
)
)
break
else:
seen[k1] = ln.handle
@@ -243,12 +260,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
if key in seen:
continue
seen.add(key)
issues.append(Issue(
severity="warning",
focus="dim_overlap",
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
handles=[h1, h2],
))
issues.append(
Issue(
severity="warning",
focus="dim_overlap",
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
handles=[h1, h2],
)
)
return issues
@@ -267,19 +286,21 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
missing = sorted(referenced - defined)
if not missing:
return []
return [Issue(
severity="error",
focus="gdt",
message=(
f"Feature control frame(s) reference datum(s) {missing} with no "
"matching datum feature. Add draw_datum_feature for each datum."
),
detail={
"missing_datums": missing,
"defined": sorted(defined),
"referenced": sorted(referenced),
},
)]
return [
Issue(
severity="error",
focus="gdt",
message=(
f"Feature control frame(s) reference datum(s) {missing} with no "
"matching datum feature. Add draw_datum_feature for each datum."
),
detail={
"missing_datums": missing,
"defined": sorted(defined),
"referenced": sorted(referenced),
},
)
]
_FOCUS_DISPATCH = {
@@ -303,11 +324,13 @@ async def run_critique(
for f in foci:
check = _FOCUS_DISPATCH.get(f)
if check is None:
issues.append(Issue(
severity="warning",
focus=f, # type: ignore[arg-type]
message=f"Unknown critique focus '{f}' (closed enum).",
))
issues.append(
Issue(
severity="warning",
focus=f, # type: ignore[arg-type]
message=f"Unknown critique focus '{f}' (closed enum).",
)
)
continue
issues.extend(await check(backend))
return issues
+4 -4
View File
@@ -163,7 +163,9 @@ async def deliver_drawing(
expected: dict | None = None,
) -> DeliveryResult:
"""Export, validate, hash and re-open a release-ready drawing bundle."""
requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"])))
requested = list(
dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"]))
)
unknown = [item for item in requested if item not in _FORMAT_FILENAMES]
if unknown:
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
@@ -210,9 +212,7 @@ async def deliver_drawing(
artifact_path.write_bytes(png)
artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
except Exception as exc:
artifacts.append(
_artifact_record(fmt, artifact_path, status="failed", error=str(exc))
)
artifacts.append(_artifact_record(fmt, artifact_path, status="failed", error=str(exc)))
finally:
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
+399
View File
@@ -0,0 +1,399 @@
"""ISO 286-1/2 limits-and-fits lookup (authored table data, not formulas).
`fit_lookup("H7", 20.0)` resolves a fit code into upper/lower deviations in
millimetres, ready to feed `tolerances.build_dim_override` (tol_mode
"deviation"). Values are the published ISO 286 table values — the standard's
rounding rules make naive formula evaluation drift by 1 µm or a full step in
places, so the tables are authored and pinned by tests instead.
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, 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, ...), 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
import re
from dataclasses import dataclass
# Size steps: (over, up_to_incl) in mm — ISO 286-1 main steps, 1..500 mm.
_SIZE_STEPS: tuple[tuple[float, float], ...] = (
(1, 3),
(3, 6),
(6, 10),
(10, 18),
(18, 30),
(30, 50),
(50, 80),
(80, 120),
(120, 180),
(180, 250),
(250, 315),
(315, 400),
(400, 500),
)
# Standard tolerance values IT4..IT11 in µm per size step (ISO 286-1 Table 1).
_IT_GRADES = (4, 5, 6, 7, 8, 9, 10, 11)
_IT_TABLE: tuple[tuple[int, ...], ...] = (
# IT4 IT5 IT6 IT7 IT8 IT9 IT10 IT11
(3, 4, 6, 10, 14, 25, 40, 60), # >1-3
(4, 5, 8, 12, 18, 30, 48, 75), # >3-6
(4, 6, 9, 15, 22, 36, 58, 90), # >6-10
(5, 8, 11, 18, 27, 43, 70, 110), # >10-18
(6, 9, 13, 21, 33, 52, 84, 130), # >18-30
(7, 11, 16, 25, 39, 62, 100, 160), # >30-50
(8, 13, 19, 30, 46, 74, 120, 190), # >50-80
(10, 15, 22, 35, 54, 87, 140, 220), # >80-120
(12, 18, 25, 40, 63, 100, 160, 250), # >120-180
(14, 20, 29, 46, 72, 115, 185, 290), # >180-250
(16, 23, 32, 52, 81, 130, 210, 320), # >250-315
(18, 25, 36, 57, 89, 140, 230, 360), # >315-400
(20, 27, 40, 63, 97, 155, 250, 400), # >400-500
)
# Fundamental deviations for shafts in µm per size step (ISO 286-1 Table 2/3).
# d..g carry the upper deviation es (negative); k..p carry the lower
# deviation ei (positive). h is es=0; js is symmetric (±IT/2).
_SHAFT_ES: dict[str, tuple[int, ...]] = {
"d": (-20, -30, -40, -50, -65, -80, -100, -120, -145, -170, -190, -210, -230),
"e": (-14, -20, -25, -32, -40, -50, -60, -72, -85, -100, -110, -125, -135),
"f": (-6, -10, -13, -16, -20, -25, -30, -36, -43, -50, -56, -62, -68),
"g": (-2, -4, -5, -6, -7, -9, -10, -12, -14, -15, -17, -18, -20),
}
_SHAFT_EI: dict[str, tuple[int, ...]] = {
# k applies to IT4..IT7 only (other grades have ei = 0 per the standard).
"k": (1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 4, 4, 5),
"m": (2, 4, 6, 7, 8, 9, 11, 13, 15, 17, 20, 21, 23),
"n": (4, 8, 10, 12, 15, 17, 20, 23, 27, 31, 34, 37, 40),
"p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68),
}
# 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),
)
# 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)
class FitDeviation:
"""Resolved fit: deviations in mm relative to the nominal size."""
code: str
nominal: float
upper_mm: float
lower_mm: float
it_grade: int
it_value_mm: float
def to_dict(self) -> dict:
return {
"code": self.code,
"nominal": self.nominal,
"upper_mm": self.upper_mm,
"lower_mm": self.lower_mm,
"it_grade": self.it_grade,
"it_value_mm": self.it_value_mm,
}
def parse_fit_code(code: str) -> tuple[str, int]:
"""Split e.g. 'H7' -> ('H', 7) or 'js6' -> ('js', 6)."""
match = _FIT_CODE_RE.match(code or "")
if not match:
raise ValueError(
f"Invalid fit code {code!r}. Expected letter + IT grade, e.g. H7, g6, js9."
)
return match.group(1), int(match.group(2))
def _size_index(nominal_mm: float) -> int:
if not 1.0 < nominal_mm <= 500.0:
raise ValueError(
f"Nominal size {nominal_mm} mm is outside the authored ISO 286 range "
"(over 1 mm up to and including 500 mm)."
)
for index, (over, up_to) in enumerate(_SIZE_STEPS):
if over < nominal_mm <= up_to:
return index
raise ValueError(f"No ISO 286 size step covers {nominal_mm} mm") # pragma: no cover
def _it_um(grade: int, size_index: int) -> int:
if grade not in _IT_GRADES:
raise ValueError(f"IT grade {grade} is outside the authored range IT4-IT11.")
return _IT_TABLE[size_index][_IT_GRADES.index(grade)]
def fit_lookup(code: str, nominal_mm: float) -> FitDeviation:
"""Resolve a fit code for a nominal size into mm deviations.
Holes use uppercase letters, shafts lowercase (ISO 286 convention).
Raises ValueError for codes outside the authored scope.
"""
letter, grade = parse_fit_code(code)
index = _size_index(float(nominal_mm))
it_um = _it_um(grade, index)
if letter in ("js", "JS"):
upper_um = it_um / 2.0
lower_um = -it_um / 2.0
elif letter == "h":
upper_um, lower_um = 0.0, float(-it_um)
elif letter == "H":
upper_um, lower_um = float(it_um), 0.0
elif letter in _SHAFT_ES: # d, e, f, g — clearance shafts
es = float(_SHAFT_ES[letter][index])
upper_um, lower_um = es, es - it_um
elif letter in ("D", "E", "F", "G"): # mirrored clearance holes: EI = -es
ei = float(-_SHAFT_ES[letter.lower()][index])
upper_um, lower_um = ei + it_um, ei
elif letter in _SHAFT_EI: # k, m, n, p — transition/interference shafts
if letter == "k" and not 4 <= grade <= 7:
ei = 0.0 # ISO 286: k has ei = 0 outside IT4-IT7
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}."
)
return FitDeviation(
code=f"{letter}{grade}",
nominal=float(nominal_mm),
upper_mm=round(upper_um / 1000.0, 6),
lower_mm=round(lower_um / 1000.0, 6),
it_grade=grade,
it_value_mm=round(it_um / 1000.0, 6),
)
+35 -13
View File
@@ -67,16 +67,18 @@ def generate_involute_flank(
return points
def _rotate_points(
pts: list[tuple[float, float]], angle: float
) -> list[tuple[float, float]]:
def _rotate_points(pts: list[tuple[float, float]], angle: float) -> list[tuple[float, float]]:
c, s = math.cos(angle), math.sin(angle)
return [(c * x - s * y, s * x + c * y) for x, y in pts]
def _arc_points(
cx: float, cy: float, radius: float,
start_angle: float, end_angle: float, n: int = 5,
cx: float,
cy: float,
radius: float,
start_angle: float,
end_angle: float,
n: int = 5,
) -> list[tuple[float, float]]:
"""Sample n points along an arc (start_angle → end_angle, radians, ccw)."""
if n < 2:
@@ -232,20 +234,29 @@ async def draw_helical_gear_front_view(
root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY")
pts = generate_full_gear_outline(
module=module, teeth=teeth, pressure_angle=pressure_angle,
helix_angle=helix_angle, hand=hand, center=(cx, cy),
module=module,
teeth=teeth,
pressure_angle=pressure_angle,
helix_angle=helix_angle,
hand=hand,
center=(cx, cy),
)
outline = await backend.entity_create_polyline(
[[float(x), float(y)] for x, y in pts],
closed=True, layer="GEOMETRY",
closed=True,
layer="GEOMETRY",
)
helix_handles: list[str] | None = None
helix_label: str | None = None
if helix_angle and abs(helix_angle) > 1e-9:
helix_handles, helix_label = await _draw_helix_symbol(
backend, cx=cx, cy=cy, outer_r=outer_r,
helix_angle=helix_angle, hand=hand,
backend,
cx=cx,
cy=cy,
outer_r=outer_r,
helix_angle=helix_angle,
hand=hand,
)
bore_handle: str | None = None
@@ -263,7 +274,10 @@ async def draw_helical_gear_front_view(
keyway_dict = keyed
else:
bore_only = await backend.entity_create_circle(
cx, cy, bore_diameter / 2.0, layer="GEOMETRY",
cx,
cy,
bore_diameter / 2.0,
layer="GEOMETRY",
)
bore_handle = bore_only.handle
@@ -385,10 +399,18 @@ async def draw_gear_section_aa(
bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]]
else:
bore_top = await backend.entity_create_line(
x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY",
x_left,
cy + bore_r,
x_right,
cy + bore_r,
layer="GEOMETRY",
)
bore_bot = await backend.entity_create_line(
x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY",
x_left,
cy - bore_r,
x_right,
cy - bore_r,
layer="GEOMETRY",
)
bore_handles = [bore_top.handle, bore_bot.handle]
+43 -9
View File
@@ -45,7 +45,9 @@ def keyway_dimensions(bore_diameter: float) -> dict:
f"({DIN6885_TABLE[-1][1]} mm)."
)
for b_min, b_max, width, height, t1 in DIN6885_TABLE:
if b_min < bore_diameter <= b_max or (bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]):
if b_min < bore_diameter <= b_max or (
bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]
):
return {
"width": float(width),
"height": float(height),
@@ -95,10 +97,18 @@ async def draw_keyed_bore(
overshoot = max(r * 0.2, 5.0)
horiz = await backend.entity_create_line(
cx - r - overshoot, cy, cx + r + overshoot, cy, layer=center_layer,
cx - r - overshoot,
cy,
cx + r + overshoot,
cy,
layer=center_layer,
)
vert = await backend.entity_create_line(
cx, cy - r - overshoot, cx, cy + r + depth + overshoot, layer=center_layer,
cx,
cy - r - overshoot,
cx,
cy + r + depth + overshoot,
layer=center_layer,
)
return {
@@ -142,23 +152,47 @@ async def draw_keyway_section(
notch_right = cx + half_kw
bore_top = await backend.entity_create_line(
x_left, top_y, x_right, top_y, layer=layer,
x_left,
top_y,
x_right,
top_y,
layer=layer,
)
bore_bottom = await backend.entity_create_line(
x_left, bot_y, x_right, bot_y, layer=layer,
x_left,
bot_y,
x_right,
bot_y,
layer=layer,
)
notch_bottom = await backend.entity_create_line(
notch_left, top_y, notch_right, top_y, layer=layer,
notch_left,
top_y,
notch_right,
top_y,
layer=layer,
)
notch_left_wall = await backend.entity_create_line(
notch_left, top_y, notch_left, notch_top_y, layer=layer,
notch_left,
top_y,
notch_left,
notch_top_y,
layer=layer,
)
notch_top = await backend.entity_create_line(
notch_left, notch_top_y, notch_right, notch_top_y, layer=layer,
notch_left,
notch_top_y,
notch_right,
notch_top_y,
layer=layer,
)
notch_right_wall = await backend.entity_create_line(
notch_right, notch_top_y, notch_right, top_y, layer=layer,
notch_right,
notch_top_y,
notch_right,
top_y,
layer=layer,
)
return {
+45 -41
View File
@@ -13,52 +13,52 @@ log = logging.getLogger(__name__)
ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
# (name, color, linetype, lineweight, description)
("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
# P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice).
PID_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"),
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
("0", 7, "Continuous", 0.25, "default"),
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
# ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1).
# This is a *starter* set — extend as needed per discipline.
ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"),
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
("0", 7, "Continuous", 0.25, "default"),
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
]
@@ -73,9 +73,9 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
# "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses
# M-DIMEN-T-N / M-CONST-E-N).
LAYER_ROLES: dict[str, dict[str, str]] = {
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
}
@@ -85,6 +85,7 @@ def resolve_role_layer(layer_set_id: str | None, role: str) -> str:
roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"])
return roles.get(role, LAYER_ROLES["mech"][role])
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
@@ -115,14 +116,14 @@ async def ensure_engineering_layers(backend: AutoCADBackend) -> dict[str, str]:
async def apply_layer_set(
backend: AutoCADBackend, standard: str = "mech",
backend: AutoCADBackend,
standard: str = "mech",
) -> dict[str, str]:
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
layer_set = LAYER_SET_REGISTRY.get(standard)
if layer_set is None:
raise RuntimeError(
f"Unknown layer set '{standard}'. "
f"Choose one of: {sorted(LAYER_SET_REGISTRY)}"
f"Unknown layer set '{standard}'. Choose one of: {sorted(LAYER_SET_REGISTRY)}"
)
await ensure_standard_linetypes(backend)
@@ -139,7 +140,10 @@ async def apply_layer_set(
continue
try:
await backend.layer_create(
name=name, color=color, linetype=linetype, lineweight=lineweight,
name=name,
color=color,
linetype=linetype,
lineweight=lineweight,
)
results[name] = "created"
except Exception as exc:
+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)))
+9 -1
View File
@@ -91,5 +91,13 @@ class Issue:
# ISO 128 lineweight set (mm). Any lineweight outside this set is flagged.
ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = (
0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00, 1.40, 2.00,
0.13,
0.18,
0.25,
0.35,
0.50,
0.70,
1.00,
1.40,
2.00,
)
+11 -8
View File
@@ -99,9 +99,7 @@ def preflight_drawing(
assumptions.append({"field": "units", "value": "mm", "reason": "default"})
if allow_assumptions and not req.get("tolerance_policy"):
req["tolerance_policy"] = "general"
assumptions.append(
{"field": "tolerance_policy", "value": "general", "reason": "default"}
)
assumptions.append({"field": "tolerance_policy", "value": "general", "reason": "default"})
if isinstance(req.get("units"), str):
req["units"] = req["units"].lower()
@@ -110,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"],
),
)
@@ -140,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:
+2 -6
View File
@@ -64,12 +64,8 @@ async def repair_untrimmed_corner(backend: AutoCADBackend, issue: Issue) -> dict
midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2)
first = await backend.entity_get(issue.handles[0])
second = await backend.entity_get(issue.handles[1])
await backend.entity_edit_geometry(
issue.handles[0], **_endpoint_kwargs(first, p1, midpoint)
)
await backend.entity_edit_geometry(
issue.handles[1], **_endpoint_kwargs(second, p2, midpoint)
)
await backend.entity_edit_geometry(issue.handles[0], **_endpoint_kwargs(first, p1, midpoint))
await backend.entity_edit_geometry(issue.handles[1], **_endpoint_kwargs(second, p2, midpoint))
return {"handles": list(issue.handles), "corner": list(midpoint)}
+71 -8
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],
@@ -72,7 +111,9 @@ async def apply_iso_a3_titleblock(
[ox, oy + SHEET_H],
]
outer = await backend.entity_create_polyline(
points=outer_pts, closed=True, layer=LAYER_BORDER,
points=outer_pts,
closed=True,
layer=LAYER_BORDER,
)
inner_x0 = ox + LEFT_MARGIN
@@ -86,7 +127,9 @@ async def apply_iso_a3_titleblock(
[inner_x0, inner_y1],
]
inner = await backend.entity_create_polyline(
points=inner_pts, closed=True, layer=LAYER_BORDER,
points=inner_pts,
closed=True,
layer=LAYER_BORDER,
)
# ---- title block (lower-right corner of inner border) ----------------------
@@ -103,7 +146,9 @@ async def apply_iso_a3_titleblock(
[tb_x0, tb_y1],
]
tb_frame = await backend.entity_create_polyline(
points=tb_frame_pts, closed=True, layer=LAYER_BORDER,
points=tb_frame_pts,
closed=True,
layer=LAYER_BORDER,
)
# Row baselines (from bottom up): row4_top, row3_top, row2_top
@@ -115,13 +160,21 @@ async def apply_iso_a3_titleblock(
async def hline(y: float) -> str:
ent = await backend.entity_create_line(
tb_x0, y, tb_x1, y, layer=LAYER_BORDER,
tb_x0,
y,
tb_x1,
y,
layer=LAYER_BORDER,
)
return ent.handle
async def vline(x: float, y0: float, y1: float) -> str:
ent = await backend.entity_create_line(
x, y0, x, y1, layer=LAYER_BORDER,
x,
y0,
x,
y1,
layer=LAYER_BORDER,
)
return ent.handle
@@ -157,13 +210,21 @@ async def apply_iso_a3_titleblock(
async def label(text: str, x: float, y: float) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=LABEL_HEIGHT, layer=LAYER_TEXT,
text=text,
x=x,
y=y,
height=LABEL_HEIGHT,
layer=LAYER_TEXT,
)
return ent.handle
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=height, layer=LAYER_TEXT,
text=text,
x=x,
y=y,
height=height,
layer=LAYER_TEXT,
)
return ent.handle
@@ -241,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,
+7 -2
View File
@@ -50,12 +50,17 @@ def build_dim_override(
elif mode == "deviation":
if tol_upper is None or tol_lower is None:
raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.")
# DIMTM convention: the displayed lower deviation is -DIMTM. Callers
# pass tol_lower as the magnitude of the minus deviation (0.01 means
# -0.01, legacy contract); a *negative* tol_lower expresses a plus
# lower deviation, which double-positive ISO 286 fits (e.g. p6
# +0.035/+0.022) require.
override.update(
{
"dimtol": 1,
"dimlim": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
"dimtm": float(tol_lower),
}
)
elif mode == "limit":
@@ -66,7 +71,7 @@ def build_dim_override(
"dimlim": 1,
"dimtol": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
"dimtm": float(tol_lower),
}
)
elif mode == "basic":
+131 -91
View File
@@ -12,8 +12,13 @@ if TYPE_CHECKING:
REAL_DIMENSION_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
"DIMENSION",
"DIMLINEAR",
"DIMALIGNED",
"DIMANGULAR",
"DIMRADIUS",
"DIMDIAMETER",
"DIMORDINATE",
}
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
@@ -35,7 +40,7 @@ _TITLE_LAYERS = {"TITLE", "TITLEBLOCK"}
class ValidationFinding:
"""One issue surfaced by the validator."""
severity: str # "error" | "warning" | "info"
severity: str # "error" | "warning" | "info"
code: str
message: str
detail: dict = field(default_factory=dict)
@@ -84,40 +89,47 @@ class DrawingValidator:
try:
ents = await backend.entity_list(limit=10000)
text_likes = [
e for e in ents
e
for e in ents
if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search(
getattr(e, "properties", {}).get("text", "") or ""
)
and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes:
findings.append(ValidationFinding(
"warning",
"fake_dimension_text",
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
"(Ø, ±, R, etc.) — use dimension_* tools instead",
{
"sample_handles": [t.handle for t in text_likes[:5]],
"real_dim_count": len(real_dims),
},
))
findings.append(
ValidationFinding(
"warning",
"fake_dimension_text",
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
"(Ø, ±, R, etc.) — use dimension_* tools instead",
{
"sample_handles": [t.handle for t in text_likes[:5]],
"real_dim_count": len(real_dims),
},
)
)
except Exception as exc:
findings.append(ValidationFinding(
"warning", "dim_check_failed", str(exc),
))
findings.append(
ValidationFinding(
"warning",
"dim_check_failed",
str(exc),
)
)
# 2 — hidden orphans
try:
hidden = [e for e in ents if not e.visible]
if hidden:
findings.append(ValidationFinding(
"error",
"hidden_orphans",
f"{len(hidden)} entities have visible=False — hide-fallback "
"is forbidden, use entity_delete",
{"sample_handles": [h.handle for h in hidden[:5]]},
))
findings.append(
ValidationFinding(
"error",
"hidden_orphans",
f"{len(hidden)} entities have visible=False — hide-fallback "
"is forbidden, use entity_delete",
{"sample_handles": [h.handle for h in hidden[:5]]},
)
)
except Exception:
pass
@@ -126,45 +138,53 @@ class DrawingValidator:
lts = {x.lower() for x in await backend.linetype_list()}
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
if missing:
findings.append(ValidationFinding(
"error",
"linetypes_missing",
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
{"missing": missing},
))
findings.append(
ValidationFinding(
"error",
"linetypes_missing",
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
{"missing": missing},
)
)
except Exception as exc:
findings.append(ValidationFinding(
"warning", "linetype_check_failed", str(exc),
))
findings.append(
ValidationFinding(
"warning",
"linetype_check_failed",
str(exc),
)
)
# 4 — gear views (heuristic, only if expected demands)
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
bore_circles = [
e for e in ents
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
]
bore_circles = [e for e in ents if e.type == "CIRCLE" and e.layer == "GEOMETRY"]
if expected.get("must_have_bore") and len(bore_circles) < 1:
findings.append(ValidationFinding(
"error",
"bore_missing",
"Expected bore but no circle on GEOMETRY layer found",
))
findings.append(
ValidationFinding(
"error",
"bore_missing",
"Expected bore but no circle on GEOMETRY layer found",
)
)
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
# on GEOMETRY — requiring `closed` here made this a permanent
# false-negative. Match a small (3-6 point) polyline, open or closed.
keyway_polys = [
e for e in ents
e
for e in ents
if e.type in ("LWPOLYLINE", "POLYLINE")
and e.layer == "GEOMETRY"
and 3 <= len(e.properties.get("points", [])) <= 6
]
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
findings.append(ValidationFinding(
"warning",
"keyway_section_unverified",
"Could not confirm keyway via heuristic "
"(small 3-6 point polyline on GEOMETRY)",
))
findings.append(
ValidationFinding(
"warning",
"keyway_section_unverified",
"Could not confirm keyway via heuristic "
"(small 3-6 point polyline on GEOMETRY)",
)
)
# 5 — title consistency
helix = expected.get("helix_angle")
@@ -190,19 +210,23 @@ class DrawingValidator:
has_spur = bool(_SPUR_RE.search(joined))
has_helical = bool(_HELICAL_RE.search(joined))
if has_spur and has_helical:
findings.append(ValidationFinding(
"error",
"title_helical_spur",
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
"'HELICAL GEAR' or 'SPUR GEAR'",
))
findings.append(
ValidationFinding(
"error",
"title_helical_spur",
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
"'HELICAL GEAR' or 'SPUR GEAR'",
)
)
elif has_spur:
findings.append(ValidationFinding(
"error",
"title_says_spur_for_helical",
f"Title contains SPUR but part is helical (helix_angle={helix})",
{"sample": joined[:200]},
))
findings.append(
ValidationFinding(
"error",
"title_says_spur_for_helical",
f"Title contains SPUR but part is helical (helix_angle={helix})",
{"sample": joined[:200]},
)
)
# 6 — saved to disk
try:
@@ -210,46 +234,62 @@ class DrawingValidator:
saved_attr = getattr(info, "saved", None)
full_path = getattr(info, "full_path", "") or ""
if not saved_attr:
findings.append(ValidationFinding(
"error",
"not_saved",
"Drawing has unsaved changes — call drawing_save before finalize",
{"full_path": full_path},
))
findings.append(
ValidationFinding(
"error",
"not_saved",
"Drawing has unsaved changes — call drawing_save before finalize",
{"full_path": full_path},
)
)
elif full_path and not Path(full_path).exists():
findings.append(ValidationFinding(
"error",
"file_missing",
f"Drawing reports saved=True but file not on disk: {full_path}",
))
findings.append(
ValidationFinding(
"error",
"file_missing",
f"Drawing reports saved=True but file not on disk: {full_path}",
)
)
except Exception as exc:
findings.append(ValidationFinding(
"warning", "save_check_failed", str(exc),
))
findings.append(
ValidationFinding(
"warning",
"save_check_failed",
str(exc),
)
)
# 7 — screenshot
try:
shot = await backend.view_screenshot()
if not shot:
findings.append(ValidationFinding(
"info",
"screenshot_unavailable",
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
))
findings.append(
ValidationFinding(
"info",
"screenshot_unavailable",
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
)
)
except Exception as exc:
findings.append(ValidationFinding(
"info", "screenshot_failed", str(exc),
))
findings.append(
ValidationFinding(
"info",
"screenshot_failed",
str(exc),
)
)
# 8 — dwg path returned
try:
info = await backend.drawing_info()
if not getattr(info, "full_path", ""):
findings.append(ValidationFinding(
"warning",
"no_path",
"drawing_info.full_path is empty",
))
findings.append(
ValidationFinding(
"warning",
"no_path",
"drawing_info.full_path is empty",
)
)
except Exception:
pass
+95 -8
View File
@@ -1,21 +1,85 @@
[project]
name = "autocad-mcp-pro"
version = "1.3.0"
version = "1.5.0"
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{ name = "Umutcan Edizsalan" },
]
keywords = [
"autocad",
"mcp",
"model-context-protocol",
"cad",
"dxf",
"ezdxf",
"drafting",
"engineering-drawing",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Manufacturing",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"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]
Homepage = "https://github.com/U-C4N/Autocad-MCP"
Repository = "https://github.com/U-C4N/Autocad-MCP"
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 = [
@@ -26,13 +90,28 @@ full = [
[project.scripts]
autocad-mcp = "server:main"
# Alias matching the PyPI distribution name so `uvx autocad-mcp-pro` works
# 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]
@@ -42,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())
+6 -4
View File
@@ -61,10 +61,12 @@ async def _run() -> int:
print(f"ok (center=({cx},{cy}), r=5)")
print("[6/9] entity_select_smart ...", end=" ")
geom = await backend.entity_select_smart({
"type": "LINE",
"layer": "GEOMETRY",
})
geom = await backend.entity_select_smart(
{
"type": "LINE",
"layer": "GEOMETRY",
}
)
assert len(geom) >= 2, len(geom)
print(f"ok ({len(geom)} lines selected)")
+158 -34
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,38 +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.
@@ -173,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,
@@ -192,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
@@ -213,10 +332,15 @@ def sanitize_lisp(expression: str) -> str:
raise ToolError("LISP expression cannot be empty.")
if config.settings.dangerous_commands_enabled:
log.info("Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80])
log.info(
"Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80]
)
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. "
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.u-c4n/autocad-mcp",
"title": "AutoCAD MCP Pro",
"description": "AutoCAD automation for AI agents: live COM or headless ezdxf, ISO dims, GD&T, quality gate.",
"repository": {
"url": "https://github.com/U-C4N/Autocad-MCP",
"source": "github"
},
"version": "1.5.0",
"packages": [
{
"registryType": "pypi",
"identifier": "autocad-mcp-pro",
"version": "1.5.0",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"name": "AUTOCAD_MCP_BACKEND",
"description": "Backend selection: auto (default), com (live AutoCAD, Windows) or ezdxf (headless DXF).",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "ALLOWED_PATHS",
"description": "Comma-separated absolute paths the server may read and write. Empty blocks file access outside temp.",
"isRequired": false,
"format": "string",
"isSecret": false
},
{
"name": "TOOL_PROFILE",
"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
}
]
}
]
}
+3446 -274
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
+4 -1
View File
@@ -68,7 +68,10 @@ async def test_analysis_entity_stats(backend):
stats = await backend.analysis_stats()
assert isinstance(stats, dict)
by_type = stats.get("by_type", stats)
total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0))
total = stats.get(
"total",
stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0),
)
assert total >= 3
+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()
+11 -4
View File
@@ -51,8 +51,12 @@ async def test_batch_modify_delete(backend):
async def test_template_architectural_layers(backend):
"""Architectural template should create standard layers."""
layer_defs = [
("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4),
("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7),
("WALLS", 7),
("DOORS", 3),
("WINDOWS", 4),
("FURNITURE", 8),
("DIMENSIONS", 2),
("TEXT", 7),
]
for name, color in layer_defs:
await backend.layer_create(name, color=color)
@@ -67,8 +71,11 @@ async def test_template_architectural_layers(backend):
async def test_template_mechanical_layers(backend):
"""Mechanical template layers."""
layer_defs = [
("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3),
("DIMENSIONS", 2), ("SECTION", 5),
("VISIBLE", 7),
("HIDDEN", 1),
("CENTER", 3),
("DIMENSIONS", 2),
("SECTION", 5),
]
for name, color in layer_defs:
await backend.layer_create(name, color=color)
+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"
)

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