Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca1c79fd01 | ||
|
|
6b9860e68e | ||
|
|
e24b46d6f0 | ||
|
|
25f8737132 | ||
|
|
776fe5bcf1 | ||
|
|
a7c8a7a0b8 | ||
|
|
8f405bddf3 | ||
|
|
e27071ab48 | ||
|
|
4e0f8a90fd | ||
|
|
b03a7615b1 | ||
|
|
d5a0ff5109 | ||
|
|
bae0750d4c | ||
|
|
af4813e7b6 | ||
|
|
47a90dbc54 | ||
|
|
d3e5fe4542 | ||
|
|
21cbc62df5 | ||
|
|
67f2c3b575 | ||
|
|
d15068efc5 | ||
|
|
e04c814cb8 | ||
|
|
3cbe30df54 | ||
|
|
6d7c39e012 | ||
|
|
2f56a73093 | ||
|
|
9ec6c4a989 |
+77
-2
@@ -1,15 +1,45 @@
|
||||
# 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
|
||||
ALLOWED_PATHS=
|
||||
|
||||
# Maximum undo stack size (number of snapshots to keep)
|
||||
MAX_UNDO_STACK=50
|
||||
# Maximum undo stack size (number of snapshots to keep). The code's default is
|
||||
# 5; this file used to say 50, which was a documentation-only disagreement.
|
||||
MAX_UNDO_STACK=5
|
||||
|
||||
# Logging level: DEBUG | INFO | WARNING | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
@@ -17,3 +47,48 @@ 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
|
||||
|
||||
# --- Limits and remote access -------------------------------------------------
|
||||
# These four are read by config.py but were missing from this file, which made
|
||||
# it look like the complete list. Two of them are the remote-HTTP controls.
|
||||
|
||||
# Reject DXF input larger than this many bytes. 0 disables the check.
|
||||
MAX_DXF_BYTES=52428800
|
||||
|
||||
# Upper bound on list/selection response sizes.
|
||||
MAX_LIST_LIMIT=5000
|
||||
|
||||
# Permit a non-loopback HTTP bind. Requires MCP_AUTH_TOKEN as well.
|
||||
ALLOW_REMOTE_HTTP=false
|
||||
|
||||
# Bearer token required for remote HTTP. No default; remote binds refuse without it.
|
||||
MCP_AUTH_TOKEN=
|
||||
|
||||
@@ -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,115 @@
|
||||
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:
|
||||
# The same three lanes as ci.yml, not one. This job was a single
|
||||
# ubuntu/3.11 run, so a Windows-only defect could reach PyPI without any
|
||||
# Windows job having executed -- which is not hypothetical: v1.5.0 shipped
|
||||
# with `except pywintypes.com_error` raising NameError wherever pywin32 is
|
||||
# absent, and only the Linux legs of ci.yml caught it.
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
extra: pdf
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
extra: pdf
|
||||
- os: windows-latest
|
||||
python-version: "3.11"
|
||||
extra: full
|
||||
runs-on: ${{ matrix.os }}
|
||||
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
|
||||
# `--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 ${{ matrix.extra }} --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.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# The ruff version here MUST equal the `ruff==` pin in pyproject.toml's
|
||||
# [dependency-groups] dev. It was v0.8.6 against a project pin of 0.16.1, and
|
||||
# ruff 0.16 formats Python inside Markdown while 0.8 does not -- so anyone who
|
||||
# ran `pre-commit install` produced commits that failed CI's
|
||||
# `ruff format --check .`. tests/test_release_consistency.py pins them equal.
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.6
|
||||
rev: v0.16.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
+890
-2
@@ -5,9 +5,897 @@ 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.1] — 2026-08-06
|
||||
|
||||
_Next: capability-group schema routing, paper-space/layout production depth, and ISO 286 fit-table lookup._
|
||||
A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the
|
||||
adversarial pass over the rewritten README. 1.5.0 is yanked: its diameter and
|
||||
radius callouts put wrong numbers on drawings at default settings, and that is
|
||||
not something to leave installable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The HTTP bind guard failed open on the launch path it was written for.**
|
||||
`_guarded_run_async` read the bind address from the `host` kwarg and treated
|
||||
an absent one as loopback. It is not: `fastmcp run` forwards that kwarg only
|
||||
when `--host` was passed, and otherwise fastmcp resolves the host from
|
||||
`fastmcp.settings.host`, which is environment-backed. So
|
||||
`FASTMCP_HOST=0.0.0.0 fastmcp run server.py:mcp --transport http` bound every
|
||||
interface — 149 tools including file open/save and AutoCAD command execution,
|
||||
with no token — while the guard inspected the literal string `"127.0.0.1"`
|
||||
and waved it through. v1.4.0's changelog claimed the guard "now fires on
|
||||
every launch path"; it fired, and then looked at the wrong host. It now
|
||||
resolves the effective host, and says so in a warning if it cannot.
|
||||
- **`[com]` and `[full]` could not be installed off Windows.** Both extras
|
||||
listed `pywin32>=306` with no environment marker, and pywin32 publishes
|
||||
Windows wheels and *no sdist* — so `pip install autocad-mcp-pro[full]` died
|
||||
at resolution on Linux and macOS, and `[full]` is the extra this project's
|
||||
own README recommended to contributors. CI never caught it because every
|
||||
Linux lane installs `[pdf]` or syncs from `uv.lock`, whose resolution markers
|
||||
hide the gap. Both entries now carry `; sys_platform == "win32"`, and a
|
||||
release-consistency test fails if a Windows-only distribution loses its
|
||||
marker again.
|
||||
- **`drawing_settings({"dimscale": ...})` reported success and changed nothing
|
||||
headlessly.** It writes the `$DIMSCALE` header variable, which is what
|
||||
AutoCAD consults when it draws a dimension — so the call worked on the live
|
||||
backend. ezdxf renders from the dimstyle table entry and never reads the
|
||||
header, so the same call produced two different drawings on the two engines
|
||||
while both said `ok`. The header value is now folded into the per-dimension
|
||||
override on all five dimension tools, including `dimension_aligned` and
|
||||
`dimension_angular`, which do not share the tolerance-override path and were
|
||||
missed by the obvious fix. DIMSCALE 1.0 writes no override.
|
||||
|
||||
### Changed
|
||||
|
||||
- README: the "What you get" table qualified every engine-conditional family
|
||||
(boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch edge
|
||||
paths, headless undo) instead of listing them as if both engines had them,
|
||||
and the checkout install line no longer recommends an extra that cannot
|
||||
resolve on the reader's platform.
|
||||
|
||||
## [1.5.0] — 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Selection filters — 3 tools.** `selection_window` (AutoCAD's ssget
|
||||
window/crossing), `selection_polygon` and `selection_filter` (QSELECT).
|
||||
Window returns only entities wholly inside; crossing also returns the ones
|
||||
straddling the edge — the modes are reported back and `window` is the
|
||||
default, because the over-selecting mode must never be the implicit one. A
|
||||
zero-area box is refused rather than answered with an empty list, which would
|
||||
read as "nothing there".
|
||||
`selection_filter` takes named parameters rather than a query string
|
||||
deliberately: ezdxf's query language answers an unknown attribute with an
|
||||
empty result, so `LINE[nosuchattr=='x']` is indistinguishable from "no
|
||||
matches". Here a typo is a `TypeError` at the boundary, and `filtered_by`
|
||||
reports which filters actually ran. `min_area` excludes shapes that have no
|
||||
area at all rather than treating them as zero, and a negative `min_area` is
|
||||
refused — no area is negative.
|
||||
- **Boundary tracing — 2 tools, and five deliberately not built.**
|
||||
`boundary_trace` is AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest
|
||||
enclosing closed polyline out. It never calls `edgeminer.find_all_loops` —
|
||||
measured at **63.9 s for 40 edges and a TimeoutError at 60**, because the
|
||||
docstring's O(n!) is real — and walks one loop per seed-adjacent edge
|
||||
instead, which is 0.0057 s at the same size. Straight edges are split where
|
||||
they cross, so a construction line drawn across a shape actually divides it;
|
||||
without that the line shares no endpoint with anything and is invisible to
|
||||
the edge graph. Curved edges are not split, and the capability says so.
|
||||
Two things the obvious implementation gets silently wrong, both now tested:
|
||||
`edgesmith.lwpolyline_from_chain` emits n+1 points with the last duplicating
|
||||
the first and `closed` left False — a polyline that draws like a square and
|
||||
behaves like an open path to hatch association, area queries and
|
||||
`entity_offset`; and `edgesmith.loop_area` works off edge endpoints, so two
|
||||
semicircular arcs closing into a circle measure **0.0** against a true
|
||||
314.159. The area now comes from the flattened curve and each curved edge
|
||||
keeps its bulge.
|
||||
**Not built: every REGION and 2D-boolean tool the plan listed.** ezdxf cannot
|
||||
author a REGION — `add_region()` returns one with zero ACIS bytes — and the
|
||||
substitute, `greiner_hormann`, takes flat vertex lists. Measured on a square
|
||||
with one edge bulged into a semicircle, routing it through that boolean loses
|
||||
**28.2% of the area and 12.5% of the perimeter**. That is the same magnitude
|
||||
as the `analysis_measure_area` bug this release fixed, and shipping it would
|
||||
have re-introduced the class.
|
||||
- **Hatch depth — 3 tools.** `hatch_set_gradient`, `hatch_edit` and
|
||||
`hatch_add_boundary`. `hatch_edit` leaves omitted parameters alone — a
|
||||
partial edit that resets the rest is data loss — and `changed` reports the
|
||||
attributes that actually *moved*, so re-setting a value to what it already
|
||||
was comes back empty rather than as a false positive. `hatch_add_boundary`
|
||||
takes typed edges (line/arc/ellipse) because a boundary API that only accepts
|
||||
vertex lists silently straightens every curve it is given; every edge is
|
||||
validated before any is written, so a malformed list refuses instead of
|
||||
leaving a half-built path. On COM it refuses with
|
||||
`capability: "hatch_edge_paths"`: ActiveX appends boundary loops as existing
|
||||
objects, not as typed edges, so the fidelity this tool exists for cannot be
|
||||
expressed there.
|
||||
- **`analysis_list_properties` — AutoCAD's LIST.** The raw DXF attribute set
|
||||
for one handle, which `entity_get` deliberately does not carry. It cannot be
|
||||
handed over as-is for two reasons, both fixed here: `dxfattribs()` holds
|
||||
`Vec3` values and is not JSON-serialisable, and its coordinates are in the
|
||||
entity's own frame, so for anything with an extrusion they would contradict
|
||||
every other coordinate this server reports.
|
||||
- **Annotation objects — 4 tools.** `entity_create_wipeout` (mask what is
|
||||
behind a closed polygon), `entity_create_revcloud`, `text_set_background`
|
||||
(MTEXT background mask) and `text_find_replace`. Each carries a refusal that
|
||||
was measured rather than assumed. A wipeout of fewer than three points
|
||||
encloses no area, so it is refused instead of returning a handle to a mask
|
||||
that masks nothing. A revision cloud *is* its arcs — `ezdxf.revcloud`'s own
|
||||
helper stamps the `RevcloudProps` xdata and takes the bulge sign from the
|
||||
winding direction, so the result satisfies `is_revcloud()` — and a
|
||||
`segment_length` longer than the shortest edge produces no arcs at all, which
|
||||
would be a plain rectangle reported as a cloud; that case is refused.
|
||||
`text_set_background` refuses TEXT (only MTEXT has a background-fill
|
||||
attribute, so setting one on TEXT would report success and change nothing)
|
||||
and refuses a scale below 1.0 (a box smaller than its text is a stripe
|
||||
through the text).
|
||||
`text_find_replace` searches TEXT, MTEXT, ATTRIB **and** ATTDEF, including
|
||||
inside block definitions — leaving the ATTDEF behind would fix the drawing
|
||||
and reintroduce the old text on the next insert. It returns
|
||||
`searched_types`, because "no matches" and "that type was never searched" are
|
||||
different answers and a caller cannot otherwise tell them apart. DIMENSION
|
||||
text is deliberately out of scope: its text field holds the `<>` override
|
||||
placeholder rather than the measurement, so editing it would either do
|
||||
nothing or detach the dimension from what it measures — the response says so.
|
||||
On COM, `entity_create_revcloud` refuses with `capability: "revcloud"`:
|
||||
ActiveX has no revision-cloud member and driving the command blind against a
|
||||
live drawing is unverified.
|
||||
- **The rest of the paper-space lifecycle — 8 tools.** v1.4 could make a sheet
|
||||
and never unmake it. Added `layout_delete`, `layout_rename`, `layout_copy`,
|
||||
`viewport_list`, `viewport_set_scale`, `viewport_lock`, `viewport_delete` and
|
||||
`entity_change_space` (CHSPACE). Verified headless (ezdxf); the COM paths for
|
||||
the first seven are implemented against the ActiveX reference but **never
|
||||
executed against a live AutoCAD**, and each is marked so in the source.
|
||||
Most of the work went into refusals, because ezdxf's own guards stop short of
|
||||
where a tool boundary has to: `layouts.delete("")` deletes the *first* sheet
|
||||
rather than refusing, `layouts.get("")` resolves to whichever is active, and
|
||||
`layouts.rename()` skips the name validation `new()` performs, so `Bad/Name`
|
||||
and even `""` land in the table and the file still audits clean. Deleting or
|
||||
renaming the current tab used to leave three different answers to "where does
|
||||
my next line go?" — `layout_list` said one thing, `$TILEMODE` another, and
|
||||
`_msp()` quietly wrote to model space; all three now agree. A handle from a
|
||||
deleted sheet refuses instead of dying with `AttributeError` (`entitydb.get`
|
||||
is documented not to filter destroyed entities, and `_get_entity` believed
|
||||
it).
|
||||
`layout_copy` is a construction — ezdxf has no layout-copy API and
|
||||
`xref.load_paperspace` is cross-document only — and every way it can go wrong
|
||||
is invisible to the Auditor, which reports zero errors on a duplicated tab
|
||||
order, two main viewports, and a hatch whose boundary still points into the
|
||||
layout it was copied from and dangles the moment that layout is deleted. All
|
||||
three are prevented structurally; `skipped` and `associativity_dropped` report
|
||||
what could not be carried over rather than letting a bare `ok: true` imply a
|
||||
complete copy.
|
||||
`viewport_list` reports the layout's own main viewport with `is_main: true`
|
||||
rather than hiding it — it is what remains after every drafting viewport is
|
||||
deleted — and refuses to report a scale on R12, where `view_height` and
|
||||
`flags` are dropped on export and `get_scale()` then returns a fabricated
|
||||
number (150.0 measured for a viewport storing no scale at all). The two
|
||||
writers refuse on R12 outright rather than reporting a success the file
|
||||
cannot keep, and `viewport_lock` read-modify-writes the flags bitfield, which
|
||||
also carries the UCS-icon and grid bits a bare assignment would silently drop.
|
||||
- **`entity_change_space` (CHSPACE), headless only.** AutoCAD moves objects
|
||||
*through* a viewport, rescaling them so they look identical on screen.
|
||||
ezdxf's `move_to_layout` applies no geometric change whatsoever, so the
|
||||
obvious implementation reports success, turns a 100 mm feature into 100 mm of
|
||||
paper inside a 1:2 viewport, and writes a perfectly clean file. The transform
|
||||
is therefore the tool, and it uses `viewport.get_transformation_matrix()`
|
||||
rather than a hand-rolled one: the hand-rolled version ignores
|
||||
`view_target_point` and misplaced geometry by 25 mm in testing while
|
||||
`is_top_view` was still true and the twist angle still zero — past every guard
|
||||
one would naturally write. A regression test pins the result against what
|
||||
ezdxf's own rendering pipeline projects.
|
||||
It refuses rather than guessing: non-plan and twisted viewports (ezdxf returns
|
||||
a meaningless matrix for the first and rotates about the paper origin for the
|
||||
second), dimensions unless `freeze_dimensions=true` bakes the measurement in
|
||||
first (`transform` halves `get_measurement()` while the baked block text keeps
|
||||
the old value, so one of the two is always lying), ACIS bodies (`transform`
|
||||
is accepted and moves nothing), tables and proxies (`transform` raises, and
|
||||
`hasattr(e, "transform")` is `True` for all of them), viewports themselves,
|
||||
and entities already in the target space. Geometry that lands outside the
|
||||
viewport or off the sheet is moved and **flagged** (`inside_viewport`,
|
||||
`on_sheet`), not refused — AutoCAD allows it too.
|
||||
On the COM backend this refuses with `capability: "chspace"`: ActiveX has no
|
||||
change-space member, so the alternatives are `CopyObjects` + delete or driving
|
||||
the command blind, and neither could be verified against a live seat.
|
||||
- **`CAD_PROGID` — the COM ProgID is configurable.** `"AutoCAD.Application"`
|
||||
was hardcoded at both connection sites, so AutoCAD-compatible applications
|
||||
(BricsCAD, ZWCAD, GstarCAD) were unreachable even though they expose the same
|
||||
ActiveX object model. The setting is honoured on *both* paths deliberately:
|
||||
`Dispatch` is not a passive probe — it COM-launches the application and sets
|
||||
`Visible = True` — so honouring the setting on `GetActiveObject` and then
|
||||
falling back would start the very AutoCAD the operator said they were not
|
||||
using. An unrecognised ProgID fails loudly for the same reason, and the error
|
||||
names both the ProgID it tried and the variable to change. `system_status`
|
||||
now reports `cad_progid`, without which a non-AutoCAD operator reads an
|
||||
`autocad_version` key naming a product they do not own.
|
||||
**Caveat, stated everywhere it appears:** `CAD_PROGID` only changes which COM
|
||||
application the backend attaches to — every tool beyond the connection itself
|
||||
is developed and tested against AutoCAD alone, and clone compatibility is
|
||||
unverified, so treat a non-default ProgID as experimental. The candidate
|
||||
ProgIDs in `.env.example` are commented out and marked UNVERIFIED: none has
|
||||
been tested against a running seat.
|
||||
|
||||
- **`analysis_measure_entity` — measure the drawing, not your memory of it.**
|
||||
Neither backend could answer "what is the area of the polyline I just drew":
|
||||
`analysis_measure_area` shoelaced points the *caller* typed in, and the live
|
||||
COM backend did the same despite having the document, a `HandleToObject`
|
||||
lookup it already used fourteen times, and an ActiveX `Area` property. The
|
||||
only available workflow was also silently wrong — `entity_get` read LWPOLYLINE
|
||||
vertices through the default format and kept x and y, dropping the **bulge**
|
||||
that makes an edge an arc. Measured on a 100×100 square with one edge bulged
|
||||
into a semicircle: 10000 reported against a true 13926.9908, **28.2% low**,
|
||||
perimeter **12.5% low**, nothing in the response saying so. And
|
||||
`props["length"] = ent.length()` was dead code — `LWPolyline` has no
|
||||
`length()`, so the key never appeared at all.
|
||||
Bulge geometry is always circular, so the new `engineering/measure.py` is
|
||||
exact rather than approximate: shoelace plus each arc's signed circular
|
||||
segment, and arc length for perimeter. `entity_get` now reports `area` and
|
||||
`length` from the same maths. The payload states its own accuracy — `exact`,
|
||||
`flatten_tolerance`, `assumed_closed` (an open boundary is closed the way
|
||||
AutoCAD's AREA does), and `self_intersecting`, because the shoelace *cancels*
|
||||
crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse with
|
||||
`capability: "measure_area_acis"` naming the live backend; LINE/TEXT/INSERT
|
||||
raise a plain error, since no engine can give them an area and a capability
|
||||
tag would falsely suggest switching helps. `AREA`/`MEASUREGEOM` now route here
|
||||
rather than at the points-based tool — a drafter typing AREA picks objects,
|
||||
they do not retype coordinates.
|
||||
- **Handle grounding: `view_screenshot(overlay_handles=true)`.** A screenshot
|
||||
showed the model what the drawing looks like and nothing it could act on —
|
||||
every modify tool takes a handle, and nothing connected "the circle at the
|
||||
top-left" to a hex string. Each entity is now labelled with its handle at its
|
||||
own bounding-box centre (taken from the renderer's extents, so the label lands
|
||||
where the entity is *drawn* even in a rotated frame). Off by default, because
|
||||
labels are ink on the drawing; past 40 entities the render is capped and the
|
||||
image itself says "handles shown for N of M" rather than letting a labelled
|
||||
subset read as the whole sheet. The live backend captures the AutoCAD window
|
||||
rather than rendering, so there is nothing to label there — it refuses with
|
||||
`capability: "handle_overlay"` instead of quietly returning an unlabelled
|
||||
image, since producing the overlay would mean creating and deleting entities
|
||||
in the user's open drawing.
|
||||
- **`block_create_from_entities` works on the live backend.** It refused
|
||||
outright and pointed at `system_run_command` with `_BLOCK`, pushing callers
|
||||
through the free-text escape hatch for something ActiveX does directly
|
||||
(`Blocks.Add` + `Document.CopyObjects`). The tool's own docstring said the
|
||||
opposite — "works by using AutoCAD's BLOCK command (COM backend only)" — so
|
||||
the one engine that could not do it was advertised as the one that could.
|
||||
Both engines now also report `skipped` handles instead of swallowing them, and
|
||||
a call where nothing resolves fails rather than leaving an empty definition
|
||||
behind. COM path unit-tested against a fake ActiveX object, not
|
||||
hardware-verified.
|
||||
- **Undo and redo on the headless backend (`EZDXF_UNDO_DEPTH`, default 0).**
|
||||
The plan called this "add redo". The larger half was that **undo never worked
|
||||
either**: `_undo_stack` was declared, read and cleaned up, but nothing ever
|
||||
appended to it, so `drawing_undo` could only answer "Nothing to undo" — while
|
||||
the tool summary said "Step back one operation" and the alias corpus routed
|
||||
`UNDO` and "take that back" straight at it. The old test asserted
|
||||
`isinstance(result, dict)`, which a permanent failure satisfies.
|
||||
It is opt-in because it is not free. ezdxf keeps no journal, so a history step
|
||||
is a whole DXF snapshot: measured 3.1 ms / 19 KB at 10 entities but 130 ms /
|
||||
658 KB at 5000, and switching it on costs **37×** on entity creation (0.18 →
|
||||
6.65 ms per call) even on a small drawing. Off, it costs nothing; on, you get
|
||||
real multi-step undo/redo. Both tools refuse with
|
||||
`capability: "undo_history"` naming the setting rather than pretending. The
|
||||
live COM backend uses AutoCAD's own undo and ignores it. Drawing after an undo
|
||||
discards the redo branch, as in AutoCAD — otherwise redo would restore a state
|
||||
that never existed, with geometry you had removed reappearing beside geometry
|
||||
you drew afterwards.
|
||||
- **BREAKING (behavior): geometry now goes into the layout you selected.**
|
||||
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current":
|
||||
"A3-Sheet"}` and then every entity created afterwards landed in **model
|
||||
space** anyway, on both engines — `_msp()` returned model space
|
||||
unconditionally, so the tool reported a state change that changed nothing.
|
||||
Proven: a line drawn straight after selecting a layout was in modelspace and
|
||||
not in the layout. That was also why a title block could not go on a sheet, so
|
||||
`titleblock_apply_iso_a3` gains a `layout` parameter — where a title block
|
||||
belongs, since the border frames the printed sheet rather than the model — and
|
||||
restores your current space afterwards rather than leaving you on the sheet.
|
||||
Existing model-space workflows are unaffected: a fresh drawing starts in model
|
||||
space, and a refused layout switch leaves the current space where it was.
|
||||
- **ISO 286 transition and interference holes (K, M, N, P).** `fit="P7"` used to
|
||||
raise by name. They are now derived by the standard's delta rule,
|
||||
`ES = -ei + Δ` with `Δ = IT(n) − IT(n−1)` — a *rule* over the shaft and IT
|
||||
tables already in the module, not new table data, which is why it can be
|
||||
pinned against published values (K7/M7/N7/P7 and K6/N6/P6 cross-checked by
|
||||
hand) instead of merely trusted. Unlocks the hole-basis pairs K7/h6, M7/h6,
|
||||
N7/h6, P7/h6.
|
||||
- **ISO 286 interference shafts (r, s, t, u).** `fit="s6"` and `fit="u6"` — the
|
||||
standard press and drive fits — used to raise. Unlike K/M/N/P these *are* new
|
||||
table data, sub-stepped more finely than the main size steps (ISO 286 splits
|
||||
18–30 into 18–24/24–30, 30–50 into 30–40/40–50, and every step above 50 mm in
|
||||
two or three; flattening that is a silent error of tens of microns at exactly
|
||||
the sizes press fits are used at). Because it is data rather than a rule, its
|
||||
**provenance is tested**: every value is cross-checked against ISO 286-1's own
|
||||
derivation formulae, an independent source. `r` reproduces as the geometric
|
||||
mean of `p` and `s` on **24/24** steps — with `p` pinned in this module before
|
||||
`r` existed — and `s`/`t`/`u` track their IT7-based formulae within 1 µm on
|
||||
every step from 18 mm up. The five places where the standard's rounding puts a
|
||||
published value 2–3 µm off its own formula are listed by name in the tests, so
|
||||
a reader holding ISO 286 checks five numbers rather than ninety-five. Shaft `t`
|
||||
is undefined below 24 mm in the standard and refuses there rather than being
|
||||
extrapolated. Unlocks H7/r6, H7/s6, H7/t6 and H7/u6.
|
||||
- **Headless performance lane** (`benchmarks/perf_suite.py` +
|
||||
`render_perf_chart.py`): fixed wall-time workloads (2k individual creates,
|
||||
10k build/export/reopen roundtrip, 10k region query, full premium quality
|
||||
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.
|
||||
- Five checks added to `correctness_suite.py` (26, was 21), and the two kinds
|
||||
read differently in the A/B table. Three measurement checks are new
|
||||
capability — v1.4.0 misses them by not having the method (`miss → pass`) —
|
||||
and are there because all three broke during this release. Two dimension
|
||||
checks are repaired defects: v1.4.0 has the methods and gets them wrong
|
||||
(`fail → pass`). Result against v1.4.0: 21/26 → 26/26, five fixed, zero
|
||||
regressed.
|
||||
|
||||
### 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.
|
||||
|
||||
- **Every headless diameter and radius dimension reported the wrong number, at
|
||||
default settings.** `add_diameter_dim` / `add_radius_dim` measure to the point
|
||||
they are given; `leader_length` is a *text placement*. Passing
|
||||
`centre + (radius + leader)` as that point made the leader part of the
|
||||
measurement, so a true ⌀40 bore was dimensioned **60** with the default
|
||||
`leader_length=10`, and **108** at 34. Radius callouts were `leader_length`
|
||||
too large the same way. This is the worst class of defect for this project —
|
||||
a wrong number, silently, on the one part of a drawing that exists to carry
|
||||
numbers — and it shipped in v1.4.0 and earlier. Now the size comes from
|
||||
`radius` and `location` only places the text.
|
||||
The live COM backend was never affected: ActiveX's `AddDimDiametric` takes
|
||||
the two chord points and the leader length as separate arguments, so the two
|
||||
engines had been disagreeing by `2 × leader_length` on the same call.
|
||||
Found while building the sheet in the README's hero image, whose own bore
|
||||
callout was wrong. Two checks added to `correctness_suite.py` (26, was 24);
|
||||
both report `fail → pass` against v1.4.0 rather than `miss → pass`, which is
|
||||
what distinguishes a repaired defect from new capability in that lane.
|
||||
- **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` (26 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,348 +1,420 @@
|
||||
<div align="center">
|
||||
|
||||
# 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 — one typed contract, two engines.
|
||||
|
||||
[](LICENSE)
|
||||
[](pyproject.toml)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/blob/main/pyproject.toml)
|
||||
[](https://github.com/jlowin/fastmcp)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
|
||||
[](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.
|
||||
[Install](https://github.com/U-C4N/Autocad-MCP#install-in-10-seconds) · [Evidence](https://github.com/U-C4N/Autocad-MCP#evidence) · [Two engines](https://github.com/U-C4N/Autocad-MCP#the-two-engines) · [Limits](https://github.com/U-C4N/Autocad-MCP#what-this-release-is-bad-at) · [Config](https://github.com/U-C4N/Autocad-MCP#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
|
||||
|
||||
> **v1.3 release snapshot:** 122 tools · 6 resources · 5 prompt templates ·
|
||||
> 415 collected tests. Runtime discovery through `system_about` is authoritative.
|
||||
</div>
|
||||
|
||||
## Start in 60 seconds
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-showcase.png" alt="ISO A3 sheet: a module-6, 24-tooth spur gear with a keyed bore, its section A-A, ISO 129 dimensions with an H7 bore fit, and an ISO 7200 title block" width="698">
|
||||
|
||||
<sub><b>Not a mockup.</b> Every line on this sheet was drawn by the tools this server exposes — ISO layers, involute gear geometry, DIN 6885 keyway, section A-A, ISO 129 dimensions, an ISO 286 <code>H7</code> bore fit, ISO 7200 title block — then rendered headlessly by <code>view_screenshot</code>. <code>drawing_critique</code> returns <b>0 issues</b> on it. Rebuild it with <code>python scripts/render_readme_showcase.py</code>.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1369 collected tests.
|
||||
> 154 is the **registered** count. A default install **advertises 149** over `tools/list`, because
|
||||
> `ENABLE_3D` is unset and the five `solid_*` tools stay hidden. `system_about` is the runtime authority.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Two problems, both measured before a line was written.
|
||||
|
||||
**A big MCP server is expensive to be connected to.** The full catalog puts an estimated **40,305 tokens** on the wire before the client has asked for anything, of which about **25,143** are the part a client actually forwards to the model — 149 advertised tools at ~169 tokens of name, description and input schema each. Turning on discovery replaces that catalog with two tools and **356** wire tokens.
|
||||
|
||||
**A drafter searches for `WBLOCK`, not for `block_create_from_entities`.** `WBLOCK`, `PEDIT`, `OVERKILL`, `LAYTRANS` and `MATCHPROP` appeared in *no* tool name or description, so they scored `df = 0` against a stock text index — not badly ranked, *absent*. No amount of tuning finds a word that is not in the data, so the fix was data: an authored corpus of **158 distinct AutoCAD command names and 672 synonym phrases** covering all 154 tools, layered onto the index. A test holds that df=0 list honest in both directions — it fails if one of those commands becomes findable without the corpus, and it fails if the corpus stops covering one.
|
||||
|
||||
| Advertised surface | Tools a client sees | `tools/list` on the wire |
|
||||
|---|---:|---:|
|
||||
| `TOOL_PROFILE=full` (default) | 149 | 40,305 tokens |
|
||||
| `TOOL_PROFILE=lean` | 47 | 12,131 tokens |
|
||||
| `DISCOVERY_MODE=search` | 2 | **356 tokens** |
|
||||
|
||||
> [!NOTE]
|
||||
> Token figures are **estimates** from an offline ratio counter (`ratio/v1`), not tokenizer counts — characters are the measured unit and the report says so. Under prompt caching the idle term is largely amortised, so read **113×** as the *uncached* wire ratio, not a bill. `python -m benchmarks.token_suite --tokenizer anthropic` counts for real, but it needs the `anthropic` package and credentials, neither of which is in this repo's lock — it refuses rather than silently falling back.
|
||||
|
||||
## 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 anywhere, 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, no AutoCAD needed
|
||||
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:
|
||||
|
||||
```powershell
|
||||
pip install -e ".[com]"
|
||||
$env:AUTOCAD_MCP_BACKEND = "com"
|
||||
python server.py
|
||||
```
|
||||
|
||||
No AutoCAD installation is needed for the ezdxf backend:
|
||||
|
||||
```powershell
|
||||
$env:AUTOCAD_MCP_BACKEND = "ezdxf"
|
||||
python server.py
|
||||
```
|
||||
|
||||
## Evidence, not adjectives
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
| 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:
|
||||
|
||||
```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.
|
||||
|
||||
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
|
||||
|
||||
### One contract, two engines
|
||||
|
||||
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 |
|
||||
| Extra | Pulls in | For |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| *(none)* | `fastmcp`, `ezdxf`, `pydantic` | Headless DXF on any OS — **no rendering** |
|
||||
| `[com]` | `pywin32`, `Pillow` | Live AutoCAD control + window capture |
|
||||
| `[pdf]` | `matplotlib` | PDF export and headless PNG rendering, any platform |
|
||||
| `[full]` | everything above | Live AutoCAD and rendering in one install |
|
||||
|
||||
Requirements:
|
||||
> [!NOTE]
|
||||
> The bare 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 just by the COM window capture it is listed under. Add `[pdf]` (or `[full]`) if you want images on Linux or macOS.
|
||||
|
||||
- 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
|
||||
From a checkout: `pip install -e ".[full]"` on Windows, or `pip install -e ".[pdf]"` elsewhere, then `python server.py`. (`[com]` and `[full]` carry a `sys_platform == "win32"` marker on `pywin32`, so they resolve everywhere — they simply install nothing Windows-specific off Windows.)
|
||||
|
||||
## Connect an MCP client
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add a server entry to `claude_desktop_config.json` and use the absolute path to
|
||||
your checkout:
|
||||
### Wire it to a client
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"autocad": {
|
||||
"command": "python",
|
||||
"args": ["C:\\path\\to\\Autocad-MCP\\server.py"],
|
||||
"command": "autocad-mcp",
|
||||
"env": {
|
||||
"AUTOCAD_MCP_BACKEND": "auto",
|
||||
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD"
|
||||
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD",
|
||||
"TOOL_PROFILE": "full",
|
||||
"DISCOVERY_MODE": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Works with Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transport http --port 8000` — loopback only unless remote HTTP is explicitly enabled **and** a bearer token is set. The model vendor is not part of the server contract.
|
||||
|
||||
### Local HTTP
|
||||
## What you get
|
||||
|
||||
```bash
|
||||
fastmcp run server.py:mcp --transport http --port 8000
|
||||
```
|
||||
<table>
|
||||
<tr><th align="left">Area</th><th align="left">What it does</th></tr>
|
||||
<tr><td><b>Drawing lifecycle</b></td><td>create, open, save, export DXF/PDF, audit <i>(now repairs)</i>, purge; undo/redo live on COM and opt-in headless via <code>EZDXF_UNDO_DEPTH</code></td></tr>
|
||||
<tr><td><b>Geometry</b></td><td>lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits</td></tr>
|
||||
<tr><td><b>Annotation</b></td><td>ISO 129 toleranced dimensions, ISO 286 fits (<code>fit="H7"</code>), TABLE, MLEADER, GD&T frames and datums (ISO 1101)</td></tr>
|
||||
<tr><td><b>Engineering generators</b></td><td>involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block</td></tr>
|
||||
<tr><td><b>Discovery</b> ✨</td><td><code>search_tools</code> ranked over an AutoCAD command and synonym corpus — <code>FILLET</code>, <code>BPOLY</code>, <code>QSELECT</code>, <code>WBLOCK</code>, <code>OVERKILL</code>, <code>CHSPACE</code> each rank <b>#1</b> of the 154-tool registry</td></tr>
|
||||
<tr><td><b>Batching</b> ✨</td><td><code>cad_batch</code> runs a whole step list in one round trip; <code>fields=</code> projects the response on 11 result-heavy tools (a closed, gated list)</td></tr>
|
||||
<tr><td><b>Paper space</b> ✨</td><td>8 of the 12 layout tools are new — <code>layout_delete/rename/copy</code>, <code>viewport_list/set_scale/lock/delete</code>, <code>entity_change_space</code> (CHSPACE, headless only); layout listing, creation and activation, <code>viewport_create</code> and <code>drawing_export_pdf(layout=...)</code> shipped in v1.4.0, which wrongly declared headless viewport rendering impossible</td></tr>
|
||||
<tr><td><b>Selection</b> ✨</td><td>window vs crossing stated back to the caller; the polygon is tested as a shape, not as its bounding box — the entity side is still <i>its</i> bounding box, on both engines</td></tr>
|
||||
<tr><td><b>Boundaries</b> ✨</td><td><code>boundary_trace</code> (BOUNDARY/BPOLY) chains loose edges into one closed polyline; ARC edges keep their exact bulges, while a bulged polyline segment or any other curved edge is chained as its chord — <i>headless engine only</i></td></tr>
|
||||
<tr><td><b>Measurement</b> ✨</td><td><code>analysis_measure_entity</code> measures what is <i>in</i> the drawing, by handle</td></tr>
|
||||
<tr><td><b>Hatch depth</b> ✨</td><td>gradients, in-place edits and island styles on both; typed edge boundaries headless only</td></tr>
|
||||
<tr><td><b>Annotation objects</b> ✨</td><td>WIPEOUT, REVCLOUD, MTEXT background masks — <i>headless only, ActiveX exposes no member</i> — plus text find/replace on both</td></tr>
|
||||
<tr><td><b>3D solids</b></td><td><code>solid_box/cylinder/extrude/revolve/boolean</code> on live AutoCAD (<code>ENABLE_3D=true</code>) — <i>unverified against a live seat: they shipped in v1.4.0, and the v1.5.0 live run covered only what that release added</i></td></tr>
|
||||
<tr><td><b>Quality loop</b></td><td><code>drawing_preflight</code> → <code>drawing_plan</code> → <code>drawing_critique</code> → <code>drawing_refine</code> → <code>drawing_finalize</code> (0–100 score)</td></tr>
|
||||
<tr><td><b>Delivery</b></td><td><code>drawing_deliver</code>: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks</td></tr>
|
||||
</table>
|
||||
|
||||
Loopback is the default. A non-loopback bind is refused unless remote HTTP is
|
||||
explicitly enabled and bearer-token authentication is configured.
|
||||
<sub>✨ = new in v1.5.0. 154 tools across 19 groups — <code>entity_creation</code> 18, <code>entity_modification</code> 15, <code>layers</code> 14, <code>analysis</code> 12, <code>layouts</code> 12, <code>premium</code> 12, <code>drawing</code> 11, <code>engineering</code> 10, <code>blocks</code> 8, <code>entity_query</code> 8, <code>system</code> 7, <code>dimensions</code> 5, <code>solids</code> 5, <code>corner_ops</code> 4, <code>view</code> 4, <code>batch</code> 3, <code>transactions</code> 3, <code>templates</code> 2, <code>validation</code> 1.</sub>
|
||||
|
||||
Alongside the tools, **6 MCP resources** cost nothing in the tool budget and are browsable at any time — `autocad://drawing/info`, `autocad://layers`, `autocad://blocks`, `autocad://entities/stats`, `autocad://entities/{layer_name}`, `autocad://system/status` — plus **5 prompt templates** (`prompt_mechanical_drawing`, `prompt_floor_plan`, `prompt_electrical_schematic`, `prompt_pid_diagram`, `prompt_quick_drawing`).
|
||||
|
||||
### Two rules worth knowing before you draw
|
||||
|
||||
**Coordinates come out in WCS, and the exceptions are declared rather than hidden.** DXF stores CIRCLE / ARC / LWPOLYLINE / TEXT / INSERT geometry in a per-entity frame, and before v1.5.0 the word `extrusion` appeared nowhere in this codebase, so a mirrored or rotated entity reported coordinates that were quietly wrong. An entity in a plane tilted out of WCS XY now reports `plane_normal` and omits the fields xy cannot express, rather than answering with a number that looks fine.
|
||||
|
||||
Three things stay in the entity frame on purpose, and `system_capabilities`' `ocs_normalized` reason string is the live list: **TEXT `rotation`** (a mirrored TEXT is mirror-imaged, and no single scalar angle expresses that), **z components**, and **HATCH boundary geometry**. On the live backend that same reason string also records `2d_polyline_coordinates_not_verified_against_live_autocad` — read it at runtime rather than trusting this paragraph.
|
||||
|
||||
**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 states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`.
|
||||
|
||||
## The two engines
|
||||
|
||||
One contract, split across 19 modules in `backends/contracts/`. Most are ABCs; `gdt.py` and `settings.py` are concrete shared mixins rather than interfaces. Optional methods are declarative — `@capability(key, reason=...)` supplies a default that *refuses* — and a test holds the two backends' capability key sets equal, so a key cannot exist on one engine and be forgotten on the other.
|
||||
|
||||
There are **27 capability keys**. `system_capabilities` returns the live map, and you should read it at runtime rather than trusting the table below, because five of the 27 depend on how the machine is set up:
|
||||
|
||||
| Capability | COM (live AutoCAD) | ezdxf (headless) |
|
||||
|---|:---:|:---:|
|
||||
| Live document control | ✅ | — |
|
||||
| Cross-platform, no AutoCAD | — | ✅ |
|
||||
| Transactions and rollback | ✅ *undo mark; rollback sends `_UNDO B` and AutoCAD never confirms it landed — best-effort, not atomic* | ✅ *full DXF snapshot, restored synchronously* |
|
||||
| Paper-space layouts + viewports | ✅ | ✅ |
|
||||
| Viewport model-content rendering | ✅ | ✅ ᵐ *(no viewport borders)* |
|
||||
| Selection window / crossing / polygon | ✅ | ✅ |
|
||||
| Entity area by handle | ActiveX `.Area` | Analytic, bulges included |
|
||||
| HATCH filled area (islands subtracted) | AutoCAD's own number — *measured: a r20 hatch with an r10 island returns 942.478, so the island is subtracted* | Loops walked, `hatch_style` reported |
|
||||
| REGION area | ✅ *`.Area` + `.Perimeter`, verified on a seat* | — *ACIS is opaque to ezdxf* |
|
||||
| 3DSOLID **surface** area | — *ActiveX exposes `.Volume` only; use MASSPROP* | — |
|
||||
| 3D solids | ✅ *only with `ENABLE_3D=true`* | — *no headless ACIS* |
|
||||
| DWG write | ✅ | — *no DWG writer* |
|
||||
| WIPEOUT | — *`AddWipeout` verified absent, AutoCAD 2026* | ✅ |
|
||||
| MTEXT background colour | — *`BackgroundFillColor` verified absent, AutoCAD 2026* | ✅ |
|
||||
| REVCLOUD, BPOLY / boundary tracing | — *no ActiveX member; command-line only* | ✅ |
|
||||
| CHSPACE (`entity_change_space`) | — *unverified against a live seat* | ✅ ᶜ |
|
||||
| Undo history | ✅ | opt-in — `EZDXF_UNDO_DEPTH` defaults to `0` |
|
||||
| TABLE and MLEADER | Native | Portable composite |
|
||||
| Raw commands / AutoLISP | ✅ *on by default, denylist-sanitized — `DANGEROUS_COMMANDS_ENABLED=true` removes the denylist* | — *no live command line* |
|
||||
| Screenshots and PDF export | AutoCAD window capture | Matplotlib render ᵐ |
|
||||
|
||||
<sub>ᵐ Needs matplotlib — the `[pdf]` or `[full]` extra. Without it `png`, `pdf`, `viewport_render` and `handle_overlay` all report unsupported on the headless engine.<br>
|
||||
ᶜ The headless CHSPACE carries four named restrictions in its own capability reason: top-view untwisted viewports only, dimensions refused unless frozen, ACIS/proxy/table refused, and viewport clipping reported rather than applied.</sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The COM paths added in v1.5.0 were executed against a **live AutoCAD 2026** before release — two documented exceptions aside: the `AcDbLWPolyline` OCS→WCS translation in `entity_get` and the COM half of `block_create_from_entities` ship unit-tested against a fake ActiveX object, not hardware-verified. The live run was 25 calls: 18 succeeded, 1 refused a specific argument, and 6 refused with the capability keys they declare. That run found four defects, three of which no amount of reading would have caught (the fourth was `analysis_list_properties` calling a helper that does not exist on `ComBackend`) — the largest being that `AcadPViewport` has no `ViewCenter` member, so the line setting it had been silently swallowed by a bare `except` since v1.4.
|
||||
|
||||
## Evidence
|
||||
|
||||
Every chart below is rendered to SVG by Python from committed data — none is hand-edited. The numbers are not all script output, and the two exceptions are marked where they appear: **section 4** quotes timings collected by hand across separate runs, and **section 5** is a hand-assigned source review whose nine scores are literals in [`source_review.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/source_review.json) that the renderer validates and draws but does not compute.
|
||||
|
||||
### 1 · Live-run scores
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-livebench.svg" alt="Live-run score bars: autocad-mcp-pro 100 on the v3 matrix, beiming183 50 and puran-water 45 on v2" width="820">
|
||||
|
||||
| Server | Matrix | Pinned | Score | Pass |
|
||||
|---|---|---|---:|---:|
|
||||
| **autocad-mcp-pro** *(this repo)* | v3 · 15 tasks | working tree | **100.0** | 15/15 |
|
||||
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | v2 · 10 tasks | `11f7c47` | 50.0 | 5/10 |
|
||||
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | v2 · 10 tasks | `95476a3` | 45.0 | 4/10 |
|
||||
|
||||
> [!WARNING]
|
||||
> **Read the matrix column before the score column.** The five v3 tasks are new in v1.5.0; the competitor runs are from 2026-07-23 against pinned competitor commits, and were never put to them. Their scores are over the ten tasks they actually ran, and the five new rows render blank rather than as zeros — an invented zero is indistinguishable from a measured one. These bars do not share a denominator, which is why each one carries its matrix.
|
||||
|
||||
Competitors are driven **black-box over MCP stdio** against the tool contracts read from their pinned source, at **50% coverage on both**: five of the ten tasks per competitor are recorded `unsupported` — the operation is absent from that checkout, or refused by it — and those rows score zero against a denominator of ten, so 50.0 and 45.0 are coverage-weighted, not ten measured attempts. Over the tasks actually driven, beiming scores 100.0 and puran-water 90.0. Where a task does run, every geometry claim the competitor makes is re-derived by re-opening the exported DXF with ezdxf inside the harness — their own tool output is never the source of truth. This repo's row is not run that way: it runs in-process against the backend, and 13 of its 15 tasks assert on backend return values rather than on a re-opened DXF (only `dxf_roundtrip` and `auditable_delivery` write a file at all). What is independent there is the expected value — a closed-form area, entities placed on purpose, a token ceiling fixed before the measurement — not the observation. A capability refusal is recorded `unsupported`, not `fail` — "cannot reach this" and "got this wrong" are different findings. That classification is written into the adapter from the competitor's pinned source; a refusal that only appears at runtime becomes `unsupported` where the adapter recognises it (beiming's `E_UNSUPPORTED` on `transaction begin` is the one that did) and lands as `fail` otherwise.
|
||||
|
||||
### 2 · Task matrix — where the score comes from
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-taskmatrix.svg" alt="Task matrix heatmap: 15 tasks against three servers" width="820">
|
||||
|
||||
**v2 scored this repo 10/10, and that number carried no information**: every task in it exercised something the server was built around. v3 adds five tasks chosen because they *can* fail — and two of them did, while being written: `hatch_islands` (a HATCH could not be measured at all) and `measure_from_handle` (the boundary tools and the measurement tool disagreed about the same shape). A third measurement defect fixed in the same release — a circle stored as two bulged vertices measured 0.0 — is carried by `two_vertex_circle_has_area` in the correctness suite, not by a benchmark task.
|
||||
|
||||
| v3 task | Verified against |
|
||||
|---|---|
|
||||
| `tool_discovery` | six AutoCAD command names, each ranking **#1** of the 154-tool registry the harness ranks (pass bar is top-3) |
|
||||
| `token_budget` | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
|
||||
| `hatch_islands` | **300** filled with the island, 400 ignoring it |
|
||||
| `selection_filter` | window 1, crossing 2, bounding box 3, polygon 1 |
|
||||
| `measure_from_handle` | **139.2699** against the 100.0 a vertex shoelace gives |
|
||||
|
||||
### 3 · Correctness A/B — every release re-proves itself
|
||||
|
||||
26 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run.
|
||||
|
||||
**v1.5.1 release gate**, baseline `v1.5.0` ([report](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json)):
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---|---:|---:|---:|---:|
|
||||
| v1.5.0 *(baseline)* | 24 / 26 | 92.3 % | — | — |
|
||||
| **v1.5.1** *(this release)* | **26 / 26** | **100 %** | 2 | **0** |
|
||||
|
||||
**v1.5.0 fails two of these**, and they are the reason it is yanked:
|
||||
`diameter_dim_measures_the_diameter` and `radius_dim_ignores_the_leader_length`.
|
||||
It has both methods and gets both wrong — `leader_length`, a *text placement*,
|
||||
was being measured as geometry, so at default settings every diameter callout
|
||||
came out **2 × leader_length too large** (60 on a true ⌀40). v1.4.0 shipped the
|
||||
same defect. The live COM backend was never affected.
|
||||
|
||||
Against the older `v1.4.0` baseline the same suite reports **21 / 26** ᵃ with
|
||||
five fixed ([report](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)) — three of those
|
||||
five are new capability rather than repairs: v1.4.0 has no `entity_measure` and
|
||||
no boundary tracing, so it misses them by not having the method.
|
||||
|
||||
<sub>ᵃ Against the 26-check current suite. v1.4.0 passed 21/21 of the checks that existed when it shipped.</sub>
|
||||
|
||||
The 21 pre-existing checks are unchanged and all still pass: the discovery layer, `cad_batch`, the 19-module contract split, layouts/viewports and Wave A landed with **zero correctness regressions**.
|
||||
|
||||
### 4 · Headless performance
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-perf.svg" alt="Performance chart across four headless workloads" width="820">
|
||||
|
||||
| Workload | Wall time | Result |
|
||||
|---|---:|---|
|
||||
| Create 2,000 lines (individual calls) | 0.35 s | ~5,716 entities/s |
|
||||
| 10,000 lines: build + DXF export + reopen | 2.18 s | ~4,591 entities/s end-to-end |
|
||||
| Region query over 10,000 entities | 2.47 s | 2,500 matched |
|
||||
| Premium pass (layers + part + dims + full critique) | 0.26 s | 0 issues |
|
||||
|
||||
<sub>One run of each workload — `perf_suite` executes each exactly once per invocation, and these are the figures in the committed [`perf-ezdxf.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/perf-ezdxf.json). Run-to-run spread on this machine is wide enough to matter: treat them as an order of magnitude, not a stopwatch.</sub>
|
||||
|
||||
Workloads call the same backend methods the MCP tools call, so the backend's own per-call overhead — worker-thread hand-off, document lock, the `EZDXF_CALL_TIMEOUT` guard — is included. The FastMCP layer above it is **not**: `perf_suite.py` never builds the server, so tool dispatch, argument validation, the five middleware and result serialization all sit outside the timer, and in-process they cost roughly an order of magnitude more than the backend call itself. Read these as backend-engine numbers, not per-tool-call latency. **Self-measurement only** — competitor servers pay an extra stdio serialization cost that in-process runs do not, so no cross-server timing claims are made.
|
||||
|
||||
> [!CAUTION]
|
||||
> These are **not** an improvement over v1.4.0, and they are not the engine's floor. The per-call timeout guard costs about **21%** of entity-creation throughput — see [what this release is bad at](https://github.com/U-C4N/Autocad-MCP#what-this-release-is-bad-at) for the paired measurement and why the guard is kept.
|
||||
|
||||
### 5 · Source-review leaderboard
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-benchmark.svg" alt="Capability leaderboard across nine public AutoCAD MCP servers" width="820">
|
||||
|
||||
Nine public AutoCAD MCP projects against a fixed 100-point rubric — CAD breadth (25), correctness and delivery (20), backends/platforms (15), engineering production (15), tests and maintenance (15), security and operations (10). Stars and raw tool counts score nothing. Data and dates in [`benchmarks/source_review.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/source_review.json).
|
||||
|
||||
**This table is not re-scored per release** — no row is, including the other eight. 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 the same day to keep it fair, and that has not been done — so the score stays where the evidence is.
|
||||
|
||||
Method, caveats and boundaries for every lane: [`benchmarks/README.md`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/README.md).
|
||||
|
||||
## What this release is bad at
|
||||
|
||||
A page that only lists strengths is a page that has not been measured.
|
||||
|
||||
**Entity creation is ~21% slower than it would be without the call timeout.**
|
||||
The timeout is what stops one hung ezdxf call wedging a server whose document
|
||||
lock is a single `asyncio.Lock`, and it is not free. Measured on one
|
||||
interpreter, three runs of each configuration with the median taken by hand
|
||||
(`perf_suite` runs each workload once per invocation — it cannot produce a
|
||||
median itself):
|
||||
|
||||
| Workload | `EZDXF_CALL_TIMEOUT=0` | default (120 s) | Cost |
|
||||
|---|---:|---:|---:|
|
||||
| 2,000 lines | 243.7 ms | 294.3 ms | +20.8% |
|
||||
| 10,000-line roundtrip | 1,709.6 ms | 2,049.2 ms | +19.9% |
|
||||
|
||||
v1.5.0 paid **28.6%** on the first workload; v1.5.1 rebuilt the wrapper around a
|
||||
single `asyncio.timeout_at` instead of two `asyncio.wait_for`s — `wait_for`
|
||||
wraps its awaitable in a Task, so the old shape created two extra Tasks and two
|
||||
timer handles on *every* call. That recovered about a third of the overhead and
|
||||
fixed a latent double-release, but it did not close the gap: the remaining cost
|
||||
is the Task the abandon-on-timeout design genuinely needs. The knob is
|
||||
documented rather than hidden, and the item stays open.
|
||||
|
||||
> These figures are hand-collected across separate single-shot runs and have no
|
||||
> published artifact; `benchmarks/results/published/perf-ezdxf.json` records one
|
||||
> default-configuration run only.
|
||||
|
||||
> The README on `main` through the v1.4 era printed *slower* numbers than the table above — 0.64 s and 13.9 s for the same two workloads. That looks like a 2–6× win and is not one: the old report was recorded on CPython 3.14 and this one on 3.11.15. Almost entirely the interpreter, not code — run on one interpreter, this release is *slower* than v1.4.0 on both workloads, not faster. Comparing the two published files directly is the mistake this paragraph exists to stop.
|
||||
|
||||
**Wave A shipped 13 tools of a planned ~39.** Every cut is backed by a measurement. The REGION and 2D-boolean family went because `add_region()` produces a REGION with **zero ACIS bytes**, and the substitute, `greiner_hormann`, takes and returns flat vertex lists, so a bulged edge goes in as its chord: **28.2%** of the area on a square with one semicircular edge — shipping it would have re-created the exact class of silent error this release's honesty pass exists to delete. Of the planned measurement family only `analysis_measure_entity` shipped — measuring a drawn entity by handle, which neither backend could do — and the centroid and moment-of-inertia tools were cut with nothing replacing them. Of the eight `analysis_*` tools that already existed, the two tagged `measure` both measured points the caller typed in.
|
||||
|
||||
**`system_run_command` / `system_run_lisp` are a guardrail, not a security boundary** — the rejection message clients receive says so in those words. A 36-verb command denylist and a 24-pattern AutoLISP denylist refuse the obviously destructive cases, but AutoCAD accepts hundreds of commands and any loaded ARX/LISP adds more, so the list cannot be complete, and `DANGEROUS_COMMANDS_ENABLED=true` switches both off entirely.
|
||||
|
||||
**Path validation is per-tool, and unscoped until you scope it.** Nine tools call `validate_path`; nothing in the middleware enforces it server-wide. With `ALLOWED_PATHS` empty — the default — the only positive bound is a ten-entry system-directory denylist that does not include `C:/Users`, `/home`, `/root` or `/var`. **Set `ALLOWED_PATHS`.** It is the setting that actually scopes the filesystem.
|
||||
|
||||
**Non-AutoCAD ProgIDs are unverified.** `CAD_PROGID` changes which COM application the backend attaches to — the configured ProgID is used at both `GetActiveObject` and `Dispatch`, and an unrecognised one fails loudly rather than falling back to AutoCAD. Nothing has been tested against BricsCAD, ZWCAD or GstarCAD, the connection included: no running seat of any of them was ever attached to. They are listed in `.env.example` commented out and marked UNVERIFIED.
|
||||
|
||||
## 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.
|
||||
Nothing here loads a `.env` file — there is no dotenv dependency. Export these, or set them in your MCP client's `env` block. `.env.example` is documentation, and it is not the full list.
|
||||
|
||||
<details>
|
||||
<summary><b>All 16 environment variables</b></summary>
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `AUTOCAD_MCP_BACKEND` | `auto` | Select `auto`, `com`, or `ezdxf` |
|
||||
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
|
||||
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to (unverified for clones) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `EZDXF_UNDO_DEPTH` | `0` | Headless undo history depth; `0` disables it |
|
||||
| `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 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 |
|
||||
| `MCP_AUTH_TOKEN` | *(empty)* | Bearer token required for remote HTTP |
|
||||
|
||||
Backend selection:
|
||||
|
||||
| Value | Behavior |
|
||||
|---|---|
|
||||
| `auto` | Try COM on Windows, then fall back to ezdxf |
|
||||
| `com` | Require a reachable live AutoCAD instance |
|
||||
| `ezdxf` | Force portable, headless DXF operation |
|
||||
</details>
|
||||
|
||||
## Architecture
|
||||
|
||||
The request path, as a Mermaid diagram — GitHub draws it; on PyPI it is the same graph in source form:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[MCP host / AI agent] --> B[FastMCP server]
|
||||
B --> C[Error · audit · timing · logging middleware]
|
||||
C --> D[Typed tool contract]
|
||||
D --> E[COM backend]
|
||||
D --> F[ezdxf backend]
|
||||
E --> G[Live AutoCAD]
|
||||
F --> H[Headless DXF]
|
||||
E --> I[Engineering quality + delivery]
|
||||
F --> I
|
||||
A[MCP host / AI agent] --> B[FastMCP 3 server]
|
||||
B --> C[Error · audit · timing · logging middleware]
|
||||
C --> D[Typed contract · 19 modules · 27 capabilities]
|
||||
D --> E[COM backend — single-STA thread]
|
||||
D --> F[ezdxf backend — asyncio.to_thread]
|
||||
E --> G[Live AutoCAD]
|
||||
F --> H[Headless DXF]
|
||||
E --> I[Engineering · critique · scoring · delivery]
|
||||
F --> I
|
||||
```
|
||||
|
||||
- `server.py` owns the FastMCP surface, lifespan, middleware, resources, and
|
||||
prompt templates.
|
||||
- `backends/base.py` defines the shared contract and capability model.
|
||||
- `backends/com_backend.py` serializes COM calls through one STA thread with
|
||||
per-call timeouts.
|
||||
- `backends/ezdxf_backend.py` wraps synchronous DXF work with
|
||||
`asyncio.to_thread` and snapshot-based transactions.
|
||||
- `engineering/` contains standards-aware planning, drawing, critique, scoring,
|
||||
refinement, validation, and delivery.
|
||||
- `security.py` centralizes path validation and command/LISP sanitization.
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `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/ocs.py` | OCS ↔ WCS translation at the backend boundary |
|
||||
| `backends/com_backend.py` | Single-STA-thread COM executor with per-call timeouts |
|
||||
| `backends/ezdxf_backend.py` | `asyncio.to_thread`-wrapped DXF engine, snapshot transactions |
|
||||
| `discovery/` | Alias corpus, ranking transform, compact serializer |
|
||||
| `engineering/` | Standards, generators, critique, fits, scoring, delivery |
|
||||
| `security.py` | Path validation, command and AutoLISP sanitization |
|
||||
|
||||
## Security model
|
||||
### The drawing at the top of this page, as tool calls
|
||||
|
||||
The server treats tool input as untrusted:
|
||||
Not pseudocode — this is the sequence in [`scripts/render_readme_showcase.py`](https://github.com/U-C4N/Autocad-MCP/blob/main/scripts/render_readme_showcase.py), which produced the sheet in the hero image:
|
||||
|
||||
- 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
|
||||
operations are enabled.
|
||||
```text
|
||||
drawing_new() fresh document, ISO linetypes + layers bootstrapped
|
||||
drawing_apply_iso_layers("mech") ISO 128 lineweights per layer
|
||||
drawing_settings({units: "mm", linear_precision: 2})
|
||||
|
||||
`DANGEROUS_COMMANDS_ENABLED=true` is a trust decision, not an installation
|
||||
shortcut. Keep it disabled for agents or clients you do not fully control.
|
||||
gear_draw_spur_front_view( involute flanks, not a decorated circle
|
||||
module=6, teeth=24, center=[135, 172],
|
||||
bore_diameter=40, keyway_width=12, keyway_depth=3.3)
|
||||
gear_draw_section_aa(x_offset=300, face_width=46)
|
||||
|
||||
Please avoid filing public issues for vulnerabilities. Contact the maintainer
|
||||
through [GitHub](https://github.com/U-C4N) first.
|
||||
dimension_linear(...) ISO 129
|
||||
dimension_diameter(..., fit="H7") deviations from authored ISO 286 tables
|
||||
titleblock_apply_iso_a3(title="SPUR GEAR m6 z24", material="C45E", ...)
|
||||
|
||||
drawing_critique(focus=None) -> [] must be empty before finalize
|
||||
view_screenshot() -> PNG
|
||||
```
|
||||
|
||||
The full production loop adds `drawing_preflight` and `drawing_plan` at the front (the plan is held by the backend and replayed by `drawing_critique` at finalize time), `drawing_refine` after the critique, and `layout_create` + `viewport_create` + `drawing_deliver` at the end for a hashed, reopen-verified handoff.
|
||||
|
||||
## Development
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Focused benchmark checks:
|
||||
`uv.lock` pins the whole transitive graph — runtime *and* dev tools — so this reproduces CI exactly rather than approximately:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_benchmark_chart.py tests/test_benchmark_v2.py -q
|
||||
python -m benchmarks.render_chart
|
||||
uv sync --locked --all-extras
|
||||
uv run pytest
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
```
|
||||
|
||||
Repository map:
|
||||
CI runs on Linux (3.11 / 3.12), Windows (mocked-COM suite), plus package, Docker and MCP-registry-schema jobs. The package job deliberately stays *unlocked* — 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`.
|
||||
|
||||
```text
|
||||
server.py FastMCP surface and orchestration
|
||||
config.py Environment-driven settings
|
||||
security.py Path, command, and AutoLISP guards
|
||||
version.py Canonical package version
|
||||
backends/ COM and ezdxf implementations
|
||||
engineering/ Standards, critique, refinement, delivery
|
||||
benchmarks/ Reproducible data, runners, and reports
|
||||
tests/ Headless, mocked-COM, contract, and security tests
|
||||
```
|
||||
## Roadmap (1.6)
|
||||
|
||||
## Roadmap
|
||||
Give back the 25% creation throughput the per-call timeout costs — arm it only for calls that can actually block. ISO 286 interference **shafts** r/s/t/u (they need sub-stepped table data nothing in the module can derive; 1.5 refuses them by name instead of guessing). 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 headlessly, 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`.
|
||||
|
||||
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.
|
||||
Features are published when their contracts and limitations are testable — not when they make a longer checklist.
|
||||
|
||||
## Star history
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://star-history.com/#U-C4N/Autocad-MCP&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=U-C4N/Autocad-MCP&type=Date&theme=dark" alt="Star history chart for U-C4N/Autocad-MCP" width="720">
|
||||
</a>
|
||||
|
||||
If this saved you a day of drafting, a star helps other engineers find it.
|
||||
|
||||
</div>
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome, especially for 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.
|
||||
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.
|
||||
|
||||
## Author
|
||||
|
||||
**Umutcan Edizsalan** · Mechanical engineering work at **Anka-Makine** ·
|
||||
GitHub [@U-C4N](https://github.com/U-C4N)
|
||||
**Umutcan Edizsalan** · Mechanical engineering work at **Anka-Makine** · GitHub [@U-C4N](https://github.com/U-C4N)
|
||||
|
||||
Built from production drawing work, then made model-agnostic through MCP.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
[MIT](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
|
||||
|
||||
<sub>The line below is the MCP registry's ownership marker. It must equal the `name` in `server.json`, and it is deliberately plain text so it survives PyPI's HTML stripping.</sub>
|
||||
|
||||
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
|
||||
|
||||
+75
-1470
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_contract.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))
|
||||
+1865
-154
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,175 @@
|
||||
"""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 reads ActiveX ``.Area``, which **does** apply the island style —
|
||||
verified on a live AutoCAD 2026 (2026-08-06): a hatch of r20 with an
|
||||
r10 island returns 942.478, which is pi*(400-100).
|
||||
|
||||
``perimeter`` is ``None`` where ActiveX exposes no length member for the
|
||||
type, and ``perimeter_exact`` is then False. The member differs per
|
||||
type and was measured rather than assumed: LWPOLYLINE ``.Length``,
|
||||
CIRCLE ``.Circumference``, REGION ``.Perimeter``, HATCH none. A
|
||||
3DSOLID has no ``.Area`` at all — only ``.Volume`` — and is refused by
|
||||
name.
|
||||
A missing perimeter is not a reason to discard an area that was read
|
||||
successfully — doing exactly that is what made this method raise on
|
||||
every ACIS type it advertised.
|
||||
|
||||
``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,770 @@
|
||||
"""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
|
||||
|
||||
from backends.quarantine import DocumentQuarantineError
|
||||
|
||||
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 DocumentQuarantineError:
|
||||
# "deleted: 0" here would read as "there was nothing to clear" on a
|
||||
# document the backend has declared untrusted -- and this is step 9
|
||||
# of the standard workflow, immediately before finalize.
|
||||
raise
|
||||
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,221 @@
|
||||
"""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, dim_text_height, dim_arrow_size, dim_decimals,
|
||||
decimal_separator, zero_suppression, text_size, point_mode, point_size,
|
||||
osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the
|
||||
INSUNITS code) and `decimal_separator` accepts "." or ",".
|
||||
|
||||
Note that a no-argument snapshot is one ``system_get_variable`` per key.
|
||||
Free headless; a COM round trip each on a live seat."""
|
||||
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"),
|
||||
# The dimension variables had no friendly name until v1.5.2, so the only
|
||||
# way to reach dimension lettering was a raw `system_set_variable` — which
|
||||
# is precisely the path that silently did nothing on the headless engine.
|
||||
# `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
|
||||
# never had anything to do with the numbers on a dimension.
|
||||
"dim_text_height": ("DIMTXT", "float"),
|
||||
"dim_arrow_size": ("DIMASZ", "float"),
|
||||
"dim_decimals": ("DIMDEC", "int"),
|
||||
"decimal_separator": ("DIMDSEP", "char"),
|
||||
"zero_suppression": ("DIMZIN", "int"),
|
||||
"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"}
|
||||
|
||||
#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
|
||||
#: on a string in the header — so the friendly key is where the translation
|
||||
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
|
||||
#: of continental Europe drafts with. Anything else lands an arbitrary glyph in
|
||||
#: the middle of every number on the sheet, so it is refused rather than stored.
|
||||
_DECIMAL_MARKERS = (".", ",")
|
||||
|
||||
|
||||
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 == "char":
|
||||
if isinstance(value, bool):
|
||||
marker = ""
|
||||
elif isinstance(value, (int, float)):
|
||||
code = int(value)
|
||||
marker = chr(code) if 0 < code < 0x110000 else ""
|
||||
else:
|
||||
marker = str(value).strip()
|
||||
if marker not in _DECIMAL_MARKERS:
|
||||
raise ValueError(
|
||||
f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
|
||||
)
|
||||
return ord(marker)
|
||||
if kind == "int":
|
||||
return _in_range(key, int(float(value)))
|
||||
if kind == "float":
|
||||
return _in_range(key, float(value))
|
||||
return value
|
||||
|
||||
|
||||
#: AutoCAD's own ranges for the settings that now reach the renderer.
|
||||
#:
|
||||
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
|
||||
#: header. Folding it into the per-dimension override is what gave it teeth:
|
||||
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
|
||||
#: with `ValueError: Format specifier missing precision` raised from inside
|
||||
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
|
||||
#: printed forty digits of float noise onto the sheet; and a non-positive
|
||||
#: length was reported applied and silently discarded. `applied` has to mean
|
||||
#: applied.
|
||||
_SETTING_RANGES: dict[str, tuple[float, float]] = {
|
||||
"dim_decimals": (0, 8), # DIMDEC
|
||||
"zero_suppression": (0, 15), # DIMZIN is a bitmask
|
||||
"linear_precision": (0, 8), # LUPREC
|
||||
"angular_precision": (0, 8), # AUPREC
|
||||
"dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive
|
||||
"dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive
|
||||
"text_size": (1e-9, 1e6), # TEXTSIZE
|
||||
"dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal
|
||||
"ltscale": (1e-9, 1e6),
|
||||
"point_size": (-100, 1e6), # negative is a percentage of the screen
|
||||
"fillet_radius": (0, 1e6),
|
||||
}
|
||||
|
||||
|
||||
def _in_range(key: str, value: float) -> float:
|
||||
"""Refuse a value the renderer cannot use, naming the range."""
|
||||
bounds = _SETTING_RANGES.get(key)
|
||||
if bounds is None:
|
||||
return value
|
||||
low, high = bounds
|
||||
if not low <= value <= high:
|
||||
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
|
||||
raise ValueError(
|
||||
f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
|
||||
)
|
||||
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 == "char":
|
||||
try:
|
||||
code = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
# ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
|
||||
# so report the marker that would actually be drawn.
|
||||
return "," if code == 0 else chr(code)
|
||||
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.
|
||||
"""
|
||||
...
|
||||
+3618
-201
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)]
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Document quarantine — the vocabulary for a call that was abandoned mid-write.
|
||||
|
||||
``EzdxfBackend._async`` cannot cancel an ``asyncio.to_thread`` worker. When a
|
||||
call overruns ``EZDXF_CALL_TIMEOUT`` the handler swaps in a fresh
|
||||
``asyncio.Lock`` so later callers do not deadlock behind the orphaned one — and
|
||||
that swap is precisely what removes the mutual exclusion the lock existed for.
|
||||
The runaway thread keeps a live reference to the one shared ``ezdxf.Drawing``,
|
||||
which is explicitly not thread-safe, through the closure it was handed.
|
||||
|
||||
Measured, with a runaway ``_sync`` that really appends LINEs to ``_doc``:
|
||||
``entity_create_circle`` after the abandonment returned handle ``A1``;
|
||||
``drawing_info`` reported 116 entities for a modelspace observed at 302 and
|
||||
climbing; ``drawing_save`` returned ``{"ok": True}`` and wrote a valid DXF of
|
||||
15200 of the 60000 entities the runaway would go on to write — a considered
|
||||
document of a state that never existed. Only the timed-out caller was warned.
|
||||
|
||||
So the abandonment quarantines the DOCUMENT, not the backend. The document stays
|
||||
untrusted until it is REPLACED by an object the abandoned thread has no
|
||||
reference to (``drawing_open`` / ``drawing_new`` / ``drawing_close``), or until
|
||||
the abandoned call is seen to finish cleanly — see ``AbandonedCall``.
|
||||
|
||||
This module deliberately has no ezdxf import: ``server.py`` needs the exception
|
||||
type to classify a refusal on a box where a backend's dependencies are missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"AbandonedCall",
|
||||
"DocumentQuarantineError",
|
||||
"QuarantineRecord",
|
||||
"quarantine_refusal",
|
||||
"real_call_name",
|
||||
]
|
||||
|
||||
#: Longest ``repr`` kept for a return value nobody received. A late
|
||||
#: ``entity_list`` can return thousands of entities; the diagnostic is that the
|
||||
#: value was lost, not what was in it.
|
||||
_MAX_RESULT_REPR = 200
|
||||
|
||||
|
||||
def real_call_name(func: Any) -> str:
|
||||
"""The backend method ``func`` belongs to, not the closure's own name.
|
||||
|
||||
Every real backend method hands ``_async`` a closure literally named
|
||||
``_sync`` — measured: ``__name__='_sync'``,
|
||||
``__qualname__='EzdxfBackend.drawing_save.<locals>._sync'`` — so
|
||||
``__name__`` made every production timeout read ``ezdxf call '_sync' timed
|
||||
out``. The qualname carries the name the user typed and was being discarded.
|
||||
"""
|
||||
qualname = getattr(func, "__qualname__", "") or ""
|
||||
parts = qualname.split(".")
|
||||
if "<locals>" in parts:
|
||||
index = parts.index("<locals>")
|
||||
if index:
|
||||
return parts[index - 1]
|
||||
return getattr(func, "__name__", None) or "call"
|
||||
|
||||
|
||||
class AbandonedCall:
|
||||
"""Observation post for a worker thread ``_async`` can no longer await.
|
||||
|
||||
The obvious hook does not work, and this was checked before being relied on:
|
||||
``asyncio.timeout_at`` **cancels** the Future ``_async`` awaits, so by the
|
||||
time the abandon branch runs that Future is already done and
|
||||
``add_done_callback`` fires immediately with ``cancelled()`` True. It reports
|
||||
the *await* ending, never the thread — which ``asyncio.to_thread`` cannot
|
||||
cancel. The only place the thread's outcome is observable is inside the
|
||||
thread, so ``_async`` wraps every timed call in one of these.
|
||||
|
||||
Deliberately no ``threading.Event``: this is allocated on every ``_async``
|
||||
call and an Event carries a Condition and a Lock. ``finished`` is a plain
|
||||
bool written once by the worker and read from the event loop; a bool store is
|
||||
atomic under the GIL and nothing here ever waits on it.
|
||||
"""
|
||||
|
||||
__slots__ = ("func", "finished", "result", "error")
|
||||
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
self.finished = False
|
||||
self.result: Any = None
|
||||
self.error: BaseException | None = None
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
try:
|
||||
self.result = self.func(*args, **kwargs)
|
||||
return self.result
|
||||
except BaseException as exc:
|
||||
self.error = exc
|
||||
raise
|
||||
finally:
|
||||
self.finished = True
|
||||
|
||||
|
||||
class QuarantineRecord:
|
||||
"""One abandoned call, and what became of it.
|
||||
|
||||
Holds the identity of the ``Drawing`` that was live at abandon time as well
|
||||
as its path: rebinding ``self._doc`` isolates a ``_sync`` that captured
|
||||
doc/msp once at entry (the shape of nearly every closure in the backend), but
|
||||
one that re-reads ``self._msp()`` inside its own loop would follow the rebind
|
||||
into the fresh document. ``_async`` cannot prevent that, so recovery says
|
||||
when the runaway was still alive instead of implying it was fenced off.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
call: str,
|
||||
timeout: float,
|
||||
watcher: Any,
|
||||
*,
|
||||
document_path: str | None = None,
|
||||
document_id: int | None = None,
|
||||
):
|
||||
self.call = call
|
||||
self.timeout = float(timeout)
|
||||
self.watcher = watcher
|
||||
self.document_path = document_path
|
||||
self.document_id = document_id
|
||||
self.stamp = time.monotonic()
|
||||
self.outcome = "running"
|
||||
self.detail = ""
|
||||
self.released_by: str | None = None
|
||||
self.result_lost = False
|
||||
self.result_repr: str | None = None
|
||||
|
||||
def age(self) -> float:
|
||||
return time.monotonic() - self.stamp
|
||||
|
||||
def thread_alive(self) -> bool:
|
||||
return not self.watcher.finished
|
||||
|
||||
def capture_lost_result(self) -> None:
|
||||
"""Record that the return value never reached the caller.
|
||||
|
||||
A late-completing ``entity_create_*`` leaves an entity in the document
|
||||
whose handle nobody holds. That is not recoverable, so it is at least
|
||||
reported rather than dropped.
|
||||
"""
|
||||
self.result_lost = True
|
||||
try:
|
||||
text = repr(self.watcher.result)
|
||||
except Exception: # a __repr__ that raises must not break the report
|
||||
text = f"<unreprable {type(self.watcher.result).__name__}>"
|
||||
if len(text) > _MAX_RESULT_REPR:
|
||||
text = text[: _MAX_RESULT_REPR - 3] + "..."
|
||||
self.result_repr = text
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"call": self.call,
|
||||
"timeout": self.timeout,
|
||||
"age_seconds": round(self.age(), 3),
|
||||
"thread_alive": self.thread_alive(),
|
||||
"outcome": self.outcome,
|
||||
"detail": self.detail,
|
||||
"released_by": self.released_by,
|
||||
"result_lost": self.result_lost,
|
||||
"lost_return_value": self.result_repr,
|
||||
"document_path": self.document_path,
|
||||
}
|
||||
|
||||
|
||||
def quarantine_refusal(record: QuarantineRecord) -> DocumentQuarantineError:
|
||||
"""The refusal every later call on a quarantined document gets."""
|
||||
thread = "still running" if record.thread_alive() else "has since stopped"
|
||||
return DocumentQuarantineError(
|
||||
record,
|
||||
f"ezdxf document quarantined: '{record.call}' overran "
|
||||
f"EZDXF_CALL_TIMEOUT={record.timeout:g}s {record.age():.1f}s ago and its worker "
|
||||
f"thread ({thread}) cannot be cancelled, so it may still be mutating this "
|
||||
"drawing. Every call on it is refused, reads included - a read of a container "
|
||||
"another thread is appending to does not fail loudly, it never finishes. "
|
||||
"Recover with drawing_open(path), drawing_new() or drawing_close(): those "
|
||||
"rebind the document to a freshly constructed object the abandoned thread "
|
||||
"cannot reach. Undo and rollback are not a way back - their snapshots may "
|
||||
"themselves have been serialised mid-write. system_status() stays answerable "
|
||||
"and reports the abandoned call.",
|
||||
)
|
||||
|
||||
|
||||
class DocumentQuarantineError(RuntimeError):
|
||||
"""The open document is untrusted because a call was abandoned on it.
|
||||
|
||||
Carries **no** ``capability`` attribute, on purpose.
|
||||
``server._batch_capability_of`` classifies refusals structurally — any
|
||||
exception with a non-empty ``capability`` str plus a callable ``to_dict()``
|
||||
is reported to the client as ``kind: "unsupported"`` and filed by
|
||||
``cad_batch`` as a capability step row. Borrowing one here would tell the
|
||||
client the ezdxf engine cannot draw a circle. The engine is fine; the
|
||||
document is not. This needs its own kind, not a borrowed one.
|
||||
"""
|
||||
|
||||
def __init__(self, record: QuarantineRecord, message: str):
|
||||
super().__init__(message)
|
||||
self.quarantine = record.to_dict()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"ok": False,
|
||||
"kind": "quarantined",
|
||||
"error": str(self),
|
||||
"quarantine": self.quarantine,
|
||||
}
|
||||
+220
-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,164 @@ 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 found in the top 3 of the 154-tool registry the harness ranks — all six came back #1 in the published run |
|
||||
| `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
|
||||
```
|
||||
|
||||
### What the call-timeout guard costs
|
||||
|
||||
The published v1.4 report was recorded on CPython 3.14 and the later ones on
|
||||
3.11.15, so comparing those two files directly shows an apparent 2–6× speedup
|
||||
that is almost entirely the interpreter. The comparison that means something is
|
||||
the guard against no guard, on one interpreter, back to back. Three runs of
|
||||
each configuration, median taken by hand — `perf_suite` executes each workload
|
||||
once per invocation and cannot produce a median itself:
|
||||
|
||||
| Workload | `EZDXF_CALL_TIMEOUT=0` | default (120 s) | Cost |
|
||||
|---|---:|---:|---:|
|
||||
| `create_lines_2k` | 243.7 ms | 294.3 ms | **+20.8%** |
|
||||
| `roundtrip_10k` | 1,709.6 ms | 2,049.2 ms | **+19.9%** |
|
||||
|
||||
v1.5.0 paid 28.6% on the first workload; v1.5.1 rebuilt the wrapper around a
|
||||
single `asyncio.timeout_at` instead of two `asyncio.wait_for`s — `wait_for`
|
||||
wraps its awaitable in a Task, so the old shape created two extra Tasks and two
|
||||
timer handles per call — which recovered about a third of the overhead. The
|
||||
rest is the Task the abandon-on-timeout design genuinely needs. The guard is
|
||||
what stops one hung ezdxf call wedging a server whose document lock is a single
|
||||
`asyncio.Lock`, so it stays; the knob is documented rather than hidden.
|
||||
|
||||
These figures are hand-collected and have **no published artifact** —
|
||||
`perf-ezdxf.json` records one default-configuration run only. Reproduce:
|
||||
|
||||
```bash
|
||||
for i in 1 2 3; do
|
||||
python -m benchmarks.perf_suite --out /tmp/on-$i.json
|
||||
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/off-$i.json
|
||||
done
|
||||
```
|
||||
|
||||
## Correctness A/B suite
|
||||
|
||||
@@ -63,6 +201,68 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
|
||||
python benchmarks/compare_versions.py --json results.json
|
||||
```
|
||||
|
||||
### Result — v1.5.1 vs v1.5.0 (release gate)
|
||||
|
||||
26 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
[`results/published/ab-v1.5.0-vs-v1.5.1.json`](results/published/ab-v1.5.0-vs-v1.5.1.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.5.0** (baseline) | 24 / 26 | 92.3 % | — | — |
|
||||
| **v1.5.1** (this release) | 26 / 26 | 100 % | 2 | **0** |
|
||||
|
||||
v1.5.0 fails `diameter_dim_measures_the_diameter` and
|
||||
`radius_dim_ignores_the_leader_length`: it has both methods and gets both
|
||||
wrong. That is the defect 1.5.1 exists to fix and the reason 1.5.0 is yanked.
|
||||
|
||||
### Result — this release vs v1.4.0
|
||||
|
||||
Same suite, older baseline:
|
||||
[`results/published/ab-v1.4.0-vs-v1.5.1.json`](results/published/ab-v1.4.0-vs-v1.5.1.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.4.0** (baseline) | 21 / 26 | 80.8 % | — | — |
|
||||
| **v1.5.1** (this release) | 26 / 26 | 100 % | 5 | **0** |
|
||||
|
||||
> This row used to read `v1.5.0 | 24 / 26 | 92.3 % | 5`, which was impossible on
|
||||
> its face — 21 passing plus 5 fixed is 26, not 24 — and it contradicted the
|
||||
> report it linked. The cause is worth recording: the file named
|
||||
> `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that already carried
|
||||
> the dimension fixes, so it never measured the v1.5.0 *tag*. The v1.5.0 tag
|
||||
> really does score 24/26, which is what the v1.5.0-baseline table above
|
||||
> reports. This table now names the tree it actually measured.
|
||||
|
||||
The pass rate is against the 26-check current suite; v1.4.0 passed 21/21 of the
|
||||
checks that existed when it shipped. The five splits into two kinds, and the
|
||||
difference is the whole reason this lane distinguishes `miss` from `fail`:
|
||||
|
||||
* **Three are new capability.** 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 (`miss → pass`). 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.
|
||||
* **Two are repaired defects.** v1.4.0 *and v1.5.0* both have
|
||||
`dimension_diameter` and `dimension_radius` and both get them wrong
|
||||
(`fail → pass`): `leader_length`, a text placement, was being measured as
|
||||
geometry, so at default settings every diameter callout came out
|
||||
2 × `leader_length` too large. Only 1.5.1 passes them.
|
||||
|
||||
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 +309,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,
|
||||
env={**os.environ, "PYTHONPATH": repo},
|
||||
capture_output=True, text=True, timeout=120)
|
||||
r = subprocess.run(
|
||||
[PY, suite, name],
|
||||
cwd=repo,
|
||||
env={**os.environ, "PYTHONPATH": repo},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "timeout"
|
||||
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
|
||||
@@ -82,22 +87,30 @@ def main():
|
||||
op = sum(r["old"] == "pass" for r in rows)
|
||||
npass = sum(r["new"] == "pass" for r in rows)
|
||||
summary = {
|
||||
"baseline_ref": ref, "total": total,
|
||||
"old_pass": op, "new_pass": npass,
|
||||
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1),
|
||||
"baseline_ref": ref,
|
||||
"total": total,
|
||||
"old_pass": op,
|
||||
"new_pass": npass,
|
||||
"old_pct": round(100 * op / total, 1),
|
||||
"new_pct": round(100 * npass / total, 1),
|
||||
"fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows),
|
||||
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
|
||||
}
|
||||
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
|
||||
print("-" * 66)
|
||||
for r in rows:
|
||||
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else (
|
||||
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
|
||||
mark = (
|
||||
" ->FIXED"
|
||||
if (r["old"] != "pass" and r["new"] == "pass")
|
||||
else (" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
|
||||
)
|
||||
print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
|
||||
print("-" * 66)
|
||||
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
|
||||
f"current: {npass}/{total} ({summary['new_pct']}%) "
|
||||
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
|
||||
print(
|
||||
f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
|
||||
f"current: {npass}/{total} ({summary['new_pct']}%) "
|
||||
f"fixed: {summary['fixed']} regressed: {summary['regressed']}"
|
||||
)
|
||||
if json_out:
|
||||
with open(json_out, "w") as fh:
|
||||
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
|
||||
|
||||
@@ -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,76 @@ 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
|
||||
|
||||
|
||||
async def diameter_dim_measures_the_diameter():
|
||||
"""The number on the callout is the whole point of the callout.
|
||||
|
||||
`add_diameter_dim` measures to `mpoint`; the leader is a text placement.
|
||||
Feeding it centre + (radius + leader) made every diameter come back
|
||||
2 x leader_length too large at DEFAULT settings - 60 on a true 40.
|
||||
"""
|
||||
b = await _b()
|
||||
info = await b.dimension_diameter(-20, 0, 20, 0)
|
||||
dim = b._doc.entitydb.get(info.handle)
|
||||
return abs(dim.get_measurement() - 40.0) < 1e-6
|
||||
|
||||
|
||||
async def radius_dim_ignores_the_leader_length():
|
||||
b = await _b()
|
||||
near = await b.dimension_radius(0, 0, 20, 0, leader_length=5)
|
||||
far = await b.dimension_radius(0, 0, 20, 0, leader_length=40)
|
||||
values = [
|
||||
b._doc.entitydb.get(near.handle).get_measurement(),
|
||||
b._doc.entitydb.get(far.handle).get_measurement(),
|
||||
]
|
||||
return all(abs(v - 20.0) < 1e-6 for v in values)
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"core_line_length": (core_line_length, "Core"),
|
||||
"core_circle_radius": (core_circle_radius, "Core"),
|
||||
@@ -220,6 +315,11 @@ 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"),
|
||||
"diameter_dim_measures_the_diameter": (diameter_dim_measures_the_diameter, "Dimensions"),
|
||||
"radius_dim_ignores_the_leader_length": (radius_dim_ignores_the_leader_length, "Dimensions"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,149 @@
|
||||
"""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]
|
||||
# Which task set each server actually ran. Not decoration: since v1.5.0
|
||||
# the reference adapter runs the 15-task v3 matrix while the pinned
|
||||
# competitor reports are v2 runs from v1.4, so the bars do not share a
|
||||
# denominator and a chart that implies they do is the lie this lane
|
||||
# exists to avoid.
|
||||
matrices = [item.get("matrix", "v2") 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, within each server's own 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 harness, pinned competitor commits; artifacts verified by re-opening the DXF. "
|
||||
"Matrices differ - read the per-bar label.",
|
||||
transform=ax.transAxes,
|
||||
color="#8b949e",
|
||||
fontsize=10,
|
||||
)
|
||||
|
||||
for bar, score, cov, ok, total, matrix in zip(
|
||||
bars, scores, coverage, passed, attempted, matrices, strict=True
|
||||
):
|
||||
ax.text(
|
||||
min(score + 1.2, 76),
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage, matrix {matrix})",
|
||||
va="center",
|
||||
color="#e6edf3",
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
ax.text(
|
||||
0,
|
||||
-0.22,
|
||||
"Unsupported tasks score 0 within a server's own matrix. The five v3 tasks were "
|
||||
"added after the competitor runs and were never put to them; per-task statuses 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,170 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.4.0",
|
||||
"total": 26,
|
||||
"old_pass": 21,
|
||||
"new_pass": 26,
|
||||
"old_pct": 80.8,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 5,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"check": "diameter_dim_measures_the_diameter",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "radius_dim_ignores_the_leader_length",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.4.0",
|
||||
"total": 26,
|
||||
"old_pass": 21,
|
||||
"new_pass": 26,
|
||||
"old_pct": 80.8,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 5,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"check": "diameter_dim_measures_the_diameter",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "radius_dim_ignores_the_leader_length",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.5.0",
|
||||
"total": 26,
|
||||
"old_pass": 24,
|
||||
"new_pass": 26,
|
||||
"old_pct": 92.3,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 2,
|
||||
"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": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "boundary_area_agrees_with_measure",
|
||||
"category": "Measurement",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "two_vertex_circle_has_area",
|
||||
"category": "Measurement",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "diameter_dim_measures_the_diameter",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "radius_dim_ignores_the_leader_length",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"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": 5715.8,
|
||||
"wall_ms": 349.9,
|
||||
"workload_id": "create_lines_2k"
|
||||
},
|
||||
{
|
||||
"description": "Create 10,000 lines, export DXF, reopen and verify",
|
||||
"detail": {
|
||||
"build_ms": 1633.1,
|
||||
"entity_count": 10000,
|
||||
"export_ms": 273.0,
|
||||
"file_bytes": 1369663,
|
||||
"reopen_ms": 272.1
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 4590.8,
|
||||
"wall_ms": 2178.3,
|
||||
"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": 2466.9,
|
||||
"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": 255.7,
|
||||
"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: 82 KiB |
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 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
+186
-68
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from backends.quarantine import DocumentQuarantineError, quarantine_refusal
|
||||
|
||||
from .layers import ENGINEERING_LAYERS
|
||||
from .plan_spec import (
|
||||
ALL_CRITIQUE_FOCUSES,
|
||||
@@ -29,8 +31,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +47,8 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
try:
|
||||
layers = await backend.layer_list()
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return [Issue("warning", "iso128", f"layer_list failed: {exc}")]
|
||||
allowed = set(ISO_128_LINEWEIGHTS_MM)
|
||||
@@ -57,15 +66,98 @@ 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=(
|
||||
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
|
||||
f"ISO 128 set {sorted(allowed)}."
|
||||
),
|
||||
detail={"layer": lyr.name, "lineweight": lw},
|
||||
)
|
||||
)
|
||||
issues.extend(_check_dimension_legibility(backend))
|
||||
return issues
|
||||
|
||||
|
||||
#: ISO 3098 minimum lettering height for technical drawings, in mm.
|
||||
ISO_3098_MIN_TEXT_MM = 2.5
|
||||
|
||||
|
||||
def _check_dimension_legibility(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""Dimension text must be readable and use an ISO 129 decimal marker.
|
||||
|
||||
This lane was blind: `iso128` checked layer lineweights only, so a sheet
|
||||
whose dimensions rendered at ezdxf's bare 1.0 mm with a comma separator
|
||||
critiqued clean — and "zero issues" is the claim that the drawing is
|
||||
conformant. Reading the settings rather than the rendered block keeps it
|
||||
cheap and catches the case before a dimension is even drawn.
|
||||
|
||||
It reads the *effective* value — header first, then the stored dimstyle
|
||||
attribute, then ezdxf's renderer fallback — because that is the order the
|
||||
renderer resolves in since the DIM* header variables started reaching it.
|
||||
Grading the dimstyle alone made this check wrong in both directions: it
|
||||
would report a 1.0 mm defect on a sheet the header letters at 2.5, and clear
|
||||
a sheet the header letters at 1.0 because the style still said 2.5.
|
||||
|
||||
Headless only by construction: a live AutoCAD document carries the
|
||||
operator's own template, and second-guessing it from here would report
|
||||
their house standard as a defect.
|
||||
"""
|
||||
doc = getattr(backend, "_doc", None)
|
||||
if doc is None: # the live backend has no ezdxf document to read
|
||||
return []
|
||||
|
||||
# This is the one check that reads `_doc` directly instead of going through
|
||||
# `_async`, so it also misses `_async`'s quarantine gate — and it runs on
|
||||
# the event loop thread, which is exactly the unsynchronised access to a
|
||||
# document an abandoned worker may still be writing that the quarantine
|
||||
# exists to prevent. Refuse here rather than read.
|
||||
record = getattr(backend, "_quarantine", None)
|
||||
if record is not None:
|
||||
reap = getattr(backend, "_reap_quarantine", None)
|
||||
if reap is not None:
|
||||
reap()
|
||||
record = getattr(backend, "_quarantine", None)
|
||||
if record is not None:
|
||||
raise quarantine_refusal(record)
|
||||
|
||||
from backends.ezdxf_backend import effective_dimvar
|
||||
|
||||
try:
|
||||
height = float(effective_dimvar(doc, "dimtxt"))
|
||||
separator = int(effective_dimvar(doc, "dimdsep"))
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
issues: list[Issue] = []
|
||||
if height < ISO_3098_MIN_TEXT_MM:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
|
||||
f"ISO 128 set {sorted(allowed)}."
|
||||
f"Dimension text is {height:g} mm; ISO 3098 sets the minimum "
|
||||
f"lettering height at {ISO_3098_MIN_TEXT_MM} mm."
|
||||
),
|
||||
detail={"layer": lyr.name, "lineweight": lw},
|
||||
))
|
||||
detail={"dimtxt": height, "minimum": ISO_3098_MIN_TEXT_MM},
|
||||
)
|
||||
)
|
||||
# DIMDSEP 0 is not "unset" — ezdxf's `get_decimal_separator` renders it as a
|
||||
# comma — so it is the violation, not an excuse to skip the check.
|
||||
marker = "," if separator == 0 else chr(separator)
|
||||
if marker != ".":
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(f"Dimension decimal marker is {marker!r}; ISO 129 uses a point."),
|
||||
detail={"dimdsep": separator},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -74,22 +166,26 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
try:
|
||||
layers = await backend.layer_list()
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
for lyr in layers:
|
||||
expected = _EXPECTED_LAYER_COLOR.get(lyr.name)
|
||||
if expected is None:
|
||||
continue # unknown layer — caller's responsibility
|
||||
continue # unknown layer — caller's responsibility
|
||||
if int(lyr.color) != int(expected):
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="layer_color",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' has color {lyr.color}; "
|
||||
f"engineering standard expects {expected}."
|
||||
),
|
||||
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="layer_color",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' has color {lyr.color}; "
|
||||
f"engineering standard expects {expected}."
|
||||
),
|
||||
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -102,22 +198,28 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""
|
||||
try:
|
||||
ents = await backend.entity_list(limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
|
||||
if not leftover:
|
||||
return []
|
||||
return [Issue(
|
||||
severity="error",
|
||||
focus="construction_left",
|
||||
message=(
|
||||
f"{len(leftover)} entity/entities still on a construction layer. "
|
||||
"Call construction_clear() before drawing_finalize."
|
||||
),
|
||||
handles=[e.handle for e in leftover[:50]],
|
||||
detail={"count": len(leftover),
|
||||
"layers": sorted({e.layer for e in leftover if e.layer})},
|
||||
)]
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
focus="construction_left",
|
||||
message=(
|
||||
f"{len(leftover)} entity/entities still on a construction layer. "
|
||||
"Call construction_clear() before drawing_finalize."
|
||||
),
|
||||
handles=[e.handle for e in leftover[:50]],
|
||||
detail={
|
||||
"count": len(leftover),
|
||||
"layers": sorted({e.layer for e in leftover if e.layer}),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
@@ -126,6 +228,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
tol = 0.5 # mm — generous; tighten via PlanSpec.notes later
|
||||
try:
|
||||
lines = await backend.entity_list(type_filter="LINE", limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
endpoints: list[tuple[str, tuple[float, float]]] = []
|
||||
@@ -150,16 +254,18 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="untrimmed_corner",
|
||||
message=(
|
||||
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
|
||||
"likely an untrimmed corner (use entity_trim or entity_fillet)."
|
||||
),
|
||||
handles=[h1, h2],
|
||||
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="untrimmed_corner",
|
||||
message=(
|
||||
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
|
||||
"likely an untrimmed corner (use entity_trim or entity_fillet)."
|
||||
),
|
||||
handles=[h1, h2],
|
||||
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -167,6 +273,8 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""Two LINEs with identical endpoints (in either direction) are duplicates."""
|
||||
try:
|
||||
lines = await backend.entity_list(type_filter="LINE", limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
seen: dict[tuple, str] = {}
|
||||
@@ -180,12 +288,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
|
||||
k2 = (k1[2], k1[3], k1[0], k1[1])
|
||||
for key in (k1, k2):
|
||||
if key in seen:
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="duplicate_entities",
|
||||
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
|
||||
handles=[seen[key], ln.handle],
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="duplicate_entities",
|
||||
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
|
||||
handles=[seen[key], ln.handle],
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
seen[k1] = ln.handle
|
||||
@@ -223,6 +333,8 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
|
||||
tol = 5.0 # mm
|
||||
try:
|
||||
ents = await backend.entity_list(limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
dim_points: list[tuple[str, tuple[float, float]]] = []
|
||||
@@ -243,12 +355,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="dim_overlap",
|
||||
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
|
||||
handles=[h1, h2],
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="dim_overlap",
|
||||
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
|
||||
handles=[h1, h2],
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -267,19 +381,21 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
||||
missing = sorted(referenced - defined)
|
||||
if not missing:
|
||||
return []
|
||||
return [Issue(
|
||||
severity="error",
|
||||
focus="gdt",
|
||||
message=(
|
||||
f"Feature control frame(s) reference datum(s) {missing} with no "
|
||||
"matching datum feature. Add draw_datum_feature for each datum."
|
||||
),
|
||||
detail={
|
||||
"missing_datums": missing,
|
||||
"defined": sorted(defined),
|
||||
"referenced": sorted(referenced),
|
||||
},
|
||||
)]
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
focus="gdt",
|
||||
message=(
|
||||
f"Feature control frame(s) reference datum(s) {missing} with no "
|
||||
"matching datum feature. Add draw_datum_feature for each datum."
|
||||
),
|
||||
detail={
|
||||
"missing_datums": missing,
|
||||
"defined": sorted(defined),
|
||||
"referenced": sorted(referenced),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
_FOCUS_DISPATCH = {
|
||||
@@ -303,11 +419,13 @@ async def run_critique(
|
||||
for f in foci:
|
||||
check = _FOCUS_DISPATCH.get(f)
|
||||
if check is None:
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus=f, # type: ignore[arg-type]
|
||||
message=f"Unknown critique focus '{f}' (closed enum).",
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus=f, # type: ignore[arg-type]
|
||||
message=f"Unknown critique focus '{f}' (closed enum).",
|
||||
)
|
||||
)
|
||||
continue
|
||||
issues.extend(await check(backend))
|
||||
return issues
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+45
-41
@@ -13,52 +13,52 @@ log = logging.getLogger(__name__)
|
||||
|
||||
ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
|
||||
# (name, color, linetype, lineweight, description)
|
||||
("0", 7, "Continuous", 0.25, "default"),
|
||||
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
|
||||
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
|
||||
("CENTER", 1, "CENTER", 0.18, "centerlines"),
|
||||
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
|
||||
("DIM", 2, "Continuous", 0.18, "dimensions"),
|
||||
("TEXT", 7, "Continuous", 0.25, "annotations"),
|
||||
("HATCH", 8, "Continuous", 0.13, "section hatching"),
|
||||
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
|
||||
("0", 7, "Continuous", 0.25, "default"),
|
||||
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
|
||||
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
|
||||
("CENTER", 1, "CENTER", 0.18, "centerlines"),
|
||||
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
|
||||
("DIM", 2, "Continuous", 0.18, "dimensions"),
|
||||
("TEXT", 7, "Continuous", 0.25, "annotations"),
|
||||
("HATCH", 8, "Continuous", 0.13, "section hatching"),
|
||||
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
|
||||
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
|
||||
]
|
||||
|
||||
|
||||
# P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice).
|
||||
PID_LAYERS: list[tuple[str, int, str, float, str]] = [
|
||||
("0", 7, "Continuous", 0.25, "default"),
|
||||
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
|
||||
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
|
||||
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
|
||||
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
|
||||
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
|
||||
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
|
||||
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
|
||||
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
|
||||
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
|
||||
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
|
||||
("DIM", 2, "Continuous", 0.18, "dimensions"),
|
||||
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
|
||||
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
|
||||
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
|
||||
("0", 7, "Continuous", 0.25, "default"),
|
||||
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
|
||||
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
|
||||
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
|
||||
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
|
||||
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
|
||||
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
|
||||
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
|
||||
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
|
||||
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
|
||||
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
|
||||
("DIM", 2, "Continuous", 0.18, "dimensions"),
|
||||
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
|
||||
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
|
||||
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
|
||||
]
|
||||
|
||||
|
||||
# ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1).
|
||||
# This is a *starter* set — extend as needed per discipline.
|
||||
ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [
|
||||
("0", 7, "Continuous", 0.25, "default"),
|
||||
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
|
||||
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
|
||||
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
|
||||
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
|
||||
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
|
||||
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
|
||||
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
|
||||
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
|
||||
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
|
||||
("0", 7, "Continuous", 0.25, "default"),
|
||||
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
|
||||
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
|
||||
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
|
||||
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
|
||||
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
|
||||
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
|
||||
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
|
||||
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
|
||||
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
|
||||
# "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses
|
||||
# M-DIMEN-T-N / M-CONST-E-N).
|
||||
LAYER_ROLES: dict[str, dict[str, str]] = {
|
||||
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
|
||||
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
|
||||
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
|
||||
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
|
||||
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
|
||||
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ def resolve_role_layer(layer_set_id: str | None, role: str) -> str:
|
||||
roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"])
|
||||
return roles.get(role, LAYER_ROLES["mech"][role])
|
||||
|
||||
|
||||
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
|
||||
|
||||
|
||||
@@ -115,14 +116,14 @@ async def ensure_engineering_layers(backend: AutoCADBackend) -> dict[str, str]:
|
||||
|
||||
|
||||
async def apply_layer_set(
|
||||
backend: AutoCADBackend, standard: str = "mech",
|
||||
backend: AutoCADBackend,
|
||||
standard: str = "mech",
|
||||
) -> dict[str, str]:
|
||||
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
|
||||
layer_set = LAYER_SET_REGISTRY.get(standard)
|
||||
if layer_set is None:
|
||||
raise RuntimeError(
|
||||
f"Unknown layer set '{standard}'. "
|
||||
f"Choose one of: {sorted(LAYER_SET_REGISTRY)}"
|
||||
f"Unknown layer set '{standard}'. Choose one of: {sorted(LAYER_SET_REGISTRY)}"
|
||||
)
|
||||
await ensure_standard_linetypes(backend)
|
||||
|
||||
@@ -139,7 +140,10 @@ async def apply_layer_set(
|
||||
continue
|
||||
try:
|
||||
await backend.layer_create(
|
||||
name=name, color=color, linetype=linetype, lineweight=lineweight,
|
||||
name=name,
|
||||
color=color,
|
||||
linetype=linetype,
|
||||
lineweight=lineweight,
|
||||
)
|
||||
results[name] = "created"
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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.
|
||||
|
||||
A closed loop may arrive either with or without a repeated closing vertex —
|
||||
`ezdxf.path.flattening()` emits one, raw polyline points do not — and both
|
||||
spellings must give the same answer. They did not: the adjacency guard
|
||||
below excludes neighbours *by index*, so with the duplicate present the
|
||||
first and last real edges were cross-tested even though they share a point,
|
||||
and every plain hatch boundary came back flagged.
|
||||
"""
|
||||
vertices = list(vertices)
|
||||
if len(vertices) > 1 and math.dist(vertices[0][:2], vertices[-1][:2]) < _EPS:
|
||||
vertices = vertices[:-1]
|
||||
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":
|
||||
|
||||
+131
-91
@@ -12,8 +12,13 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
REAL_DIMENSION_TYPES = {
|
||||
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
|
||||
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
|
||||
"DIMENSION",
|
||||
"DIMLINEAR",
|
||||
"DIMALIGNED",
|
||||
"DIMANGULAR",
|
||||
"DIMRADIUS",
|
||||
"DIMDIAMETER",
|
||||
"DIMORDINATE",
|
||||
}
|
||||
|
||||
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
|
||||
@@ -35,7 +40,7 @@ _TITLE_LAYERS = {"TITLE", "TITLEBLOCK"}
|
||||
class ValidationFinding:
|
||||
"""One issue surfaced by the validator."""
|
||||
|
||||
severity: str # "error" | "warning" | "info"
|
||||
severity: str # "error" | "warning" | "info"
|
||||
code: str
|
||||
message: str
|
||||
detail: dict = field(default_factory=dict)
|
||||
@@ -84,40 +89,47 @@ class DrawingValidator:
|
||||
try:
|
||||
ents = await backend.entity_list(limit=10000)
|
||||
text_likes = [
|
||||
e for e in ents
|
||||
e
|
||||
for e in ents
|
||||
if e.type in ("TEXT", "MTEXT")
|
||||
and DIMENSION_LIKE_TEXT.search(
|
||||
getattr(e, "properties", {}).get("text", "") or ""
|
||||
)
|
||||
and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
|
||||
]
|
||||
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
|
||||
if text_likes:
|
||||
findings.append(ValidationFinding(
|
||||
"warning",
|
||||
"fake_dimension_text",
|
||||
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
|
||||
"(Ø, ±, R, etc.) — use dimension_* tools instead",
|
||||
{
|
||||
"sample_handles": [t.handle for t in text_likes[:5]],
|
||||
"real_dim_count": len(real_dims),
|
||||
},
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"fake_dimension_text",
|
||||
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
|
||||
"(Ø, ±, R, etc.) — use dimension_* tools instead",
|
||||
{
|
||||
"sample_handles": [t.handle for t in text_likes[:5]],
|
||||
"real_dim_count": len(real_dims),
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
findings.append(ValidationFinding(
|
||||
"warning", "dim_check_failed", str(exc),
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"dim_check_failed",
|
||||
str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
# 2 — hidden orphans
|
||||
try:
|
||||
hidden = [e for e in ents if not e.visible]
|
||||
if hidden:
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"hidden_orphans",
|
||||
f"{len(hidden)} entities have visible=False — hide-fallback "
|
||||
"is forbidden, use entity_delete",
|
||||
{"sample_handles": [h.handle for h in hidden[:5]]},
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"hidden_orphans",
|
||||
f"{len(hidden)} entities have visible=False — hide-fallback "
|
||||
"is forbidden, use entity_delete",
|
||||
{"sample_handles": [h.handle for h in hidden[:5]]},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -126,45 +138,53 @@ class DrawingValidator:
|
||||
lts = {x.lower() for x in await backend.linetype_list()}
|
||||
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
|
||||
if missing:
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"linetypes_missing",
|
||||
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
|
||||
{"missing": missing},
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"linetypes_missing",
|
||||
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
|
||||
{"missing": missing},
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
findings.append(ValidationFinding(
|
||||
"warning", "linetype_check_failed", str(exc),
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"linetype_check_failed",
|
||||
str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
# 4 — gear views (heuristic, only if expected demands)
|
||||
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
|
||||
bore_circles = [
|
||||
e for e in ents
|
||||
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
|
||||
]
|
||||
bore_circles = [e for e in ents if e.type == "CIRCLE" and e.layer == "GEOMETRY"]
|
||||
if expected.get("must_have_bore") and len(bore_circles) < 1:
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"bore_missing",
|
||||
"Expected bore but no circle on GEOMETRY layer found",
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"bore_missing",
|
||||
"Expected bore but no circle on GEOMETRY layer found",
|
||||
)
|
||||
)
|
||||
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
|
||||
# on GEOMETRY — requiring `closed` here made this a permanent
|
||||
# false-negative. Match a small (3-6 point) polyline, open or closed.
|
||||
keyway_polys = [
|
||||
e for e in ents
|
||||
e
|
||||
for e in ents
|
||||
if e.type in ("LWPOLYLINE", "POLYLINE")
|
||||
and e.layer == "GEOMETRY"
|
||||
and 3 <= len(e.properties.get("points", [])) <= 6
|
||||
]
|
||||
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
|
||||
findings.append(ValidationFinding(
|
||||
"warning",
|
||||
"keyway_section_unverified",
|
||||
"Could not confirm keyway via heuristic "
|
||||
"(small 3-6 point polyline on GEOMETRY)",
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"keyway_section_unverified",
|
||||
"Could not confirm keyway via heuristic "
|
||||
"(small 3-6 point polyline on GEOMETRY)",
|
||||
)
|
||||
)
|
||||
|
||||
# 5 — title consistency
|
||||
helix = expected.get("helix_angle")
|
||||
@@ -190,19 +210,23 @@ class DrawingValidator:
|
||||
has_spur = bool(_SPUR_RE.search(joined))
|
||||
has_helical = bool(_HELICAL_RE.search(joined))
|
||||
if has_spur and has_helical:
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"title_helical_spur",
|
||||
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
|
||||
"'HELICAL GEAR' or 'SPUR GEAR'",
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"title_helical_spur",
|
||||
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
|
||||
"'HELICAL GEAR' or 'SPUR GEAR'",
|
||||
)
|
||||
)
|
||||
elif has_spur:
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"title_says_spur_for_helical",
|
||||
f"Title contains SPUR but part is helical (helix_angle={helix})",
|
||||
{"sample": joined[:200]},
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"title_says_spur_for_helical",
|
||||
f"Title contains SPUR but part is helical (helix_angle={helix})",
|
||||
{"sample": joined[:200]},
|
||||
)
|
||||
)
|
||||
|
||||
# 6 — saved to disk
|
||||
try:
|
||||
@@ -210,46 +234,62 @@ class DrawingValidator:
|
||||
saved_attr = getattr(info, "saved", None)
|
||||
full_path = getattr(info, "full_path", "") or ""
|
||||
if not saved_attr:
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"not_saved",
|
||||
"Drawing has unsaved changes — call drawing_save before finalize",
|
||||
{"full_path": full_path},
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"not_saved",
|
||||
"Drawing has unsaved changes — call drawing_save before finalize",
|
||||
{"full_path": full_path},
|
||||
)
|
||||
)
|
||||
elif full_path and not Path(full_path).exists():
|
||||
findings.append(ValidationFinding(
|
||||
"error",
|
||||
"file_missing",
|
||||
f"Drawing reports saved=True but file not on disk: {full_path}",
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"error",
|
||||
"file_missing",
|
||||
f"Drawing reports saved=True but file not on disk: {full_path}",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
findings.append(ValidationFinding(
|
||||
"warning", "save_check_failed", str(exc),
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"save_check_failed",
|
||||
str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
# 7 — screenshot
|
||||
try:
|
||||
shot = await backend.view_screenshot()
|
||||
if not shot:
|
||||
findings.append(ValidationFinding(
|
||||
"info",
|
||||
"screenshot_unavailable",
|
||||
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"info",
|
||||
"screenshot_unavailable",
|
||||
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
findings.append(ValidationFinding(
|
||||
"info", "screenshot_failed", str(exc),
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"info",
|
||||
"screenshot_failed",
|
||||
str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
# 8 — dwg path returned
|
||||
try:
|
||||
info = await backend.drawing_info()
|
||||
if not getattr(info, "full_path", ""):
|
||||
findings.append(ValidationFinding(
|
||||
"warning",
|
||||
"no_path",
|
||||
"drawing_info.full_path is empty",
|
||||
))
|
||||
findings.append(
|
||||
ValidationFinding(
|
||||
"warning",
|
||||
"no_path",
|
||||
"drawing_info.full_path is empty",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+106
-10
@@ -1,38 +1,126 @@
|
||||
[project]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.3.0"
|
||||
version = "1.5.1"
|
||||
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",
|
||||
# pywin32 publishes Windows wheels and no sdist, so without this marker
|
||||
# the extra cannot be resolved at all on Linux or macOS -- pip dies at
|
||||
# "no matching distribution" before installing anything. The marker
|
||||
# makes `pip install autocad-mcp-pro[com]` a no-op there instead of a
|
||||
# hard failure, which is the honest outcome: the COM backend needs
|
||||
# Windows, and the server already falls back to ezdxf.
|
||||
'pywin32>=306; sys_platform == "win32"',
|
||||
# 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 = [
|
||||
"pywin32>=306",
|
||||
# Same marker as `com` above, and for the same reason: `[full]` is the
|
||||
# extra this project recommends to contributors, and it was
|
||||
# uninstallable on every non-Windows machine.
|
||||
'pywin32>=306; sys_platform == "win32"',
|
||||
"Pillow>=10.0",
|
||||
"matplotlib>=3.8",
|
||||
]
|
||||
|
||||
[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 +130,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,214 @@
|
||||
"""Hold the numbers in the documents equal to the numbers in the tree.
|
||||
|
||||
Every figure this checks has been wrong at least once, and the expensive one was
|
||||
wrong in three files at the same time: `benchmarks/README.md` and `CLAUDE.md`
|
||||
described the A/B suite as 24 checks at 87.5% while the suite held 26 and the
|
||||
published report said 80.8%. Nothing caught it because each file was edited on
|
||||
its own and nobody re-read the other two.
|
||||
|
||||
The rule is the one the release snapshot already follows: a number a human
|
||||
types is a number that rots, so derive it and fail when the prose disagrees.
|
||||
|
||||
python scripts/check_doc_numbers.py # report and exit non-zero on drift
|
||||
python scripts/check_doc_numbers.py --list # show every derived value
|
||||
|
||||
`tests/test_release_consistency.py` runs this as a gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _json(relative: str) -> dict:
|
||||
return json.loads(_read(relative))
|
||||
|
||||
|
||||
def _collected_tests() -> int:
|
||||
"""How many tests pytest actually collects.
|
||||
|
||||
On the same README line as tools/resources/prompts, and the only number on
|
||||
it that nothing derived -- so it drifted by 95 while its neighbours stayed
|
||||
exact. Collection only; running the suite here would make the gate cost a
|
||||
minute.
|
||||
"""
|
||||
env = dict(os.environ, AUTOCAD_MCP_BACKEND="ezdxf")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
)
|
||||
match = re.search(r"(\d+) tests collected", result.stdout)
|
||||
if not match:
|
||||
raise SystemExit(
|
||||
"check_doc_numbers: could not read a collected-test count from pytest.\n"
|
||||
+ result.stdout[-2000:]
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def derived() -> dict[str, object]:
|
||||
"""Every number a document is allowed to quote, computed from the tree."""
|
||||
sys.path.insert(0, str(ROOT))
|
||||
inventory = _json("docs/tool-inventory.json")["totals"]
|
||||
ab = _json("benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json")["summary"]
|
||||
perf = {
|
||||
w["workload_id"]: w["wall_ms"]
|
||||
for w in _json("benchmarks/results/published/perf-ezdxf.json")["workloads"]
|
||||
}
|
||||
|
||||
from benchmarks import correctness_suite
|
||||
from benchmarks.tasks_v3 import TASKS_V3
|
||||
|
||||
return {
|
||||
"collected_tests": _collected_tests(),
|
||||
"tools": inventory["tools"],
|
||||
"resources": inventory["resources"],
|
||||
"prompts": inventory["prompts"],
|
||||
"groups": inventory["groups"],
|
||||
"ab_total": ab["total"],
|
||||
"ab_old_pass": ab["old_pass"],
|
||||
"ab_old_pct": ab["old_pct"],
|
||||
"ab_fixed": ab["fixed"],
|
||||
"ab_regressed": ab["regressed"],
|
||||
"suite_checks": len(correctness_suite.CHECKS),
|
||||
"v3_tasks": len(TASKS_V3),
|
||||
"perf_ms": perf,
|
||||
}
|
||||
|
||||
|
||||
#: (relative path, human label, regex over the file, callable -> expected value)
|
||||
#:
|
||||
#: Each pattern captures the number as group 1. A pattern that matches nothing is
|
||||
#: itself a failure: the sentence it was written for has moved, and a silent
|
||||
#: no-op gate is worse than no gate.
|
||||
CHECKS: list[tuple[str, str, str, str]] = [
|
||||
("README.md", "release snapshot tools", r"release snapshot:\*\*\s*(\d+) tools", "tools"),
|
||||
(
|
||||
"README.md",
|
||||
"release snapshot resources",
|
||||
r"release snapshot:.*?(\d+) resources",
|
||||
"resources",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"release snapshot prompts",
|
||||
r"release snapshot:.*?(\d+) prompt templates",
|
||||
"prompts",
|
||||
),
|
||||
("README.md", "A/B suite size", r"(\d+) deterministic headless checks", "ab_total"),
|
||||
("README.md", "A/B baseline pass", r"v1\.5\.0 \*\(baseline\)\* \| (\d+) / \d+", "ab_old_pass"),
|
||||
("README.md", "A/B suite denominator", r"v1\.5\.0 \*\(baseline\)\* \| \d+ / (\d+)", "ab_total"),
|
||||
("benchmarks/README.md", "A/B suite size", r"(\d+) checks, ezdxf backend", "ab_total"),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B baseline pass",
|
||||
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| (\d+) / \d+",
|
||||
"ab_old_pass",
|
||||
),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B suite denominator",
|
||||
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / (\d+)",
|
||||
"ab_total",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"release snapshot collected tests",
|
||||
r"release snapshot:.*?(\d+) collected tests",
|
||||
"collected_tests",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"A/B baseline pass rate",
|
||||
r"v1\.5\.0 \*\(baseline\)\* \| \d+ / \d+ \| ([\d.]+) %",
|
||||
"ab_old_pct",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"A/B fixed count",
|
||||
r"v1\.5\.1\*\* \*\(this release\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
|
||||
"ab_fixed",
|
||||
),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B baseline pass rate",
|
||||
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / \d+ \| ([\d.]+) %",
|
||||
"ab_old_pct",
|
||||
),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B fixed count",
|
||||
r"\*\*v1\.5\.1\*\* \(this release\) \| \d+ / \d+ \| \d+ % \| (\d+)",
|
||||
"ab_fixed",
|
||||
),
|
||||
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
|
||||
("CLAUDE.md", "v3 task count", r"the current set \((\d+) tasks", "v3_tasks"),
|
||||
]
|
||||
|
||||
|
||||
def run(verbose: bool = False) -> int:
|
||||
values = derived()
|
||||
|
||||
if values["suite_checks"] != values["ab_total"]:
|
||||
print(
|
||||
f"DRIFT correctness_suite.py has {values['suite_checks']} checks but the published "
|
||||
f"A/B report was run over {values['ab_total']} -- regenerate the report:\n"
|
||||
f" python benchmarks/compare_versions.py v1.5.0 "
|
||||
f"--json benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json"
|
||||
)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
for key, value in values.items():
|
||||
print(f" {key:20s} {value}")
|
||||
print()
|
||||
|
||||
failures = 0
|
||||
for relative, label, pattern, key in CHECKS:
|
||||
text = _read(relative)
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
expected = values[key]
|
||||
if match is None:
|
||||
print(
|
||||
f"DRIFT {relative}: the sentence carrying '{label}' no longer matches its pattern"
|
||||
)
|
||||
failures += 1
|
||||
continue
|
||||
found = type(expected)(match.group(1))
|
||||
if found != expected:
|
||||
print(f"DRIFT {relative}: {label} says {found}, the tree says {expected}")
|
||||
failures += 1
|
||||
elif verbose:
|
||||
print(f" ok {relative:24s} {label:24s} {found}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{failures} document number(s) disagree with the tree.")
|
||||
return 1
|
||||
print(f"ok: {len(CHECKS)} document numbers match the tree")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--list", action="store_true", help="Print every derived value")
|
||||
return run(verbose=parser.parse_args().list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Render the drawing the README shows, using only the server's own tools.
|
||||
|
||||
A CAD README that shows nothing but bar charts is asking to be taken on faith.
|
||||
This produces the one asset that is not a claim: an actual sheet, built through
|
||||
the same backend methods the MCP tools call, in the order CLAUDE.md's premium
|
||||
workflow prescribes -- ISO layers, deterministic gear geometry, a keyed bore,
|
||||
ISO 129 dimensions, an ISO 7200 title block -- and rendered headlessly.
|
||||
|
||||
Reproduce:
|
||||
|
||||
AUTOCAD_MCP_BACKEND=ezdxf python scripts/render_readme_showcase.py
|
||||
|
||||
Nothing here is decoration: every line on the sheet comes from a tool the
|
||||
server exposes, so if a tool regresses the picture changes with it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-showcase.png"
|
||||
|
||||
#: A 24-tooth, module-6 spur gear. Sized to fill the A3 drawing area, because
|
||||
#: the render crops to the sheet border: geometry that fills the border gets
|
||||
#: the pixels, and involute flanks that do not resolve prove nothing.
|
||||
GEAR_MODULE = 6.0
|
||||
GEAR_TEETH = 24
|
||||
BORE_DIAMETER = 40.0
|
||||
KEYWAY_WIDTH = 12.0
|
||||
KEYWAY_DEPTH = 3.3
|
||||
FACE_WIDTH = 46.0
|
||||
GEAR_CENTER = (135.0, 172.0)
|
||||
SECTION_X = 300.0
|
||||
|
||||
#: No DIMSCALE here on purpose. `drawing_settings({"dimscale": ...})` sets the
|
||||
#: header variable and reports success, but ezdxf renders dimensions from the
|
||||
#: dimstyle table entry, so headlessly it changes nothing (measured). Setting it
|
||||
#: in this script would put a call in the README's own showcase that does not do
|
||||
#: what it looks like it does.
|
||||
|
||||
|
||||
async def build(backend) -> dict:
|
||||
from engineering.gear import draw_gear_section_aa, draw_spur_gear_front_view
|
||||
from engineering.titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||
|
||||
await backend.drawing_new()
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await backend.drawing_settings({"units": "mm", "linear_precision": 2})
|
||||
|
||||
gear = await draw_spur_gear_front_view(
|
||||
backend,
|
||||
module=GEAR_MODULE,
|
||||
teeth=GEAR_TEETH,
|
||||
center=GEAR_CENTER,
|
||||
bore_diameter=BORE_DIAMETER,
|
||||
keyway_width=KEYWAY_WIDTH,
|
||||
keyway_depth=KEYWAY_DEPTH,
|
||||
)
|
||||
|
||||
# `gear_metadata` is the geometry record, not the handle map the front view
|
||||
# returns -- the section needs radii, not handles.
|
||||
await draw_gear_section_aa(
|
||||
backend,
|
||||
gear_metadata=gear["metadata"],
|
||||
x_offset=SECTION_X,
|
||||
face_width=FACE_WIDTH,
|
||||
)
|
||||
|
||||
# ISO 129 dimensions, and a real ISO 286 fit on the bore -- "H7" is the
|
||||
# whole reason a drafter trusts a generated hole. `fit=` is resolved by the
|
||||
# tool layer, so the deviations are looked up here the same way it does
|
||||
# rather than typed in as text.
|
||||
from engineering.fits import fit_lookup
|
||||
|
||||
cx, cy = GEAR_CENTER
|
||||
pitch_radius = GEAR_MODULE * GEAR_TEETH / 2.0
|
||||
outer_radius = pitch_radius + GEAR_MODULE
|
||||
await backend.dimension_linear(
|
||||
cx - pitch_radius,
|
||||
cy,
|
||||
cx + pitch_radius,
|
||||
cy,
|
||||
cx,
|
||||
cy - outer_radius - 22.0,
|
||||
layer="DIM",
|
||||
)
|
||||
# dimension_diameter takes the two ENDS OF A DIAMETER, not the centre and a
|
||||
# point on the circle. Passing the latter halves the measured size, which is
|
||||
# how the first draft of this sheet ended up calling a 40 mm bore 20.
|
||||
bore_fit = fit_lookup("H7", BORE_DIAMETER)
|
||||
await backend.dimension_diameter(
|
||||
cx - BORE_DIAMETER / 2.0,
|
||||
cy,
|
||||
cx + BORE_DIAMETER / 2.0,
|
||||
cy,
|
||||
leader_length=34.0,
|
||||
layer="DIM",
|
||||
tol_upper=bore_fit.upper_mm,
|
||||
tol_lower=-bore_fit.lower_mm,
|
||||
tol_mode="deviation",
|
||||
text_override=f"<> {bore_fit.code}",
|
||||
)
|
||||
|
||||
await apply_iso_a3_titleblock(
|
||||
backend,
|
||||
metadata=TitleBlockMetadata(
|
||||
title=f"SPUR GEAR m{GEAR_MODULE:g} z{GEAR_TEETH}",
|
||||
drawing_no="AMC-1500-001",
|
||||
part_no="1500-001",
|
||||
material="C45E",
|
||||
scale="1:1",
|
||||
drawn_by="autocad-mcp-pro",
|
||||
checked_by="drawing_critique",
|
||||
date="2026-08-06",
|
||||
revision="A",
|
||||
),
|
||||
origin=(0.0, 0.0),
|
||||
)
|
||||
|
||||
issues = await backend.drawing_critique(focus=None)
|
||||
info = await backend.drawing_info()
|
||||
return {"issues": len(issues), "entities": info.entity_count, "gear": gear}
|
||||
|
||||
|
||||
async def main(output: Path) -> int:
|
||||
os.environ.setdefault("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
try:
|
||||
summary = await build(backend)
|
||||
png = await backend.view_screenshot(overlay_handles=False)
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
if not png:
|
||||
print("render produced no image", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(png)
|
||||
print(
|
||||
f"wrote {output} ({len(png)} bytes) - "
|
||||
f"{summary['entities']} entities, {summary['issues']} critique issues"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main(build_parser().parse_args().output)))
|
||||
@@ -61,10 +61,12 @@ async def _run() -> int:
|
||||
print(f"ok (center=({cx},{cy}), r=5)")
|
||||
|
||||
print("[6/9] entity_select_smart ...", end=" ")
|
||||
geom = await backend.entity_select_smart({
|
||||
"type": "LINE",
|
||||
"layer": "GEOMETRY",
|
||||
})
|
||||
geom = await backend.entity_select_smart(
|
||||
{
|
||||
"type": "LINE",
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
)
|
||||
assert len(geom) >= 2, len(geom)
|
||||
print(f"ok ({len(geom)} lines selected)")
|
||||
|
||||
|
||||
+239
-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,115 @@ 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.
|
||||
#
|
||||
# That "the CHANNEL" was singular was wrong, and it took an audit to notice.
|
||||
# `_ensure_linetype_loaded` and `ComBackend.linetype_load` reach SendCommand
|
||||
# too, carrying a caller's linetype name and file path, and neither passed
|
||||
# through anything here — so the denylist below was guarding one door of two.
|
||||
# `sanitize_symbol_name` / `sanitize_macro_argument` at the end of this module
|
||||
# close the other. Before adding a third SendCommand site, guard it there and
|
||||
# amend this paragraph rather than this one:
|
||||
#
|
||||
# * `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 +289,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 +314,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 +340,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. "
|
||||
@@ -225,3 +357,76 @@ def sanitize_lisp(expression: str) -> str:
|
||||
)
|
||||
|
||||
return expression
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Symbol-table names interpolated into a SendCommand macro
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and
|
||||
# `sanitize_lisp` were the only routes into SendCommand. They were not.
|
||||
# `_ensure_linetype_loaded` builds `_-LINETYPE _LOAD {name} {file}` from a
|
||||
# caller's `linetype=` argument, and `ComBackend.linetype_load` adds a
|
||||
# caller-supplied path. SendCommand takes a whole macro and treats a newline
|
||||
# and `;` as segment separators, so either string could append commands that
|
||||
# the 36-verb denylist never sees.
|
||||
#
|
||||
# The guard is the DXF symbol-name rule rather than a second blocklist. AutoCAD
|
||||
# already forbids these characters in a table name, so nothing this rejects was
|
||||
# ever a loadable linetype, and the injection characters fall out of the rule
|
||||
# instead of having to be enumerated and kept up to date.
|
||||
|
||||
#: Characters DXF forbids in a symbol-table name, plus the whitespace that ends
|
||||
#: a SendCommand segment.
|
||||
_FORBIDDEN_SYMBOL_CHARS = set('<>/\\":;?*|,=`') | set("\n\r\t")
|
||||
|
||||
#: Long enough for any real linetype, short enough not to be a payload.
|
||||
_MAX_SYMBOL_NAME = 255
|
||||
|
||||
|
||||
def sanitize_symbol_name(name: str, *, kind: str = "symbol") -> str:
|
||||
"""Return `name` if it is a legal DXF symbol-table name, else refuse.
|
||||
|
||||
Used at every point where a caller-controlled name is interpolated into an
|
||||
AutoCAD command macro. The refusal deliberately does not echo the rejected
|
||||
string back: an error message that a client logs or renders is a channel
|
||||
too, and a payload that reaches a log is a payload that reached something.
|
||||
"""
|
||||
candidate = (name or "").strip()
|
||||
if not candidate:
|
||||
raise ToolError(f"{kind} name rejected: empty.")
|
||||
if len(candidate) > _MAX_SYMBOL_NAME:
|
||||
raise ToolError(f"{kind} name rejected: longer than {_MAX_SYMBOL_NAME} characters.")
|
||||
offenders = sorted({c for c in candidate if c in _FORBIDDEN_SYMBOL_CHARS or ord(c) < 32})
|
||||
if offenders:
|
||||
shown = ", ".join(repr(c) for c in offenders)
|
||||
raise ToolError(
|
||||
f"{kind} name rejected: DXF symbol-table names cannot contain {shown}. "
|
||||
"This name would also end the AutoCAD command macro it is placed in."
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
#: What ends a segment inside a SendCommand macro. A path legitimately contains
|
||||
#: ':' and '\', so a file argument cannot be held to the symbol-name rule -- it
|
||||
#: gets this narrower one plus `validate_path`.
|
||||
_MACRO_SEPARATORS = set("\n\r;") | {chr(c) for c in range(32)}
|
||||
|
||||
|
||||
def sanitize_macro_argument(value: str, *, kind: str = "argument") -> str:
|
||||
"""Return `value` if it cannot end the AutoCAD command macro it sits in.
|
||||
|
||||
For arguments that are legitimately allowed the characters DXF forbids in a
|
||||
symbol name -- a file path, mostly. Narrower than `sanitize_symbol_name` on
|
||||
purpose: the goal is only that the string cannot start a second command.
|
||||
"""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate:
|
||||
raise ToolError(f"{kind} rejected: empty.")
|
||||
if any(c in _MACRO_SEPARATORS for c in candidate):
|
||||
raise ToolError(
|
||||
f"{kind} rejected: contains a newline, carriage return, semicolon or "
|
||||
"control character, which would end the AutoCAD command macro it is "
|
||||
"placed in and start another."
|
||||
)
|
||||
return candidate
|
||||
|
||||
+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.1",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "autocad-mcp-pro",
|
||||
"version": "1.5.1",
|
||||
"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 means no allowlist is enforced at all - traversal and system-directory checks still run, but nothing scopes the filesystem. Set this.",
|
||||
"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."""
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user