Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27071ab48 | ||
|
|
4e0f8a90fd | ||
|
|
b03a7615b1 | ||
|
|
d5a0ff5109 | ||
|
|
bae0750d4c | ||
|
|
af4813e7b6 | ||
|
|
47a90dbc54 | ||
|
|
d3e5fe4542 | ||
|
|
21cbc62df5 | ||
|
|
67f2c3b575 | ||
|
|
d15068efc5 | ||
|
|
e04c814cb8 | ||
|
|
3cbe30df54 | ||
|
|
6d7c39e012 | ||
|
|
2f56a73093 | ||
|
|
9ec6c4a989 |
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -1,259 +1,276 @@
|
||||
# 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.
|
||||
|
||||
[](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](LICENSE)
|
||||
[](pyproject.toml)
|
||||
[](https://github.com/jlowin/fastmcp)
|
||||
[](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
|
||||

|
||||
|
||||

|
||||
| # | 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).
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
### Geometry agents do not have to guess
|
||||
|
||||
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.
|
||||
|
||||
### Quality is a loop
|
||||
|
||||
`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.
|
||||
|
||||
### Delivery carries evidence
|
||||
|
||||
`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.
|
||||
|
||||
## Backend capabilities
|
||||
|
||||
| Capability | COM backend | ezdxf backend |
|
||||
|---|:---:|:---:|
|
||||
| Live AutoCAD document control | ✓ | — |
|
||||
| Headless DXF creation and editing | — | ✓ |
|
||||
| Cross-platform execution | — | ✓ |
|
||||
| Deterministic entity/query contract | ✓ | ✓ |
|
||||
| Transactions and rollback | ✓ | ✓ |
|
||||
| TABLE and MLEADER semantics | Native | Portable composite |
|
||||
| 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.
|
||||
|
||||
## 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
|
||||
→ drawing_critique → drawing_refine
|
||||
→ 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"autocad": {
|
||||
"command": "python",
|
||||
"args": ["C:\\path\\to\\Autocad-MCP\\server.py"],
|
||||
"env": {
|
||||
"AUTOCAD_MCP_BACKEND": "auto",
|
||||
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Local HTTP
|
||||
One runner, headless ezdxf lane. Competitors are driven **black-box over MCP
|
||||
stdio** using their own documented tool contracts at the pinned commit, and
|
||||
every geometry claim is verified by re-opening the exported DXF with ezdxf
|
||||
inside the harness — no self-reporting. `unsupported` scores zero within a
|
||||
server's own matrix; a capability refusal is recorded as `unsupported` rather
|
||||
than `fail`, because "cannot reach this" and "got this wrong" are different
|
||||
findings. Per-task reasons are committed under
|
||||
[`benchmarks/results/published/`](benchmarks/results/published/). File-IPC
|
||||
(live AutoCAD) lanes run locally, not in CI.
|
||||
|
||||
```bash
|
||||
fastmcp run server.py:mcp --transport http --port 8000
|
||||
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
|
||||
```
|
||||
|
||||
Loopback is the default. A non-loopback bind is refused unless remote HTTP is
|
||||
explicitly enabled and bearer-token authentication is configured.
|
||||
### 2 · Task matrix — where the score comes from
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
The v2 matrix scored this repository 10/10, and that was not a good sign —
|
||||
every task in it exercised something the server was built around, so full
|
||||
marks were the only possible outcome. **v3 adds five tasks chosen because they
|
||||
can fail**, and three of them did fail while being written: a hatch could not
|
||||
be measured at all, the boundary tools disagreed with the measurement tool
|
||||
about the same shape, and a circle stored as two bulged vertices measured zero.
|
||||
|
||||
| v3 task | What it pins | Verified against |
|
||||
|---|---|---|
|
||||
| `AUTOCAD_MCP_BACKEND` | `auto` | Select `auto`, `com`, or `ezdxf` |
|
||||
| `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 |
|
||||
| `ALLOW_REMOTE_HTTP` | `false` | Permit a non-loopback HTTP bind |
|
||||
| `MCP_AUTH_TOKEN` | empty | Bearer token required for remote HTTP |
|
||||
| `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 |
|
||||
|
||||
Backend selection:
|
||||
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.
|
||||
|
||||
| Value | Behavior |
|
||||
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
|
||||
|
||||

|
||||
|
||||
| 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 |
|
||||
|---|---|
|
||||
| `auto` | Try COM on Windows, then fall back to ezdxf |
|
||||
| `com` | Require a reachable live AutoCAD instance |
|
||||
| `ezdxf` | Force portable, headless DXF operation |
|
||||
| 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]
|
||||
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]
|
||||
@@ -262,52 +279,174 @@ flowchart LR
|
||||
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.
|
||||
- `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
|
||||
|
||||
| Capability | COM backend | ezdxf backend |
|
||||
|---|:---:|:---:|
|
||||
| Live AutoCAD document control | ✓ | — |
|
||||
| Headless DXF creation and editing | — | ✓ |
|
||||
| Cross-platform execution | — | ✓ |
|
||||
| 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 | — |
|
||||
|
||||
`system_capabilities` returns this machine-readably (`native`, `composite`,
|
||||
`rendered`, `snapshot`, `shared`, `unsupported`) — use it at runtime instead of
|
||||
assuming.
|
||||
|
||||
## A production drawing workflow
|
||||
|
||||
```text
|
||||
drawing_preflight → drawing_plan → drawing_apply_iso_layers
|
||||
→ deterministic create/edit tools → dimension_auto (+ fit="H7" callouts)
|
||||
→ drawing_critique → drawing_refine
|
||||
→ layout_create + viewport_create → drawing_finalize → drawing_deliver
|
||||
```
|
||||
|
||||
## Connect an MCP client
|
||||
|
||||
### Claude Desktop / Cursor / any stdio host
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"autocad": {
|
||||
"command": "autocad-mcp",
|
||||
"env": {
|
||||
"AUTOCAD_MCP_BACKEND": "auto",
|
||||
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD",
|
||||
"TOOL_PROFILE": "full"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
autocad-mcp --transport http --port 8000
|
||||
```
|
||||
|
||||
Loopback only by default; a non-loopback bind is refused unless remote HTTP is
|
||||
explicitly enabled **and** a bearer token is configured.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
`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:
|
||||
|
||||
@@ -316,25 +455,43 @@ 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, critique, refinement, delivery
|
||||
benchmarks/ Reproducible data, runners, and reports
|
||||
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,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
|
||||
|
||||
+67
-1462
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
+1745
-117
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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).
|
||||
"""
|
||||
...
|
||||
@@ -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: ...
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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}
|
||||
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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: ...
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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: ...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
+3130
-166
File diff suppressed because it is too large
Load Diff
+100
@@ -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
@@ -1,15 +1,17 @@
|
||||
# Benchmarks
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
r = subprocess.run(
|
||||
[PY, suite, name],
|
||||
cwd=repo,
|
||||
env={**os.environ, "PYTHONPATH": repo},
|
||||
capture_output=True, text=True, timeout=120)
|
||||
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']}%) "
|
||||
print(
|
||||
f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
|
||||
f"current: {npass}/{total} ({summary['new_pct']}%) "
|
||||
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
|
||||
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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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] = []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
+1276
-786
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
+43
-20
@@ -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,7 +62,8 @@ 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(
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(
|
||||
@@ -65,7 +71,8 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
||||
f"ISO 128 set {sorted(allowed)}."
|
||||
),
|
||||
detail={"layer": lyr.name, "lineweight": lw},
|
||||
))
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -81,7 +88,8 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
||||
if expected is None:
|
||||
continue # unknown layer — caller's responsibility
|
||||
if int(lyr.color) != int(expected):
|
||||
issues.append(Issue(
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="layer_color",
|
||||
message=(
|
||||
@@ -89,7 +97,8 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
||||
f"engineering standard expects {expected}."
|
||||
),
|
||||
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
||||
))
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -107,7 +116,8 @@ 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(
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
focus="construction_left",
|
||||
message=(
|
||||
@@ -115,9 +125,12 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
|
||||
"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})},
|
||||
)]
|
||||
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,7 +163,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
issues.append(Issue(
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="untrimmed_corner",
|
||||
message=(
|
||||
@@ -159,7 +173,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
),
|
||||
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(
|
||||
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(
|
||||
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,7 +286,8 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
||||
missing = sorted(referenced - defined)
|
||||
if not missing:
|
||||
return []
|
||||
return [Issue(
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
focus="gdt",
|
||||
message=(
|
||||
@@ -279,7 +299,8 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
||||
"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(
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)))
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
+85
-45
@@ -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.
|
||||
@@ -84,15 +89,15 @@ 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(
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"fake_dimension_text",
|
||||
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
|
||||
@@ -101,23 +106,30 @@ class DrawingValidator:
|
||||
"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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"no_path",
|
||||
"drawing_info.full_path is empty",
|
||||
))
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+95
-8
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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({
|
||||
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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Static fixtures for the test suite."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user