Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f2c3b575 | ||
|
|
d15068efc5 | ||
|
|
e04c814cb8 | ||
|
|
3cbe30df54 | ||
|
|
6d7c39e012 | ||
|
|
2f56a73093 | ||
|
|
9ec6c4a989 |
@@ -17,3 +17,11 @@ 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) | core | lean
|
||||
# core hides raw escape hatches and long-tail tools; lean is a curated
|
||||
# ~46-tool drafting/inspection core for clients with tight tool caps.
|
||||
TOOL_PROFILE=full
|
||||
|
||||
# Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported)
|
||||
ENABLE_3D=false
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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"
|
||||
|
||||
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
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install
|
||||
run: pip install -e ".[pdf]"
|
||||
- name: Run benchmark (ezdxf lane)
|
||||
run: |
|
||||
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,110 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install ruff
|
||||
- run: ruff check .
|
||||
- run: 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
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e ".[pdf]"
|
||||
pip install pytest pytest-asyncio pytest-cov
|
||||
- name: Run tests
|
||||
run: 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
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e ".[full]"
|
||||
pip install pytest pytest-asyncio pytest-cov
|
||||
- name: Run tests
|
||||
run: 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/*
|
||||
- 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,88 @@
|
||||
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*"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e ".[pdf]"
|
||||
pip install pytest pytest-asyncio ruff
|
||||
- run: ruff check .
|
||||
- run: pytest -q
|
||||
|
||||
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
|
||||
@@ -43,6 +43,12 @@ desktop.ini
|
||||
out/
|
||||
benchmarks/results/*
|
||||
!benchmarks/results/.gitkeep
|
||||
!benchmarks/results/published
|
||||
!benchmarks/results/published/**
|
||||
benchmarks/.competitors/
|
||||
*.dxf
|
||||
*.dwg
|
||||
*.bak
|
||||
|
||||
# Local editor/agent state
|
||||
.cursor/
|
||||
|
||||
+80
-1
@@ -7,7 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Next: capability-group schema routing, paper-space/layout production depth, and ISO 286 fit-table lookup._
|
||||
_Next (1.5): screenshot overlay + handle grounding v1, ezdxf redo (forward
|
||||
snapshots), COM `block_create_from_entities`, ISO 286 transition/interference
|
||||
hole letters (delta rule), titleblock on paper-space layouts._
|
||||
|
||||
## [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 ~131 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
|
||||
|
||||
## Running the Server
|
||||
|
||||
@@ -69,24 +69,30 @@ 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 (4 tools): `layout_list/create/set_current`, `viewport_create` (scaled model viewports); `drawing_export_pdf` takes an optional `layout` param. Viewport model-content projection is COM-only (`viewport_render` capability).
|
||||
16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS).
|
||||
|
||||
**Tool profiles:** `TOOL_PROFILE=lean|core|full` (default `full`) controls the advertised surface — `lean` ≈ 46 curated drafting tools, `core` hides raw escape hatches (`system_run_command/lisp`, low-level variables, long-tail tools). Applied in the lifespan; reported by `system_about`.
|
||||
|
||||
**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.
|
||||
|
||||
### Adding a New Tool
|
||||
@@ -110,8 +116,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
|
||||
|
||||
+4
-3
@@ -4,9 +4,10 @@ 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/
|
||||
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
@@ -15,6 +16,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"]
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Production-grade AutoCAD automation for AI agents—live through COM on Windows,
|
||||
or headless through ezdxf on any platform.
|
||||
|
||||
[](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](LICENSE)
|
||||
[](pyproject.toml)
|
||||
[](https://github.com/jlowin/fastmcp)
|
||||
@@ -12,8 +14,8 @@ 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.
|
||||
|
||||
> **v1.3 release snapshot:** 122 tools · 6 resources · 5 prompt templates ·
|
||||
> 415 collected tests. Runtime discovery through `system_about` is authoritative.
|
||||
> **v1.4 release snapshot:** 131 tools · 6 resources · 5 prompt templates ·
|
||||
> 474 collected tests. Runtime discovery through `system_about` is authoritative.
|
||||
|
||||
## Start in 60 seconds
|
||||
|
||||
@@ -86,12 +88,32 @@ 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.
|
||||
### Live-run lane — same harness, pinned competitors
|
||||
|
||||
Cross-project runtime scores will be published only after equivalent adapters
|
||||
execute the same task contract. The complete rubric, caveats, version A/B suite,
|
||||
and reproduction commands live in [`benchmarks/`](benchmarks/README.md).
|
||||

|
||||
|
||||
| Server | Pinned commit | Score | Pass | Coverage |
|
||||
|---|---|---:|---:|---:|
|
||||
| **autocad-mcp-pro** (this repo) | working tree | **100.0** | 10/10 | 100% |
|
||||
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | `11f7c47` | 50.0 | 5/10 | 50% |
|
||||
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | `95476a3` | 45.0 | 4/10 | 50% |
|
||||
|
||||
Same ten fixed tasks, same runner, headless ezdxf lane. Competitors are driven
|
||||
**black-box over MCP stdio** using their own documented tool contracts at the
|
||||
pinned commit, and every geometry claim is verified by re-opening the exported
|
||||
DXF with ezdxf in the harness — no competitor self-reporting. `unsupported`
|
||||
scores zero in the fixed matrix; per-task statuses with exact reasons are
|
||||
committed under [`benchmarks/results/published/`](benchmarks/results/published/).
|
||||
File-IPC (live AutoCAD) lanes run locally, not in CI.
|
||||
|
||||
```bash
|
||||
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.render_live_chart
|
||||
```
|
||||
|
||||
The complete rubric, caveats, version A/B suite, and reproduction commands live
|
||||
in [`benchmarks/`](benchmarks/README.md).
|
||||
|
||||
## Why this architecture holds up
|
||||
|
||||
@@ -346,3 +368,6 @@ Built from production drawing work, then made model-agnostic through MCP.
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
<!-- MCP registry ownership marker; must equal the "name" in server.json. -->
|
||||
mcp-name: io.github.u-c4n/autocad-mcp
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""AutoCAD MCP Pro – backend package."""
|
||||
|
||||
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
|
||||
from .com_backend import ComBackend
|
||||
from .ezdxf_backend import EzdxfBackend
|
||||
|
||||
+429
-146
File diff suppressed because it is too large
Load Diff
+582
-124
File diff suppressed because it is too large
Load Diff
+570
-125
File diff suppressed because it is too large
Load Diff
+44
-4
@@ -42,10 +42,50 @@ python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --
|
||||
python -m benchmarks.run_competitors --task table_mleader --task auditable_delivery --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 **10/10 (100.0)**. Repository stars and
|
||||
raw tool counts do not contribute to the score. Adapter registration lives in
|
||||
`competitors.yaml`.
|
||||
|
||||
## Live competitor lane (v1.4)
|
||||
|
||||
Two competitor adapters now execute the exact same task matrix, black-box over
|
||||
MCP stdio, against commits pinned in `competitors.yaml`:
|
||||
|
||||
| Server | Pinned | Score | Pass | Coverage |
|
||||
|---|---|---:|---:|---:|
|
||||
| autocad-mcp-pro (reference) | working tree | 100.0 | 10/10 | 100% |
|
||||
| beiming183-cloud/AutoCAD-MCP | `11f7c47e` | 50.0 | 5/10 | 50% |
|
||||
| puran-water/autocad-mcp | `95476a33` | 45.0 | 4/10 | 50% |
|
||||
|
||||
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 chart is
|
||||
regenerated from them:
|
||||
|
||||
```bash
|
||||
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.render_live_chart
|
||||
```
|
||||
|
||||
## Correctness A/B suite
|
||||
|
||||
|
||||
@@ -90,10 +90,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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Render the live-run benchmark lane (v2 fixed-task matrix) as a deterministic SVG.
|
||||
|
||||
Input: the published per-server reports under ``benchmarks/results/published/``
|
||||
(produced by ``benchmarks.run_competitors``, artifact paths sanitized to
|
||||
filenames). Output: a GitHub-readable horizontal bar chart showing the
|
||||
weighted score and task coverage per server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
|
||||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-livebench.svg"
|
||||
|
||||
|
||||
def load_reports(input_dir: Path) -> list[dict[str, Any]]:
|
||||
reports: list[dict[str, Any]] = []
|
||||
for path in sorted(input_dir.glob("*.json")):
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("schema_version") != "2.0":
|
||||
raise ValueError(f"{path.name}: unsupported schema_version")
|
||||
summary = data.get("summary") or {}
|
||||
if "score" not in summary or "coverage_percent" not in summary:
|
||||
raise ValueError(f"{path.name}: summary is missing score/coverage")
|
||||
reports.append(data)
|
||||
if not reports:
|
||||
raise SystemExit(f"no published reports found in {input_dir}")
|
||||
return reports
|
||||
|
||||
|
||||
def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
|
||||
) from exc
|
||||
|
||||
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-livebench-v2"
|
||||
reports = sorted(reports, key=lambda item: item["summary"]["score"])
|
||||
labels = [item["adapter"] for item in reports]
|
||||
scores = [item["summary"]["score"] for item in reports]
|
||||
coverage = [item["summary"]["coverage_percent"] for item in reports]
|
||||
passed = [item["summary"]["passed"] for item in reports]
|
||||
attempted = [item["summary"]["attempted"] for item in reports]
|
||||
colors = ["#0969da" if label == "autocad-mcp-pro" else "#6e7781" for label in labels]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 4.8), constrained_layout=True)
|
||||
fig.patch.set_facecolor("#f6f8fa")
|
||||
ax.set_facecolor("#f6f8fa")
|
||||
bars = ax.barh(labels, scores, color=colors, height=0.58)
|
||||
|
||||
ax.set_xlim(0, 100)
|
||||
ax.set_xlabel(
|
||||
"Weighted live-run score / 100 (fixed 10-task matrix)", color="#24292f", labelpad=10
|
||||
)
|
||||
ax.set_title(
|
||||
"Live-run benchmark - headless ezdxf lane",
|
||||
loc="left",
|
||||
fontsize=18,
|
||||
fontweight="bold",
|
||||
color="#24292f",
|
||||
pad=28,
|
||||
)
|
||||
ax.text(
|
||||
0,
|
||||
1.02,
|
||||
"Same 10 tasks, same harness, pinned competitor commits; artifacts verified by re-opening the DXF",
|
||||
transform=ax.transAxes,
|
||||
color="#57606a",
|
||||
fontsize=10,
|
||||
)
|
||||
|
||||
for bar, score, cov, ok, total in zip(bars, scores, coverage, passed, attempted, strict=True):
|
||||
ax.text(
|
||||
min(score + 1.2, 82),
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage)",
|
||||
va="center",
|
||||
color="#24292f",
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
ax.text(
|
||||
0,
|
||||
-0.22,
|
||||
"Unsupported tasks score 0 in the fixed matrix; per-task statuses and reasons are in benchmarks/results/published/.",
|
||||
transform=ax.transAxes,
|
||||
color="#57606a",
|
||||
fontsize=9,
|
||||
)
|
||||
ax.grid(axis="x", color="#d0d7de", linewidth=0.8, alpha=0.8)
|
||||
ax.set_axisbelow(True)
|
||||
ax.tick_params(axis="x", colors="#57606a")
|
||||
ax.tick_params(axis="y", colors="#24292f", 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,270 @@
|
||||
{
|
||||
"adapter": "autocad-mcp-pro",
|
||||
"adapter_metadata": {
|
||||
"backend": "ezdxf",
|
||||
"capabilities": {
|
||||
"backend": "ezdxf",
|
||||
"features": {
|
||||
"delivery": {
|
||||
"mode": "shared",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"drawing_2d": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"dxf": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"lisp": {
|
||||
"mode": null,
|
||||
"reason": "live_com_only",
|
||||
"supported": false
|
||||
},
|
||||
"mleader": {
|
||||
"mode": "composite",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"paper_space": {
|
||||
"mode": null,
|
||||
"reason": "planned_1.4",
|
||||
"supported": false
|
||||
},
|
||||
"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
|
||||
},
|
||||
"solid_3d": {
|
||||
"mode": null,
|
||||
"reason": "planned_1.5",
|
||||
"supported": false
|
||||
},
|
||||
"table": {
|
||||
"mode": "composite",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"transactions": {
|
||||
"mode": "snapshot",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"implementation": "CPython",
|
||||
"machine": "AMD64",
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"python": "3.14.0"
|
||||
},
|
||||
"generated_at": "2026-07-23T11:39:04.277175+00:00",
|
||||
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
|
||||
"results": [
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.14,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"line_length": 5.0
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "core_geometry"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 3.39,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "modify_query"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.11,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "layers_linetypes"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 348.74,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_type": "DIMENSION"
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "dimensions"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 15.74,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"representations": [
|
||||
"composite",
|
||||
"composite"
|
||||
]
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "table_mleader"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 28.95,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_count": 0
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "transactions"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 2.5,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "preflight"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 29.19,
|
||||
"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": "6413204a5baec19a5fbf551418eb56e30b1488ca5bb755e6a8709ac8e8ab0b22",
|
||||
"size_bytes": 18442
|
||||
}
|
||||
],
|
||||
"duration_ms": 16.66,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_count": 1
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "dxf_roundtrip"
|
||||
},
|
||||
{
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "manifest.json",
|
||||
"sha256": "38c2ee819b49b9c66b2311b952e856eff9e3a7565194cce2edea60a3211d233b",
|
||||
"size_bytes": 3581
|
||||
}
|
||||
],
|
||||
"duration_ms": 4380.8,
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"schema_version": "2.0",
|
||||
"summary": {
|
||||
"attempted": 10,
|
||||
"coverage_percent": 100.0,
|
||||
"passed": 10,
|
||||
"score": 100.0,
|
||||
"supported": 10,
|
||||
"unsupported": 0
|
||||
},
|
||||
"timeout_seconds": 30.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,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
|
||||
@@ -17,6 +18,19 @@ from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.tasks_v2 import TASKS_V2, 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:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -91,9 +105,7 @@ 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 {
|
||||
"schema_version": "2.0",
|
||||
@@ -122,10 +134,15 @@ 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(
|
||||
"--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")
|
||||
@@ -135,19 +152,22 @@ def _parser() -> argparse.ArgumentParser:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parser().parse_args()
|
||||
registry = _load_registry()
|
||||
args = _parser(registry).parse_args()
|
||||
if args.list:
|
||||
for task in TASKS_V2:
|
||||
print(f"{task.task_id}\t{task.category}\t{task.description}")
|
||||
return
|
||||
|
||||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||||
|
||||
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) for task_id in args.task] if args.task else list(TASKS_V2)
|
||||
adapter = AutoCADMCPProAdapter(backend=args.backend)
|
||||
report = asyncio.run(
|
||||
run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout)
|
||||
)
|
||||
adapter = _make_adapter(entry, args.backend)
|
||||
report = asyncio.run(run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout))
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
print(rendered)
|
||||
|
||||
|
||||
@@ -43,5 +43,16 @@ class Settings:
|
||||
)
|
||||
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
|
||||
|
||||
# Exposed tool profile: "full" (default, everything), "core" (hides the
|
||||
# raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool
|
||||
# drafting/inspection core for clients with tight tool caps). Invalid
|
||||
# values fall back to "full" with a warning at startup.
|
||||
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").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,51 @@
|
||||
# Release & Distribution Runbook (v1.4.0+)
|
||||
|
||||
The code side of a release is fully automated; three account-bound steps need
|
||||
a human once, plus the directory submissions. Track them here.
|
||||
|
||||
## One-time setup (owner action)
|
||||
|
||||
- [ ] **PyPI trusted publisher** — on <https://pypi.org/manage/account/publishing/>
|
||||
add a *pending publisher* for project `autocad-mcp-pro`:
|
||||
repository `U-C4N/Autocad-MCP`, workflow `release.yml`, environment `pypi`.
|
||||
(No API token is stored anywhere; `release.yml` uses OIDC.)
|
||||
- [ ] **GitHub environment** — create the `pypi` environment in the repo
|
||||
settings (Settings → Environments) so the publish job can bind to it.
|
||||
- [ ] **MCP registry login** — install `mcp-publisher`, then
|
||||
`mcp-publisher login github` (authenticates the `io.github.u-c4n` namespace).
|
||||
|
||||
## Every release
|
||||
|
||||
1. Bump `pyproject.toml` + `server.json` versions, add the CHANGELOG section,
|
||||
update the README snapshot line — `tests/test_release_consistency.py`
|
||||
enforces all of it.
|
||||
2. Push `main`, wait for CI to go green (lint, linux, windows, package,
|
||||
docker, registry-validate).
|
||||
3. Tag and push: `git tag v1.4.0 && git push origin v1.4.0`.
|
||||
`release.yml` runs the test gate, verifies tag == package version, builds,
|
||||
publishes to PyPI via trusted publishing and cuts the GitHub Release.
|
||||
4. Verify: `pip install autocad-mcp-pro==<version>` in a clean venv;
|
||||
`uvx autocad-mcp-pro --help`.
|
||||
5. Publish the registry manifest: `mcp-publisher publish` (validates
|
||||
`server.json`, checks the `mcp-name` marker in the PyPI README).
|
||||
|
||||
## Directory listings (submit once, then keep fresh)
|
||||
|
||||
- [ ] Glama — <https://glama.ai/mcp/servers> (auto-indexes GitHub; claim the
|
||||
server page and verify metadata).
|
||||
- [ ] mcpservers.org — submit via their GitHub repo PR flow.
|
||||
- [ ] awesome-mcp-servers — PR adding the server under CAD/engineering.
|
||||
- [ ] Smithery — optional; requires a `smithery.yaml` (evaluate demand first).
|
||||
|
||||
## Benchmark refresh (per release)
|
||||
|
||||
```bash
|
||||
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
|
||||
# sanitize + copy reports into benchmarks/results/published/, then:
|
||||
python -m benchmarks.render_live_chart
|
||||
```
|
||||
|
||||
Bump the competitor pins in `benchmarks/competitors.yaml` deliberately (new
|
||||
SHA = re-read their tool contracts before trusting the mappings).
|
||||
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 69 KiB |
+95
-72
@@ -29,8 +29,13 @@ _EXPECTED_LAYER_COLOR: dict[str, int] = {
|
||||
|
||||
# Dimension entity types that should sit on the DIM layer.
|
||||
_DIM_TYPES = {
|
||||
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
|
||||
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
|
||||
"DIMENSION",
|
||||
"DIMLINEAR",
|
||||
"DIMALIGNED",
|
||||
"DIMANGULAR",
|
||||
"DIMRADIUS",
|
||||
"DIMDIAMETER",
|
||||
"DIMORDINATE",
|
||||
}
|
||||
|
||||
|
||||
@@ -57,15 +62,17 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
||||
# (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept
|
||||
# either the raw mm value or hundredths/100.
|
||||
if lw not in allowed and (lw / 100.0) not in allowed:
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
|
||||
f"ISO 128 set {sorted(allowed)}."
|
||||
),
|
||||
detail={"layer": lyr.name, "lineweight": lw},
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
|
||||
f"ISO 128 set {sorted(allowed)}."
|
||||
),
|
||||
detail={"layer": lyr.name, "lineweight": lw},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -79,17 +86,19 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
||||
for lyr in layers:
|
||||
expected = _EXPECTED_LAYER_COLOR.get(lyr.name)
|
||||
if expected is None:
|
||||
continue # unknown layer — caller's responsibility
|
||||
continue # unknown layer — caller's responsibility
|
||||
if int(lyr.color) != int(expected):
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="layer_color",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' has color {lyr.color}; "
|
||||
f"engineering standard expects {expected}."
|
||||
),
|
||||
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="layer_color",
|
||||
message=(
|
||||
f"Layer '{lyr.name}' has color {lyr.color}; "
|
||||
f"engineering standard expects {expected}."
|
||||
),
|
||||
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -107,17 +116,21 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
|
||||
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
|
||||
if not leftover:
|
||||
return []
|
||||
return [Issue(
|
||||
severity="error",
|
||||
focus="construction_left",
|
||||
message=(
|
||||
f"{len(leftover)} entity/entities still on a construction layer. "
|
||||
"Call construction_clear() before drawing_finalize."
|
||||
),
|
||||
handles=[e.handle for e in leftover[:50]],
|
||||
detail={"count": len(leftover),
|
||||
"layers": sorted({e.layer for e in leftover if e.layer})},
|
||||
)]
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
focus="construction_left",
|
||||
message=(
|
||||
f"{len(leftover)} entity/entities still on a construction layer. "
|
||||
"Call construction_clear() before drawing_finalize."
|
||||
),
|
||||
handles=[e.handle for e in leftover[:50]],
|
||||
detail={
|
||||
"count": len(leftover),
|
||||
"layers": sorted({e.layer for e in leftover if e.layer}),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
@@ -150,16 +163,18 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="untrimmed_corner",
|
||||
message=(
|
||||
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
|
||||
"likely an untrimmed corner (use entity_trim or entity_fillet)."
|
||||
),
|
||||
handles=[h1, h2],
|
||||
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="untrimmed_corner",
|
||||
message=(
|
||||
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
|
||||
"likely an untrimmed corner (use entity_trim or entity_fillet)."
|
||||
),
|
||||
handles=[h1, h2],
|
||||
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -180,12 +195,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
|
||||
k2 = (k1[2], k1[3], k1[0], k1[1])
|
||||
for key in (k1, k2):
|
||||
if key in seen:
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="duplicate_entities",
|
||||
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
|
||||
handles=[seen[key], ln.handle],
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="duplicate_entities",
|
||||
message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
|
||||
handles=[seen[key], ln.handle],
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
seen[k1] = ln.handle
|
||||
@@ -243,12 +260,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus="dim_overlap",
|
||||
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
|
||||
handles=[h1, h2],
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="dim_overlap",
|
||||
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
|
||||
handles=[h1, h2],
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -267,19 +286,21 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
|
||||
missing = sorted(referenced - defined)
|
||||
if not missing:
|
||||
return []
|
||||
return [Issue(
|
||||
severity="error",
|
||||
focus="gdt",
|
||||
message=(
|
||||
f"Feature control frame(s) reference datum(s) {missing} with no "
|
||||
"matching datum feature. Add draw_datum_feature for each datum."
|
||||
),
|
||||
detail={
|
||||
"missing_datums": missing,
|
||||
"defined": sorted(defined),
|
||||
"referenced": sorted(referenced),
|
||||
},
|
||||
)]
|
||||
return [
|
||||
Issue(
|
||||
severity="error",
|
||||
focus="gdt",
|
||||
message=(
|
||||
f"Feature control frame(s) reference datum(s) {missing} with no "
|
||||
"matching datum feature. Add draw_datum_feature for each datum."
|
||||
),
|
||||
detail={
|
||||
"missing_datums": missing,
|
||||
"defined": sorted(defined),
|
||||
"referenced": sorted(referenced),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
_FOCUS_DISPATCH = {
|
||||
@@ -303,11 +324,13 @@ async def run_critique(
|
||||
for f in foci:
|
||||
check = _FOCUS_DISPATCH.get(f)
|
||||
if check is None:
|
||||
issues.append(Issue(
|
||||
severity="warning",
|
||||
focus=f, # type: ignore[arg-type]
|
||||
message=f"Unknown critique focus '{f}' (closed enum).",
|
||||
))
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus=f, # type: ignore[arg-type]
|
||||
message=f"Unknown critique focus '{f}' (closed enum).",
|
||||
)
|
||||
)
|
||||
continue
|
||||
issues.extend(await check(backend))
|
||||
return issues
|
||||
|
||||
@@ -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,181 @@
|
||||
"""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.
|
||||
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS).
|
||||
- Transition/interference HOLE letters (K, M, N, P) require the ISO 286
|
||||
delta-rule and sub-stepped r/s tables; they are intentionally excluded and
|
||||
raise a ValueError naming the supported set.
|
||||
|
||||
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, ...).
|
||||
"""
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p")
|
||||
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS")
|
||||
|
||||
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$")
|
||||
|
||||
|
||||
@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
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Fit letter {letter!r} is outside the authored scope. Supported: "
|
||||
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. "
|
||||
"Transition/interference hole letters (K, M, N, P) need the ISO 286 "
|
||||
"delta-rule and are not authored yet."
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
|
||||
@@ -72,7 +72,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 +88,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 +107,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 +121,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 +171,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+37
-1
@@ -1,15 +1,48 @@
|
||||
[project]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [
|
||||
{ name = "Umutcan Edizsalan" },
|
||||
]
|
||||
keywords = [
|
||||
"autocad",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"cad",
|
||||
"dxf",
|
||||
"ezdxf",
|
||||
"drafting",
|
||||
"engineering-drawing",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Manufacturing",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=3.0",
|
||||
"ezdxf>=1.3",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[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]
|
||||
com = [
|
||||
"pywin32>=306",
|
||||
@@ -26,6 +59,9 @@ full = [
|
||||
|
||||
[project.scripts]
|
||||
autocad-mcp = "server:main"
|
||||
# Alias matching the PyPI distribution name so `uvx autocad-mcp-pro` works
|
||||
# without a --from indirection (MCP registry / uvx convention).
|
||||
autocad-mcp-pro = "server:main"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
+44
-24
@@ -106,32 +106,50 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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",
|
||||
"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",
|
||||
r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -213,7 +231,9 @@ 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):
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$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.4.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "autocad-mcp-pro",
|
||||
"version": "1.4.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
"environmentVariables": [
|
||||
{
|
||||
"name": "AUTOCAD_MCP_BACKEND",
|
||||
"description": "Backend selection: auto (default), com (live AutoCAD, Windows) or ezdxf (headless DXF).",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
},
|
||||
{
|
||||
"name": "ALLOWED_PATHS",
|
||||
"description": "Comma-separated absolute paths the server may read and write. Empty blocks file access outside temp.",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
},
|
||||
{
|
||||
"name": "TOOL_PROFILE",
|
||||
"description": "Exposed tool profile: full (default), core or lean.",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -68,7 +68,10 @@ async def test_analysis_entity_stats(backend):
|
||||
stats = await backend.analysis_stats()
|
||||
assert isinstance(stats, dict)
|
||||
by_type = stats.get("by_type", stats)
|
||||
total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0))
|
||||
total = stats.get(
|
||||
"total",
|
||||
stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0),
|
||||
)
|
||||
assert total >= 3
|
||||
|
||||
|
||||
|
||||
@@ -51,8 +51,12 @@ async def test_batch_modify_delete(backend):
|
||||
async def test_template_architectural_layers(backend):
|
||||
"""Architectural template should create standard layers."""
|
||||
layer_defs = [
|
||||
("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4),
|
||||
("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7),
|
||||
("WALLS", 7),
|
||||
("DOORS", 3),
|
||||
("WINDOWS", 4),
|
||||
("FURNITURE", 8),
|
||||
("DIMENSIONS", 2),
|
||||
("TEXT", 7),
|
||||
]
|
||||
for name, color in layer_defs:
|
||||
await backend.layer_create(name, color=color)
|
||||
@@ -67,8 +71,11 @@ async def test_template_architectural_layers(backend):
|
||||
async def test_template_mechanical_layers(backend):
|
||||
"""Mechanical template layers."""
|
||||
layer_defs = [
|
||||
("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3),
|
||||
("DIMENSIONS", 2), ("SECTION", 5),
|
||||
("VISIBLE", 7),
|
||||
("HIDDEN", 1),
|
||||
("CENTER", 3),
|
||||
("DIMENSIONS", 2),
|
||||
("SECTION", 5),
|
||||
]
|
||||
for name, color in layer_defs:
|
||||
await backend.layer_create(name, color=color)
|
||||
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from backends.base import CapabilityMap, FeatureCapability
|
||||
from version import __version__
|
||||
|
||||
|
||||
def test_capability_types_serialize_stably():
|
||||
@@ -32,7 +33,11 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
|
||||
"reason": None,
|
||||
}
|
||||
assert caps["features"]["mleader"]["mode"] == "composite"
|
||||
assert caps["features"]["paper_space"]["supported"] is False
|
||||
# v1.4: paper-space layouts/viewports are native on both backends;
|
||||
# projecting model content through viewports stays COM-only, and 3D
|
||||
# ACIS solids remain impossible headlessly.
|
||||
assert caps["features"]["paper_space"]["supported"] is True
|
||||
assert caps["features"]["viewport_render"]["supported"] is False
|
||||
assert caps["features"]["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
@@ -70,5 +75,5 @@ async def test_system_about_uses_package_version_and_capability_summary(backend)
|
||||
|
||||
result = await server.system_about(ctx=Ctx())
|
||||
|
||||
assert result["version"] == "1.3.0"
|
||||
assert result["version"] == __version__
|
||||
assert result["capabilities"]["table"]["mode"] == "composite"
|
||||
|
||||
@@ -48,6 +48,7 @@ async def test_active_document_switch_clears_com_document_state(monkeypatch):
|
||||
assert backend._preflight_result is None
|
||||
assert backend._gdt_datums_defined == set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_lineweight — parametrized table
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -107,7 +108,12 @@ def test_normalize_lineweight_non_numeric_passthrough():
|
||||
# _safe_send_command — CMDACTIVE polling / timeout / var-restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_doc(cmdactive_sequence: list[int], modelspace_handles_pre: set[str] | None = None, modelspace_handles_post: set[str] | None = None) -> MagicMock:
|
||||
|
||||
def _make_mock_doc(
|
||||
cmdactive_sequence: list[int],
|
||||
modelspace_handles_pre: set[str] | None = None,
|
||||
modelspace_handles_post: set[str] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a minimal mock AutoCAD document for _safe_send_command tests."""
|
||||
doc = MagicMock()
|
||||
|
||||
@@ -270,9 +276,9 @@ async def test_dimension_linear_uses_add_dim_rotated():
|
||||
mspace.AddDimRotated.assert_called_once()
|
||||
mspace.AddDimLinear.assert_not_called()
|
||||
args = mspace.AddDimRotated.call_args[0]
|
||||
assert args[0] == (0.0, 0.0) # ext line 1 origin
|
||||
assert args[1] == (100.0, 0.0) # ext line 2 origin
|
||||
assert args[2] == (50.0, 20.0) # dim line location
|
||||
assert args[0] == (0.0, 0.0) # ext line 1 origin
|
||||
assert args[1] == (100.0, 0.0) # ext line 2 origin
|
||||
assert args[2] == (50.0, 20.0) # dim line location
|
||||
assert args[3] == pytest.approx(0.0) # rotation in radians
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class _Doc:
|
||||
|
||||
# ── NEW-com-set-variable-coercion ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
|
||||
app = MagicMock()
|
||||
app.GetVariable.return_value = 4159 # OSMODE current value is an int
|
||||
@@ -69,6 +70,7 @@ async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
|
||||
|
||||
# ── R23 / N6 — run_lisp guard + value capture ───────────────────────────────
|
||||
|
||||
|
||||
async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
|
||||
app = MagicMock()
|
||||
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
|
||||
@@ -90,12 +92,13 @@ async def test_run_lisp_captures_value_from_users1(monkeypatch):
|
||||
b = _backend_no_executor()
|
||||
out = await b.system_run_lisp("(+ 1 2)")
|
||||
|
||||
assert out["result"] == "3" # captured, not the bogus "nil"
|
||||
assert out["result"] == "3" # captured, not the bogus "nil"
|
||||
assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band
|
||||
|
||||
|
||||
# ── R16 — transaction flag cleared even on error ────────────────────────────
|
||||
|
||||
|
||||
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
|
||||
doc = _Doc()
|
||||
doc.StartUndoMark = MagicMock()
|
||||
@@ -136,10 +139,11 @@ async def test_transaction_rollback_uses_safe_send_command(monkeypatch):
|
||||
|
||||
# ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
|
||||
|
||||
|
||||
async def test_offset_picks_copy_nearest_side_point(monkeypatch):
|
||||
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
|
||||
pos_copy = MagicMock(name="pos")
|
||||
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
|
||||
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
|
||||
neg_copy = MagicMock(name="neg")
|
||||
neg_copy.GetBoundingBox.return_value = ((-1, -11), (1, -9)) # centre (0, -10)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
import server
|
||||
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
|
||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||
from version import __version__
|
||||
|
||||
|
||||
async def _clean_drawing(backend) -> None:
|
||||
@@ -42,7 +43,7 @@ async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_pa
|
||||
assert dxf["status"] == "created"
|
||||
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
||||
assert manifest["parity"]["ok"] is True
|
||||
assert manifest["version"] == "1.3.0"
|
||||
assert manifest["version"] == __version__
|
||||
assert manifest["capabilities"]["backend"] == "ezdxf"
|
||||
|
||||
|
||||
@@ -107,9 +108,7 @@ async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monk
|
||||
assert source["listed_entity_count"] == 1
|
||||
assert source["inventory_complete"] is False
|
||||
assert result["ok"] is False
|
||||
assert "source_inventory_complete" in {
|
||||
item["field"] for item in result["differences"]
|
||||
}
|
||||
assert "source_inventory_complete" in {item["field"] for item in result["differences"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -33,6 +33,7 @@ async def test_drawing_export_dxf(backend, tmp_path):
|
||||
result = await backend.drawing_export_dxf(export_path)
|
||||
assert result.get("ok") is True
|
||||
import os
|
||||
|
||||
assert os.path.exists(export_path)
|
||||
|
||||
|
||||
|
||||
@@ -33,9 +33,13 @@ async def test_set_units_cm_and_inch_codes(backend):
|
||||
|
||||
|
||||
async def test_set_numeric_settings(backend):
|
||||
res = await backend.drawing_settings({
|
||||
"dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5,
|
||||
})
|
||||
res = await backend.drawing_settings(
|
||||
{
|
||||
"dimscale": 2.0,
|
||||
"linear_precision": 3,
|
||||
"ltscale": 0.5,
|
||||
}
|
||||
)
|
||||
assert res["ok"] is True
|
||||
snap = (await backend.drawing_settings())["settings"]
|
||||
assert snap["dimscale"] == 2.0
|
||||
@@ -57,6 +61,7 @@ async def test_bad_unit_value_is_reported(backend):
|
||||
|
||||
# ── server tool wiring ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, backend):
|
||||
self.lifespan_context = {"backend": backend}
|
||||
|
||||
@@ -66,19 +66,41 @@ async def test_helical_gear_front_view_returns_metadata(backend):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
res = await draw_helical_gear_front_view(
|
||||
backend, module=3, teeth=24, helix_angle=20.0, hand="RH",
|
||||
backend,
|
||||
module=3,
|
||||
teeth=24,
|
||||
helix_angle=20.0,
|
||||
hand="RH",
|
||||
center=(0.0, 0.0),
|
||||
)
|
||||
expected_keys = {
|
||||
"outline", "pitch_circle", "base_circle", "outer_circle", "root_circle",
|
||||
"helix_symbol", "helix_label", "bore", "keyway", "metadata",
|
||||
"outline",
|
||||
"pitch_circle",
|
||||
"base_circle",
|
||||
"outer_circle",
|
||||
"root_circle",
|
||||
"helix_symbol",
|
||||
"helix_label",
|
||||
"bore",
|
||||
"keyway",
|
||||
"metadata",
|
||||
}
|
||||
assert expected_keys.issubset(res.keys())
|
||||
md = res["metadata"]
|
||||
for k in (
|
||||
"module", "teeth", "pressure_angle", "helix_angle", "hand", "center",
|
||||
"bore_diameter", "keyway_width", "keyway_depth",
|
||||
"pitch_radius", "outer_radius", "base_radius", "root_radius",
|
||||
"module",
|
||||
"teeth",
|
||||
"pressure_angle",
|
||||
"helix_angle",
|
||||
"hand",
|
||||
"center",
|
||||
"bore_diameter",
|
||||
"keyway_width",
|
||||
"keyway_depth",
|
||||
"pitch_radius",
|
||||
"outer_radius",
|
||||
"base_radius",
|
||||
"root_radius",
|
||||
):
|
||||
assert k in md, f"metadata missing key {k!r}"
|
||||
|
||||
@@ -88,8 +110,13 @@ async def test_helical_gear_with_bore_and_keyway_handles_present(backend):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
res = await draw_helical_gear_front_view(
|
||||
backend, module=3, teeth=24, helix_angle=20.0,
|
||||
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
|
||||
backend,
|
||||
module=3,
|
||||
teeth=24,
|
||||
helix_angle=20.0,
|
||||
bore_diameter=25.0,
|
||||
keyway_width=8.0,
|
||||
keyway_depth=4.0,
|
||||
)
|
||||
assert res["bore"] is not None
|
||||
assert res["keyway"] is not None
|
||||
@@ -102,7 +129,11 @@ async def test_helical_gear_metadata_radii_correct(backend):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
res = await draw_helical_gear_front_view(
|
||||
backend, module=3, teeth=24, helix_angle=20.0, pressure_angle=20.0,
|
||||
backend,
|
||||
module=3,
|
||||
teeth=24,
|
||||
helix_angle=20.0,
|
||||
pressure_angle=20.0,
|
||||
)
|
||||
md = res["metadata"]
|
||||
assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6)
|
||||
@@ -116,12 +147,19 @@ async def test_section_aa_does_not_draw_helix_lines(backend):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
front = await draw_helical_gear_front_view(
|
||||
backend, module=3, teeth=24, helix_angle=20.0,
|
||||
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
|
||||
backend,
|
||||
module=3,
|
||||
teeth=24,
|
||||
helix_angle=20.0,
|
||||
bore_diameter=25.0,
|
||||
keyway_width=8.0,
|
||||
keyway_depth=4.0,
|
||||
)
|
||||
section = await draw_gear_section_aa(
|
||||
backend, gear_metadata=front["metadata"],
|
||||
x_offset=200.0, face_width=40.0,
|
||||
backend,
|
||||
gear_metadata=front["metadata"],
|
||||
x_offset=200.0,
|
||||
face_width=40.0,
|
||||
)
|
||||
assert "helix" not in section
|
||||
assert "helix_symbol" not in section
|
||||
|
||||
@@ -37,8 +37,11 @@ async def test_draw_keyed_bore_returns_expected_handles(backend):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
res = await draw_keyed_bore(
|
||||
backend, center=(0.0, 0.0), bore_diameter=25.0,
|
||||
keyway_width=8.0, keyway_depth=4.0,
|
||||
backend,
|
||||
center=(0.0, 0.0),
|
||||
bore_diameter=25.0,
|
||||
keyway_width=8.0,
|
||||
keyway_depth=4.0,
|
||||
)
|
||||
for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"):
|
||||
assert k in res
|
||||
@@ -50,8 +53,12 @@ async def test_draw_keyway_section_geometry(backend):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
res = await draw_keyway_section(
|
||||
backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0,
|
||||
keyway_width=8.0, keyway_depth=4.0,
|
||||
backend,
|
||||
center=(0.0, 0.0),
|
||||
bore_diameter=25.0,
|
||||
face_width=40.0,
|
||||
keyway_width=8.0,
|
||||
keyway_depth=4.0,
|
||||
)
|
||||
assert "bore_top" in res
|
||||
assert "bore_bottom" in res
|
||||
|
||||
@@ -39,7 +39,8 @@ async def test_titleblock_outer_border_dimensions(backend):
|
||||
async def test_title_text_exact_match(backend):
|
||||
"""Title text in the drawing must equal metadata.title verbatim."""
|
||||
result = await apply_iso_a3_titleblock(
|
||||
backend, metadata=_meta(title="HELICAL GEAR"),
|
||||
backend,
|
||||
metadata=_meta(title="HELICAL GEAR"),
|
||||
)
|
||||
title_ent = await backend.entity_get(result["title_text"])
|
||||
assert title_ent.type == "TEXT"
|
||||
@@ -50,9 +51,16 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend):
|
||||
"""value_texts must contain a handle for every metadata field."""
|
||||
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
|
||||
expected_keys = {
|
||||
"drawing_no", "revision", "sheet",
|
||||
"part_no", "material", "scale",
|
||||
"drawn_by", "checked_by", "date", "company",
|
||||
"drawing_no",
|
||||
"revision",
|
||||
"sheet",
|
||||
"part_no",
|
||||
"material",
|
||||
"scale",
|
||||
"drawn_by",
|
||||
"checked_by",
|
||||
"date",
|
||||
"company",
|
||||
}
|
||||
assert set(result["value_texts"].keys()) == expected_keys
|
||||
# All handles must resolve to TEXT entities
|
||||
@@ -64,7 +72,9 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend):
|
||||
async def test_titleblock_origin_offset(backend):
|
||||
"""Passing origin=(100,50) must shift the bbox by that amount."""
|
||||
result = await apply_iso_a3_titleblock(
|
||||
backend, metadata=_meta(), origin=(100.0, 50.0),
|
||||
backend,
|
||||
metadata=_meta(),
|
||||
origin=(100.0, 50.0),
|
||||
)
|
||||
bbox = result["bbox"]
|
||||
assert bbox["min"][0] == pytest.approx(100.0)
|
||||
|
||||
@@ -73,7 +73,11 @@ async def test_validator_catches_helical_spur_title(backend, tmp_path):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
await backend.entity_create_text(
|
||||
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
|
||||
text="HELICAL SPUR GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TEXT",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "tb.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 20})
|
||||
@@ -86,7 +90,11 @@ async def test_validator_catches_fake_dimension_text(backend, tmp_path):
|
||||
await ensure_standard_linetypes(backend)
|
||||
await ensure_engineering_layers(backend)
|
||||
await backend.entity_create_text(
|
||||
text="Ø78", x=10, y=10, height=3.5, layer="DIM",
|
||||
text="Ø78",
|
||||
x=10,
|
||||
y=10,
|
||||
height=3.5,
|
||||
layer="DIM",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "fake.dxf"))
|
||||
result = await DrawingValidator().run(backend)
|
||||
@@ -124,7 +132,8 @@ async def test_validator_bore_missing_when_expected(backend, tmp_path):
|
||||
# No circle drawn at all
|
||||
await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
|
||||
result = await DrawingValidator().run(
|
||||
backend, expected={"must_have_bore": True},
|
||||
backend,
|
||||
expected={"must_have_bore": True},
|
||||
)
|
||||
assert "bore_missing" in _codes(result)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ def _close(a, b, tol=1e-6):
|
||||
|
||||
# ─── TRIM ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrim:
|
||||
async def test_trim_keep_left(self, backend):
|
||||
target = await backend.entity_create_line(0, 0, 100, 0)
|
||||
@@ -55,6 +56,7 @@ class TestTrim:
|
||||
|
||||
# ─── EXTEND ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtend:
|
||||
async def test_extend_auto_endpoint(self, backend):
|
||||
# Target ends at x=40; boundary is vertical at x=80.
|
||||
@@ -84,6 +86,7 @@ class TestExtend:
|
||||
|
||||
# ─── FILLET ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFillet:
|
||||
async def test_fillet_90_degree_corner(self, backend):
|
||||
# L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80).
|
||||
@@ -139,6 +142,7 @@ class TestFillet:
|
||||
|
||||
# ─── CHAMFER ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestChamfer:
|
||||
async def test_chamfer_symmetric(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 100, 0)
|
||||
|
||||
@@ -14,10 +14,11 @@ pytestmark = pytest.mark.asyncio
|
||||
|
||||
# ── entity_edit_text ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_edit_text_changes_content_keeps_handle(backend):
|
||||
t = await backend.entity_create_text("OLD LABEL", 0, 0, height=2.5, layer="TEXT")
|
||||
updated = await backend.entity_edit_text(t.handle, text="NEW LABEL")
|
||||
assert updated.handle == t.handle # handle preserved
|
||||
assert updated.handle == t.handle # handle preserved
|
||||
assert updated.properties["text"] == "NEW LABEL"
|
||||
|
||||
|
||||
@@ -44,6 +45,7 @@ async def test_edit_text_rejects_non_text(backend):
|
||||
|
||||
# ── entity_edit_geometry ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_edit_circle_radius_and_center(backend):
|
||||
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
|
||||
updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for ezdxf backend — no AutoCAD required."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""ISO 286 fit-table lookups pinned against published table values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from engineering.fits import FitDeviation, fit_lookup, parse_fit_code
|
||||
from engineering.tolerances import build_dim_override
|
||||
from server import _fit_to_tolerances
|
||||
|
||||
|
||||
def _um(value_mm: float) -> float:
|
||||
return round(value_mm * 1000.0, 3)
|
||||
|
||||
|
||||
# (code, nominal, upper µm, lower µm) — published ISO 286-2 values.
|
||||
CANONICAL_FITS = [
|
||||
("H7", 20.0, 21, 0),
|
||||
("H7", 40.0, 25, 0),
|
||||
("H8", 30.0, 33, 0),
|
||||
("H9", 6.0, 30, 0),
|
||||
("H7", 200.0, 46, 0),
|
||||
("g6", 20.0, -7, -20),
|
||||
("g6", 6.0, -4, -12),
|
||||
("f7", 40.0, -25, -50),
|
||||
("e8", 25.0, -40, -73),
|
||||
("d9", 100.0, -120, -207),
|
||||
("h6", 30.0, 0, -13), # 30 mm is inside the >18-30 step (inclusive upper bound)
|
||||
("h7", 50.0, 0, -25),
|
||||
("k6", 20.0, 15, 2),
|
||||
("m6", 25.0, 21, 8),
|
||||
("n6", 20.0, 28, 15),
|
||||
("p6", 20.0, 35, 22),
|
||||
("js9", 25.0, 26, -26),
|
||||
("G7", 6.0, 16, 4),
|
||||
("F8", 40.0, 64, 25),
|
||||
("D10", 30.0, 149, 65),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,nominal,upper_um,lower_um", CANONICAL_FITS)
|
||||
def test_canonical_fit_values(code, nominal, upper_um, lower_um):
|
||||
deviation = fit_lookup(code, nominal)
|
||||
assert isinstance(deviation, FitDeviation)
|
||||
assert _um(deviation.upper_mm) == pytest.approx(upper_um), f"{code}@{nominal} upper"
|
||||
assert _um(deviation.lower_mm) == pytest.approx(lower_um), f"{code}@{nominal} lower"
|
||||
|
||||
|
||||
def test_k_outside_it4_to_7_has_zero_lower_deviation():
|
||||
deviation = fit_lookup("k9", 20.0)
|
||||
assert deviation.lower_mm == 0.0
|
||||
assert _um(deviation.upper_mm) == 52 # IT9 @ 18-30
|
||||
|
||||
|
||||
def test_parse_fit_code():
|
||||
assert parse_fit_code("H7") == ("H", 7)
|
||||
assert parse_fit_code("js10") == ("js", 10)
|
||||
with pytest.raises(ValueError):
|
||||
parse_fit_code("77")
|
||||
with pytest.raises(ValueError):
|
||||
parse_fit_code("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,nominal",
|
||||
[
|
||||
("K7", 20.0), # transition hole letters need the delta-rule — excluded
|
||||
("P7", 20.0),
|
||||
("x6", 20.0), # letter outside the authored subset (regex rejects)
|
||||
("H3", 20.0), # grade below authored range
|
||||
("H12", 20.0), # grade above authored range
|
||||
("H7", 0.5), # below 1 mm
|
||||
("H7", 600.0), # above 500 mm
|
||||
],
|
||||
)
|
||||
def test_out_of_scope_lookups_raise(code, nominal):
|
||||
with pytest.raises(ValueError):
|
||||
fit_lookup(code, nominal)
|
||||
|
||||
|
||||
def test_double_positive_fit_renders_correct_dimvars():
|
||||
"""p6-style fits (+upper/+lower) must produce a negative DIMTM so AutoCAD
|
||||
displays the plus lower deviation."""
|
||||
deviation = fit_lookup("p6", 20.0)
|
||||
override, _ = build_dim_override(
|
||||
tol_upper=deviation.upper_mm,
|
||||
tol_lower=-deviation.lower_mm, # server convention: positive = minus
|
||||
tol_mode="deviation",
|
||||
)
|
||||
assert override["dimtp"] == pytest.approx(0.035)
|
||||
assert override["dimtm"] == pytest.approx(-0.022) # displayed as +0.022
|
||||
|
||||
|
||||
def test_fit_to_tolerances_contract():
|
||||
tol_upper, tol_lower, tol_mode, text = _fit_to_tolerances("H7", 20.0, None, None, "none", None)
|
||||
assert tol_upper == pytest.approx(0.021)
|
||||
assert tol_lower == pytest.approx(0.0)
|
||||
assert tol_mode == "deviation"
|
||||
assert text == "<> H7"
|
||||
|
||||
|
||||
def test_fit_to_tolerances_rejects_mixed_usage():
|
||||
with pytest.raises(ToolError):
|
||||
_fit_to_tolerances("H7", 20.0, 0.1, None, "none", None)
|
||||
with pytest.raises(ToolError):
|
||||
_fit_to_tolerances("H7", 20.0, None, None, "symmetric", None)
|
||||
with pytest.raises(ToolError):
|
||||
_fit_to_tolerances("Q9", 20.0, None, None, "none", None)
|
||||
|
||||
|
||||
def test_fit_passthrough_without_code():
|
||||
assert _fit_to_tolerances(None, 20.0, 0.1, 0.05, "deviation", "x") == (
|
||||
0.1,
|
||||
0.05,
|
||||
"deviation",
|
||||
"x",
|
||||
)
|
||||
+16
-1
@@ -46,15 +46,18 @@ def _reload_server_with_token(token: str | None):
|
||||
# Auth object construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_object_built_when_token_set():
|
||||
"""_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty."""
|
||||
with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}):
|
||||
import config as cfg
|
||||
|
||||
old_token = cfg.settings.mcp_auth_token
|
||||
cfg.settings.mcp_auth_token = "secret-test-token-123"
|
||||
try:
|
||||
sys.modules.pop("server", None)
|
||||
import server as srv
|
||||
|
||||
assert srv._auth is not None
|
||||
assert isinstance(srv._auth, StaticTokenVerifier)
|
||||
finally:
|
||||
@@ -65,11 +68,13 @@ def test_auth_object_built_when_token_set():
|
||||
def test_auth_object_none_when_no_token():
|
||||
"""_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode)."""
|
||||
import config as cfg
|
||||
|
||||
old_token = cfg.settings.mcp_auth_token
|
||||
cfg.settings.mcp_auth_token = ""
|
||||
try:
|
||||
sys.modules.pop("server", None)
|
||||
import server as srv
|
||||
|
||||
assert srv._auth is None
|
||||
finally:
|
||||
cfg.settings.mcp_auth_token = old_token
|
||||
@@ -79,11 +84,13 @@ def test_auth_object_none_when_no_token():
|
||||
def test_mcp_receives_auth_when_set():
|
||||
"""The FastMCP instance's auth attribute matches the _auth object."""
|
||||
import config as cfg
|
||||
|
||||
old_token = cfg.settings.mcp_auth_token
|
||||
cfg.settings.mcp_auth_token = "another-secret"
|
||||
try:
|
||||
sys.modules.pop("server", None)
|
||||
import server as srv
|
||||
|
||||
# FastMCP stores auth as _auth_provider or similar; we check it via
|
||||
# the _auth module-level variable matching what was passed in.
|
||||
assert srv._auth is not None
|
||||
@@ -97,11 +104,13 @@ def test_mcp_receives_auth_when_set():
|
||||
# _validate_http_bind guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_http_bind_loopback_no_error():
|
||||
"""Loopback hosts pass validation regardless of token/allow_remote flags."""
|
||||
sys.modules.pop("server", None)
|
||||
import config as cfg
|
||||
import server as srv
|
||||
|
||||
old_remote = cfg.settings.allow_remote_http
|
||||
old_token = cfg.settings.mcp_auth_token
|
||||
cfg.settings.allow_remote_http = False
|
||||
@@ -121,6 +130,7 @@ def test_validate_http_bind_remote_without_flag_raises():
|
||||
sys.modules.pop("server", None)
|
||||
import config as cfg
|
||||
import server as srv
|
||||
|
||||
old = cfg.settings.allow_remote_http
|
||||
cfg.settings.allow_remote_http = False
|
||||
try:
|
||||
@@ -135,6 +145,7 @@ def test_validate_http_bind_remote_without_token_raises():
|
||||
sys.modules.pop("server", None)
|
||||
import config as cfg
|
||||
import server as srv
|
||||
|
||||
old_remote = cfg.settings.allow_remote_http
|
||||
old_token = cfg.settings.mcp_auth_token
|
||||
cfg.settings.allow_remote_http = True
|
||||
@@ -150,9 +161,11 @@ def test_validate_http_bind_remote_without_token_raises():
|
||||
def test_validate_http_bind_remote_with_token_logs_warning(caplog):
|
||||
"""Remote bind with both flag and token logs a warning but does not raise."""
|
||||
import logging
|
||||
|
||||
sys.modules.pop("server", None)
|
||||
import config as cfg
|
||||
import server as srv
|
||||
|
||||
old_remote = cfg.settings.allow_remote_http
|
||||
old_token = cfg.settings.mcp_auth_token
|
||||
cfg.settings.allow_remote_http = True
|
||||
@@ -160,7 +173,9 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog):
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING, logger="server"):
|
||||
srv._validate_http_bind("0.0.0.0") # should not raise
|
||||
assert any("non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records)
|
||||
assert any(
|
||||
"non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records
|
||||
)
|
||||
finally:
|
||||
cfg.settings.allow_remote_http = old_remote
|
||||
cfg.settings.mcp_auth_token = old_token
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Paper-space layouts, viewports, layout PDF export and opt-in 3D solids."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import find_spec
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
import config
|
||||
import server
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def backend():
|
||||
b = EzdxfBackend()
|
||||
await b.connect()
|
||||
await b.drawing_new()
|
||||
yield b
|
||||
await b.disconnect()
|
||||
|
||||
|
||||
async def test_layout_list_reports_model_and_current(backend):
|
||||
result = await backend.layout_list()
|
||||
assert result["ok"] is True
|
||||
assert "Model" in result["layouts"]
|
||||
assert isinstance(result["current"], str)
|
||||
|
||||
|
||||
async def test_layout_create_and_duplicate_rejected(backend):
|
||||
created = await backend.layout_create("A3-Sheet")
|
||||
assert created["ok"] is True
|
||||
listing = await backend.layout_list()
|
||||
assert "A3-Sheet" in listing["layouts"]
|
||||
duplicate = await backend.layout_create("A3-Sheet")
|
||||
assert duplicate["ok"] is False
|
||||
|
||||
|
||||
async def test_layout_set_current_roundtrip(backend):
|
||||
await backend.layout_create("A3-Sheet")
|
||||
switched = await backend.layout_set_current("A3-Sheet")
|
||||
assert switched["ok"] is True
|
||||
assert (await backend.layout_list())["current"] == "A3-Sheet"
|
||||
back = await backend.layout_set_current("Model")
|
||||
assert back["ok"] is True
|
||||
missing = await backend.layout_set_current("NOPE")
|
||||
assert missing["ok"] is False
|
||||
|
||||
|
||||
async def test_viewport_create_scaled(backend, tmp_path):
|
||||
await backend.layout_create("A3-Sheet")
|
||||
result = await backend.viewport_create(
|
||||
"A3-Sheet",
|
||||
center_x=200,
|
||||
center_y=140,
|
||||
width=180,
|
||||
height=120,
|
||||
view_center_x=50,
|
||||
view_center_y=25,
|
||||
scale=0.5,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["view_height"] == pytest.approx(240.0) # height / scale
|
||||
|
||||
exported = tmp_path / "layout_roundtrip.dxf"
|
||||
save = await backend.drawing_export_dxf(str(exported))
|
||||
assert save["ok"] is True
|
||||
doc = ezdxf.readfile(str(exported))
|
||||
viewports = doc.layouts.get("A3-Sheet").query("VIEWPORT")
|
||||
assert len(viewports) >= 1
|
||||
created = [vp for vp in viewports if vp.dxf.handle == result["handle"]]
|
||||
assert created, "created viewport handle must survive the DXF roundtrip"
|
||||
assert created[0].dxf.view_height == pytest.approx(240.0)
|
||||
|
||||
|
||||
async def test_viewport_rejects_model_and_bad_scale(backend):
|
||||
bad_layout = await backend.viewport_create("Model", 0, 0, 10, 10, 0, 0, 1.0)
|
||||
assert bad_layout["ok"] is False
|
||||
await backend.layout_create("A3-Sheet")
|
||||
bad_scale = await backend.viewport_create("A3-Sheet", 0, 0, 10, 10, 0, 0, 0.0)
|
||||
assert bad_scale["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(find_spec("matplotlib") is None, reason="matplotlib not installed")
|
||||
async def test_export_pdf_layout(backend, tmp_path):
|
||||
await backend.layout_create("A3-Sheet")
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
target = tmp_path / "layout.pdf"
|
||||
result = await backend.drawing_export_pdf(str(target), layout="A3-Sheet")
|
||||
assert result["ok"] is True
|
||||
assert result["layout"] == "A3-Sheet"
|
||||
assert "note" in result # honest viewport-projection limitation
|
||||
assert target.is_file() and target.stat().st_size > 0
|
||||
missing = await backend.drawing_export_pdf(str(tmp_path / "x.pdf"), layout="NOPE")
|
||||
assert missing["ok"] is False
|
||||
|
||||
|
||||
async def test_solids_unsupported_headlessly(backend):
|
||||
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
|
||||
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
|
||||
extrude = await backend.solid_extrude("FF", 10)
|
||||
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
|
||||
boolean = await backend.solid_boolean("FF", "FE", "union")
|
||||
for result in (box, cylinder, extrude, revolve, boolean):
|
||||
assert result["ok"] is False
|
||||
assert result["capability"] == "solid_3d"
|
||||
|
||||
|
||||
async def test_capability_map_reports_paper_space_and_solids(backend):
|
||||
features = backend.capabilities().to_dict()["features"]
|
||||
assert features["paper_space"]["supported"] is True
|
||||
assert features["viewport_render"]["supported"] is False
|
||||
assert features["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
class TestSolidToolGating:
|
||||
@pytest.mark.asyncio
|
||||
async def test_solid_tools_hidden_without_enable_3d(self, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "enable_3d", False)
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert server.SOLID_TOOL_NAMES <= set(info["disabled_tools"])
|
||||
await server._apply_tool_profile("full")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solid_tools_visible_with_enable_3d(self, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "enable_3d", True)
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert not (server.SOLID_TOOL_NAMES & set(info["disabled_tools"]))
|
||||
monkeypatch.setattr(config.settings, "enable_3d", False)
|
||||
await server._apply_tool_profile("full")
|
||||
@@ -16,10 +16,13 @@ def _close(a, b, tol=1e-6):
|
||||
|
||||
# ─── drawing_plan ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDrawingPlan:
|
||||
async def test_plan_returns_spec_with_intent(self, backend):
|
||||
plan = await backend.drawing_plan(
|
||||
"L-bracket 50x80", sheet_size="A4", scale=1.0,
|
||||
"L-bracket 50x80",
|
||||
sheet_size="A4",
|
||||
scale=1.0,
|
||||
)
|
||||
assert plan.intent == "L-bracket 50x80"
|
||||
assert plan.sheet_size == "A4"
|
||||
@@ -36,6 +39,7 @@ class TestDrawingPlan:
|
||||
|
||||
# ─── drawing_critique ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDrawingCritique:
|
||||
async def test_critique_clean_drawing(self, backend):
|
||||
# Bootstrap layer set so layer_color/iso128 don't trigger.
|
||||
@@ -73,6 +77,7 @@ class TestDrawingCritique:
|
||||
|
||||
# ─── point_from_snap ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPointFromSnap:
|
||||
async def test_snap_end_default(self, backend):
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
@@ -122,6 +127,7 @@ class TestPointFromSnap:
|
||||
|
||||
# ─── construction layer ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstructionLayer:
|
||||
async def test_xline_creates_on_construction_layer(self, backend):
|
||||
xl = await backend.construction_xline(0, 0, 45)
|
||||
@@ -140,6 +146,7 @@ class TestConstructionLayer:
|
||||
|
||||
# ─── drawing_apply_iso_layers ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApplyISOLayers:
|
||||
async def test_mech_layer_set(self, backend):
|
||||
result = await backend.drawing_apply_iso_layers("mech")
|
||||
@@ -170,13 +177,15 @@ class TestApplyISOLayers:
|
||||
|
||||
# ─── dimension_auto ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDimensionAuto:
|
||||
async def test_chain_creates_one_dim_per_segment(self, backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 30, 0)
|
||||
l2 = await backend.entity_create_line(30, 0, 70, 0)
|
||||
l3 = await backend.entity_create_line(70, 0, 100, 0)
|
||||
dims = await backend.dimension_auto(
|
||||
[l1.handle, l2.handle, l3.handle], style="chain",
|
||||
[l1.handle, l2.handle, l3.handle],
|
||||
style="chain",
|
||||
)
|
||||
assert len(dims) == 3
|
||||
|
||||
@@ -198,6 +207,7 @@ class TestDimensionAuto:
|
||||
|
||||
# ─── entity_select_smart ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEntitySelectSmart:
|
||||
async def test_select_by_type(self, backend):
|
||||
await backend.entity_create_line(0, 0, 50, 0)
|
||||
@@ -214,13 +224,13 @@ class TestEntitySelectSmart:
|
||||
assert len(result) == 1
|
||||
|
||||
async def test_select_by_near(self, backend):
|
||||
await backend.entity_create_line(0, 0, 10, 0) # near origin
|
||||
await backend.entity_create_line(0, 0, 10, 0) # near origin
|
||||
await backend.entity_create_line(1000, 1000, 1010, 1000) # far
|
||||
result = await backend.entity_select_smart({"type": "LINE", "near": [0, 0, 50]})
|
||||
assert len(result) == 1
|
||||
|
||||
async def test_select_by_length_range(self, backend):
|
||||
await backend.entity_create_line(0, 0, 10, 0) # length 10
|
||||
await backend.entity_create_line(0, 0, 10, 0) # length 10
|
||||
await backend.entity_create_line(0, 50, 100, 50) # length 100
|
||||
result = await backend.entity_select_smart({"type": "LINE", "length_range": [50, 200]})
|
||||
assert len(result) == 1
|
||||
@@ -228,6 +238,7 @@ class TestEntitySelectSmart:
|
||||
|
||||
# ─── Integration: plan → draw → critique pipeline ──────────────────────────
|
||||
|
||||
|
||||
class TestPremiumPipeline:
|
||||
async def test_full_l_bracket_pipeline_clean(self, backend):
|
||||
"""plan → apply_layers → draw + fillet → clear construction → critique = []."""
|
||||
|
||||
@@ -13,6 +13,7 @@ pytestmark = pytest.mark.asyncio
|
||||
# point_intersection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPointIntersection:
|
||||
async def test_line_line_perpendicular(self, backend):
|
||||
"""Horizontal and vertical lines cross at (50, 50)."""
|
||||
@@ -91,6 +92,7 @@ class TestPointIntersection:
|
||||
# point_tangent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPointTangent:
|
||||
async def test_tangent_from_outside(self, backend):
|
||||
"""From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5)."""
|
||||
|
||||
@@ -100,7 +100,8 @@ def test_preflight_does_not_invent_values_when_assumptions_disabled():
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_plan_accepts_matching_ready_preflight(backend):
|
||||
preflight = await backend.drawing_preflight(
|
||||
"L bracket", requirements=_complete_requirements(),
|
||||
"L bracket",
|
||||
requirements=_complete_requirements(),
|
||||
)
|
||||
|
||||
plan = await backend.drawing_plan(
|
||||
@@ -125,7 +126,8 @@ async def test_backend_plan_rejects_not_ready_preflight_hash(backend):
|
||||
@pytest.mark.asyncio
|
||||
async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend):
|
||||
preflight = await backend.drawing_preflight(
|
||||
"L bracket", requirements=_complete_requirements(),
|
||||
"L bracket",
|
||||
requirements=_complete_requirements(),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="do not match"):
|
||||
@@ -148,7 +150,8 @@ async def test_legacy_plan_remains_compatible_and_marks_preflight_skipped(backen
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_change_clears_plan_preflight_and_gdt_state(backend):
|
||||
preflight = await backend.drawing_preflight(
|
||||
"First drawing", requirements=_complete_requirements(),
|
||||
"First drawing",
|
||||
requirements=_complete_requirements(),
|
||||
)
|
||||
await backend.drawing_plan(
|
||||
"First drawing",
|
||||
|
||||
@@ -66,9 +66,7 @@ async def test_refiner_repairs_construction_and_duplicates(backend):
|
||||
assert result.status == "threshold_met"
|
||||
assert result.final_score == 100.0
|
||||
assert result.rounds[0].score_after > result.rounds[0].score_before
|
||||
assert await backend.drawing_critique(
|
||||
["duplicate_entities", "construction_left"]
|
||||
) == []
|
||||
assert await backend.drawing_critique(["duplicate_entities", "construction_left"]) == []
|
||||
|
||||
|
||||
async def test_refiner_dry_run_does_not_mutate_drawing(backend):
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Release-consistency gates (v1.4.0 Faz 0).
|
||||
|
||||
Locks the version string, the README release snapshot, the server.py section
|
||||
headers, and the Dockerfile file selection to the actual state of the tree so
|
||||
none of them can drift again:
|
||||
|
||||
- pyproject version == CHANGELOG top release == README snapshot major.minor
|
||||
- version.py fallback parser returns the pyproject version
|
||||
- README snapshot tool/resource/prompt counts == live @mcp.* decorator counts
|
||||
- every ``SECTION n: ... (N tools)`` header matches the decorators below it
|
||||
- every wheel ``only-include`` entry is COPY'd into the Docker image
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
with (ROOT / "pyproject.toml").open("rb") as stream:
|
||||
return tomllib.load(stream)
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
return str(_pyproject()["project"]["version"])
|
||||
|
||||
|
||||
def _server_source() -> str:
|
||||
return (ROOT / "server.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
|
||||
|
||||
def test_pyproject_version_is_semver() -> None:
|
||||
assert SEMVER_RE.match(_pyproject_version()), (
|
||||
f"pyproject version {_pyproject_version()!r} is not MAJOR.MINOR.PATCH"
|
||||
)
|
||||
|
||||
|
||||
def test_version_py_fallback_matches_pyproject(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The pyproject fallback in version.py must resolve the same version.
|
||||
|
||||
The installed-metadata short circuit is forced to miss so the test checks
|
||||
our parser (the code path a source checkout without an install uses).
|
||||
"""
|
||||
import version as version_module
|
||||
|
||||
def _raise(_name: str) -> str:
|
||||
raise version_module.PackageNotFoundError
|
||||
|
||||
monkeypatch.setattr(version_module, "version", _raise)
|
||||
assert version_module.package_version() == _pyproject_version()
|
||||
|
||||
|
||||
def test_changelog_top_release_matches_pyproject() -> None:
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
match = re.search(r"^## \[(\d+\.\d+\.\d+)\]", changelog, flags=re.MULTILINE)
|
||||
assert match, "CHANGELOG.md has no '## [X.Y.Z]' release heading"
|
||||
assert match.group(1) == _pyproject_version(), (
|
||||
f"CHANGELOG top release {match.group(1)} != pyproject {_pyproject_version()}"
|
||||
)
|
||||
|
||||
|
||||
def _readme_snapshot() -> tuple[str, int, int, int]:
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
# The snapshot may wrap across blockquote lines; join continuations first.
|
||||
flat = re.sub(r"\n>\s*", " ", readme)
|
||||
match = re.search(
|
||||
r"\*\*v(\d+\.\d+) release snapshot:\*\*\s*(\d+) tools\D+?(\d+) resources"
|
||||
r"\D+?(\d+) prompt templates",
|
||||
flat,
|
||||
)
|
||||
assert match, "README.md release-snapshot line not found or malformed"
|
||||
return match.group(1), int(match.group(2)), int(match.group(3)), int(match.group(4))
|
||||
|
||||
|
||||
def test_readme_snapshot_matches_pyproject_minor() -> None:
|
||||
snapshot_minor, _, _, _ = _readme_snapshot()
|
||||
expected = ".".join(_pyproject_version().split(".")[:2])
|
||||
assert snapshot_minor == expected, (
|
||||
f"README snapshot v{snapshot_minor} != pyproject major.minor v{expected}"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_snapshot_counts_match_registrations() -> None:
|
||||
src = _server_source()
|
||||
tools = len(re.findall(r"@mcp\.tool\(", src))
|
||||
resources = len(re.findall(r"@mcp\.resource\(", src))
|
||||
prompts = len(re.findall(r"@mcp\.prompt\(", src))
|
||||
_, readme_tools, readme_resources, readme_prompts = _readme_snapshot()
|
||||
assert (readme_tools, readme_resources, readme_prompts) == (tools, resources, prompts), (
|
||||
f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} "
|
||||
f"(tools/resources/prompts) but server.py registers {tools}/{resources}/{prompts}"
|
||||
)
|
||||
|
||||
|
||||
def test_section_header_counts_match_decorators() -> None:
|
||||
src = _server_source()
|
||||
headers = [
|
||||
(m.start(), m.group(1), m.group(2))
|
||||
for m in re.finditer(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", src)
|
||||
]
|
||||
assert headers, "no counted SECTION headers found in server.py"
|
||||
# Segment boundaries: every SECTION header (counted or not) ends a segment.
|
||||
boundaries = sorted(m.start() for m in re.finditer(r"#[^\n]*SECTION [\w]+:", src))
|
||||
boundaries.append(len(src))
|
||||
mismatches: list[str] = []
|
||||
for start, section_id, declared in headers:
|
||||
end = min(b for b in boundaries if b > start)
|
||||
actual = len(re.findall(r"@mcp\.tool\(", src[start:end]))
|
||||
if actual != int(declared):
|
||||
mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}")
|
||||
assert not mismatches, "; ".join(mismatches)
|
||||
|
||||
|
||||
def test_server_json_versions_match_pyproject() -> None:
|
||||
"""MCP registry manifest must ship the same version as the package."""
|
||||
import json
|
||||
|
||||
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
||||
expected = _pyproject_version()
|
||||
assert manifest["version"] == expected, (
|
||||
f"server.json version {manifest['version']} != pyproject {expected}"
|
||||
)
|
||||
for package in manifest["packages"]:
|
||||
assert package["version"] == expected, (
|
||||
f"server.json package {package['identifier']} version "
|
||||
f"{package['version']} != pyproject {expected}"
|
||||
)
|
||||
if package["registryType"] == "pypi":
|
||||
assert package["identifier"] == _pyproject()["project"]["name"]
|
||||
|
||||
|
||||
def test_readme_contains_mcp_name_marker() -> None:
|
||||
"""PyPI ownership validation for the MCP registry needs this marker in the
|
||||
README (which ships to PyPI as the long description)."""
|
||||
import json
|
||||
|
||||
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert f"mcp-name: {manifest['name']}" in readme
|
||||
|
||||
|
||||
def test_dockerfile_copies_every_wheel_include() -> None:
|
||||
"""The Docker image must contain everything the wheel ships (GH bug: the
|
||||
image previously missed engineering/ and version.py)."""
|
||||
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
||||
copy_lines = "\n".join(
|
||||
line for line in dockerfile.splitlines() if line.strip().upper().startswith("COPY")
|
||||
)
|
||||
includes = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]["only-include"]
|
||||
missing = [name for name in includes if name not in copy_lines]
|
||||
assert not missing, f"Dockerfile COPY misses wheel-shipped paths: {missing}"
|
||||
@@ -73,6 +73,7 @@ class TestValidatePath:
|
||||
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
|
||||
|
||||
import config
|
||||
|
||||
config.settings = Settings()
|
||||
|
||||
blocked = tmp_path / "blocked" / "file.dxf"
|
||||
@@ -98,6 +99,7 @@ class TestValidatePath:
|
||||
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
|
||||
|
||||
import config
|
||||
|
||||
config.settings = Settings()
|
||||
|
||||
result = validate_path(str(test_file))
|
||||
@@ -160,6 +162,7 @@ class TestSanitizeCommand:
|
||||
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
|
||||
|
||||
import config
|
||||
|
||||
config.settings = Settings()
|
||||
|
||||
result = sanitize_command("ERASE ALL")
|
||||
@@ -222,6 +225,7 @@ class TestSanitizeLisp:
|
||||
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
|
||||
|
||||
import config
|
||||
|
||||
config.settings = Settings()
|
||||
|
||||
result = sanitize_lisp('(startapp "cmd")')
|
||||
|
||||
@@ -32,6 +32,7 @@ class _FakeCtx:
|
||||
|
||||
# ── NEW-screenshot-1 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_screenshot_renders_png_without_gui_thread_crash(backend):
|
||||
"""view_screenshot runs in a worker thread; the Agg figure must not crash and
|
||||
must return valid PNG bytes."""
|
||||
@@ -43,6 +44,7 @@ async def test_screenshot_renders_png_without_gui_thread_crash(backend):
|
||||
|
||||
# ── I15 — critique is part of the finalize gate ─────────────────────────────
|
||||
|
||||
|
||||
async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path):
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
@@ -68,6 +70,7 @@ async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_pa
|
||||
|
||||
# ── R4 — dim_overlap critique fires ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_dim_overlap_critique_is_not_a_noop(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
# Two dimensions measuring the same segment overlap exactly.
|
||||
@@ -80,6 +83,7 @@ async def test_dim_overlap_critique_is_not_a_noop(backend):
|
||||
|
||||
# ── N4 — dim / construction layers follow the active layer set ──────────────
|
||||
|
||||
|
||||
async def test_iso13567_dim_and_construction_layers_resolve(backend):
|
||||
await backend.drawing_apply_iso_layers("iso13567")
|
||||
assert backend._active_layer_set_id() == "iso13567"
|
||||
@@ -102,6 +106,7 @@ async def test_iso13567_dim_and_construction_layers_resolve(backend):
|
||||
|
||||
# ── NEW-AUTH-1 — bind guard on the run_async launch path ────────────────────
|
||||
|
||||
|
||||
async def test_run_async_invokes_bind_guard_for_http(monkeypatch):
|
||||
import server
|
||||
|
||||
@@ -121,8 +126,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
|
||||
import server
|
||||
|
||||
seen = {}
|
||||
monkeypatch.setattr(server, "_validate_http_bind",
|
||||
lambda h: seen.setdefault("host", h))
|
||||
monkeypatch.setattr(server, "_validate_http_bind", lambda h: seen.setdefault("host", h))
|
||||
|
||||
async def fake_orig(*args, **kwargs):
|
||||
return "stdio-ran"
|
||||
@@ -135,6 +139,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
|
||||
|
||||
# ── NEW-gear-roottooth — outline never dips inside the root circle ──────────
|
||||
|
||||
|
||||
async def test_gear_outline_stays_outside_root_circle():
|
||||
from engineering.gear import generate_full_gear_outline
|
||||
|
||||
@@ -144,19 +149,28 @@ async def test_gear_outline_stays_outside_root_circle():
|
||||
pts = generate_full_gear_outline(module, teeth, pa)
|
||||
radii = [math.hypot(x, y) for x, y in pts]
|
||||
# No point may sit inside the root circle (the self-overlap bug for z>=~42).
|
||||
assert min(radii) >= root_r - 1e-6, f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}"
|
||||
assert min(radii) >= root_r - 1e-6, (
|
||||
f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}"
|
||||
)
|
||||
|
||||
|
||||
# ── NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr ─────────
|
||||
|
||||
|
||||
async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend, tmp_path):
|
||||
from engineering.gear import draw_helical_gear_front_view
|
||||
from engineering.validator import DrawingValidator
|
||||
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
res = await draw_helical_gear_front_view(
|
||||
backend, module=2, teeth=20, helix_angle=0, center=(0, 0),
|
||||
bore_diameter=20, keyway_width=6, keyway_depth=2.8,
|
||||
backend,
|
||||
module=2,
|
||||
teeth=20,
|
||||
helix_angle=0,
|
||||
center=(0, 0),
|
||||
bore_diameter=20,
|
||||
keyway_width=6,
|
||||
keyway_depth=2.8,
|
||||
)
|
||||
# NEW-gear-baseconstr: base circle is a persistent PHANTOM reference, not CONSTRUCTION.
|
||||
base = await backend.entity_get(res["base_circle"])
|
||||
@@ -167,7 +181,8 @@ async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend
|
||||
# NEW-validator-keyway: the open 4-point keyway polyline is now recognised.
|
||||
await backend.drawing_save_as(str(tmp_path / "gear.dxf"))
|
||||
vr = await DrawingValidator().run(
|
||||
backend, expected={"must_have_bore": True, "must_have_keyway": True},
|
||||
backend,
|
||||
expected={"must_have_bore": True, "must_have_keyway": True},
|
||||
)
|
||||
assert "keyway_section_unverified" not in [f.code for f in vr.findings]
|
||||
|
||||
@@ -178,9 +193,15 @@ async def test_gear_section_does_not_duplicate_bore_lines(backend):
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await draw_gear_section_aa(
|
||||
backend,
|
||||
gear_metadata={"outer_radius": 22, "center": [0, 0], "bore_diameter": 20,
|
||||
"keyway_width": 6, "keyway_depth": 2.8},
|
||||
x_offset=100, face_width=30,
|
||||
gear_metadata={
|
||||
"outer_radius": 22,
|
||||
"center": [0, 0],
|
||||
"bore_diameter": 20,
|
||||
"keyway_width": 6,
|
||||
"keyway_depth": 2.8,
|
||||
},
|
||||
x_offset=100,
|
||||
face_width=30,
|
||||
)
|
||||
dups = await backend.drawing_critique(focus=["duplicate_entities"])
|
||||
assert dups == []
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Regression tests for the CI-verifiable (ezdxf / shared) Sprint-3 fixes.
|
||||
|
||||
* N2 — drawing_save_as derives the on-disk format from the path extension
|
||||
* N3 — ARC carries a `length` property; entity_select_smart length_range selects it
|
||||
* N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation
|
||||
* NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline
|
||||
* NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset
|
||||
* N2 — drawing_save_as derives the on-disk format from the path extension
|
||||
* N3 — ARC carries a `length` property; entity_select_smart length_range selects it
|
||||
* N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation
|
||||
* NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline
|
||||
* NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +27,7 @@ class _Ctx:
|
||||
|
||||
# ── N2 — format follows the file extension ──────────────────────────────────
|
||||
|
||||
|
||||
async def test_save_as_derives_format_from_extension(backend):
|
||||
import server
|
||||
|
||||
@@ -42,6 +43,7 @@ async def test_save_as_derives_format_from_extension(backend):
|
||||
|
||||
# ── N3 — ARC length + length_range selection ────────────────────────────────
|
||||
|
||||
|
||||
async def test_arc_has_length_and_is_selectable(backend):
|
||||
arc = await backend.entity_create_arc(0, 0, 10, 0, 90) # r=10, 90deg -> len ~15.708
|
||||
info = await backend.entity_get(arc.handle)
|
||||
@@ -55,6 +57,7 @@ async def test_arc_has_length_and_is_selectable(backend):
|
||||
|
||||
# ── N5 — cross-backend property parity ──────────────────────────────────────
|
||||
|
||||
|
||||
async def test_ezdxf_populates_bounding_box(backend):
|
||||
line = await backend.entity_create_line(0, 0, 30, 40)
|
||||
info = await backend.entity_get(line.handle)
|
||||
@@ -78,6 +81,7 @@ async def test_mtext_honors_caller_rotation(backend):
|
||||
|
||||
# ── NEW-dimauto-baseline-rotation ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
|
||||
ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline
|
||||
dims = await backend.dimension_auto([ln.handle], style="baseline")
|
||||
@@ -90,6 +94,7 @@ async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
|
||||
|
||||
# ── NEW-dimauto-ordinate-misnomer ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_ordinate_dims_are_staggered_not_overlapping(backend):
|
||||
l1 = await backend.entity_create_line(0, 0, 20, 10)
|
||||
l2 = await backend.entity_create_line(0, 0, 40, 30)
|
||||
|
||||
@@ -25,6 +25,7 @@ pytestmark = pytest.mark.asyncio
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
|
||||
"""True if pt lies on the circle of radius r AND within the [sa, ea] sweep
|
||||
(degrees, CCW, with wraparound)."""
|
||||
@@ -41,10 +42,12 @@ def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
|
||||
# N7 — ARC snap angle-missing raises RuntimeError, not KeyError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestN7ArcAngleMissing:
|
||||
async def _patch_entity_get(self, backend, info: EntityInfo):
|
||||
async def _fake(handle):
|
||||
return info
|
||||
|
||||
backend.entity_get = _fake # type: ignore[method-assign]
|
||||
|
||||
async def test_end_snap_missing_angles_raises_runtimeerror(self, backend):
|
||||
@@ -107,6 +110,7 @@ class TestN7ArcAngleMissing:
|
||||
# NEW-base-1 — point_tangent on/near the perimeter must not raise ValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNewBase1TangentClamp:
|
||||
async def test_from_point_exactly_on_circle_no_valueerror(self, backend):
|
||||
"""A from-point exactly on the perimeter (d == r) degenerates to the
|
||||
@@ -145,6 +149,7 @@ class TestNewBase1TangentClamp:
|
||||
# NEW-snap-quad-arc / NEW-snap-near-arc — snaps stay on the arc sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNewSnapArcSweep:
|
||||
async def test_quad_on_quarter_arc_stays_on_sweep(self, backend):
|
||||
"""Quarter arc 0..90 deg, r=10 at origin. Only the 0-deg and 90-deg
|
||||
@@ -208,6 +213,7 @@ class TestNewSnapArcSweep:
|
||||
# N8 — get_plan_spec accessor exposes the stored PlanSpec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestN8GetPlanSpec:
|
||||
async def test_none_before_plan(self, backend):
|
||||
assert backend.get_plan_spec() is None
|
||||
|
||||
@@ -34,6 +34,7 @@ def _backend_no_executor() -> ComBackend:
|
||||
|
||||
# ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ───────
|
||||
|
||||
|
||||
async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
|
||||
"""The real main-frame handle from the COM app wins; EnumWindows is not
|
||||
consulted when the HWND read succeeds."""
|
||||
@@ -43,7 +44,9 @@ async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
|
||||
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
|
||||
|
||||
enum = MagicMock(side_effect=AssertionError("EnumWindows must not be called"))
|
||||
monkeypatch.setattr(cb, "win32gui", MagicMock(EnumWindows=enum))
|
||||
# raising=False: the win32gui module global only exists on Windows; the
|
||||
# mock must be injectable on the Linux CI leg as well.
|
||||
monkeypatch.setattr(cb, "win32gui", MagicMock(EnumWindows=enum), raising=False)
|
||||
|
||||
assert cb._find_autocad_hwnd() == 0xABCD
|
||||
enum.assert_not_called()
|
||||
@@ -55,7 +58,7 @@ async def test_find_hwnd_coerces_hwnd_to_int(monkeypatch):
|
||||
app.HWND = "43981" # COM can hand back a stringy handle
|
||||
monkeypatch.setattr(cb, "_acad_app", lambda: app)
|
||||
monkeypatch.setattr(cb, "_WIN32_AVAILABLE", True)
|
||||
monkeypatch.setattr(cb, "win32gui", MagicMock())
|
||||
monkeypatch.setattr(cb, "win32gui", MagicMock(), raising=False)
|
||||
|
||||
result = cb._find_autocad_hwnd()
|
||||
assert result == 43981
|
||||
@@ -80,7 +83,7 @@ async def test_find_hwnd_falls_back_to_enumwindows(monkeypatch):
|
||||
win32gui.EnumWindows.side_effect = _enum_windows
|
||||
win32gui.GetWindowText.return_value = "AutoCAD 2025 - [Drawing1.dwg]"
|
||||
win32gui.IsWindowVisible.return_value = True
|
||||
monkeypatch.setattr(cb, "win32gui", win32gui)
|
||||
monkeypatch.setattr(cb, "win32gui", win32gui, raising=False)
|
||||
|
||||
assert cb._find_autocad_hwnd() == 0x1234
|
||||
win32gui.EnumWindows.assert_called_once()
|
||||
@@ -98,6 +101,7 @@ async def test_find_hwnd_returns_none_when_win32_unavailable(monkeypatch):
|
||||
|
||||
# ── S3 — block_list populates BlockInfo.description from .Comments ───────────
|
||||
|
||||
|
||||
class _Block:
|
||||
"""Minimal stand-in for an AutoCAD block definition."""
|
||||
|
||||
@@ -167,10 +171,12 @@ async def test_block_list_description_empty_when_comments_none(monkeypatch):
|
||||
|
||||
|
||||
async def test_block_list_skips_anonymous_layout_blocks(monkeypatch):
|
||||
doc = _doc_with_blocks([
|
||||
_Block("*Model_Space", comments="ignored"),
|
||||
_Block("GEAR", comments="spur gear"),
|
||||
])
|
||||
doc = _doc_with_blocks(
|
||||
[
|
||||
_Block("*Model_Space", comments="ignored"),
|
||||
_Block("GEAR", comments="spur gear"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
|
||||
b = _backend_no_executor()
|
||||
|
||||
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.asyncio
|
||||
|
||||
# ── R24 — CENTER/HIDDEN linetype actually loaded + applied ──────────────────
|
||||
|
||||
|
||||
async def test_create_line_loads_center_linetype(backend):
|
||||
# Fresh doc: CENTER is not loaded yet.
|
||||
assert "CENTER" not in backend._doc.linetypes
|
||||
@@ -53,6 +54,7 @@ async def test_create_line_continuous_does_not_error(backend):
|
||||
|
||||
# ── S3 — block description surfaced from the definition ─────────────────────
|
||||
|
||||
|
||||
async def test_block_list_surfaces_description(backend):
|
||||
# Create a block from an entity, then stamp a description on its definition.
|
||||
seg = await backend.entity_create_line(0, 0, 10, 0)
|
||||
@@ -76,6 +78,7 @@ async def test_block_list_description_defaults_empty(backend):
|
||||
|
||||
# ── R26 / N9 — honest zoom payload shape ────────────────────────────────────
|
||||
|
||||
|
||||
async def test_zoom_extents_reports_not_applied(backend):
|
||||
res = await backend.view_zoom_extents()
|
||||
assert res["ok"] is True
|
||||
@@ -92,6 +95,7 @@ async def test_zoom_window_reports_not_applied(backend):
|
||||
|
||||
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
|
||||
|
||||
|
||||
async def test_undo_empty_stack_returns_error(backend):
|
||||
res = await backend.drawing_undo()
|
||||
assert res["ok"] is False
|
||||
|
||||
@@ -35,6 +35,7 @@ class _Ctx:
|
||||
|
||||
# ── R20 — tool count comes from the public accessor and is a real number ─────
|
||||
|
||||
|
||||
async def test_registered_tool_count_is_positive_never_minus_one():
|
||||
import server
|
||||
|
||||
@@ -59,6 +60,7 @@ async def test_registered_tools_returns_objects_with_name_and_tags():
|
||||
|
||||
# ── R15 — system_about tool_groups is dynamic + correct ──────────────────────
|
||||
|
||||
|
||||
async def test_system_about_includes_engineering_group(backend):
|
||||
import server
|
||||
|
||||
@@ -122,6 +124,7 @@ async def test_drawing_close_appears_in_groups(backend):
|
||||
|
||||
# ── R20 — system_status reports a real tool_count, never -1 ──────────────────
|
||||
|
||||
|
||||
async def test_system_status_reports_real_tool_count(backend):
|
||||
import server
|
||||
|
||||
@@ -134,6 +137,7 @@ async def test_system_status_reports_real_tool_count(backend):
|
||||
|
||||
# ── N8 — drawing_plan docstring corrected ────────────────────────────────────
|
||||
|
||||
|
||||
async def test_drawing_plan_docstring_not_misleading():
|
||||
import server
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ async def test_spur_as_substring_does_not_false_match(backend, tmp_path):
|
||||
"""R27: 'SPURIOUS' contains 'spur' but must NOT trip a SPUR mismatch."""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="SPURIOUS HELICAL GEAR", x=0, y=0, height=5, layer="TEXT",
|
||||
text="SPURIOUS HELICAL GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TEXT",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "spurious.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||
@@ -50,7 +54,11 @@ async def test_genuine_spur_title_flags_when_helical_expected(backend, tmp_path)
|
||||
"""R27: a real 'SPUR GEAR' title still flags the mismatch when helix declared."""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
|
||||
text="SPUR GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TEXT",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "spur.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||
@@ -63,7 +71,11 @@ async def test_spur_word_in_titleblock_layer_flags(backend, tmp_path):
|
||||
"""R27: title-block-scoped scan — SPUR on TITLEBLOCK layer still flags."""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="SPUR GEAR", x=0, y=0, height=5, layer="TITLEBLOCK",
|
||||
text="SPUR GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TITLEBLOCK",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "spur_tb.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||
@@ -74,7 +86,11 @@ async def test_substring_in_titleblock_layer_does_not_false_match(backend, tmp_p
|
||||
"""R27: 'SPURIOUS' on TITLEBLOCK layer must not trip a SPUR match either."""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="SPURIOUS NOTE", x=0, y=0, height=5, layer="TITLEBLOCK",
|
||||
text="SPURIOUS NOTE",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TITLEBLOCK",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "spurious_tb.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||
@@ -87,7 +103,11 @@ async def test_helical_spur_combo_still_flags_invalid(backend, tmp_path):
|
||||
"""R27: both whole words present -> invalid combo error preserved."""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
|
||||
text="HELICAL SPUR GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TEXT",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "combo.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||
@@ -99,7 +119,11 @@ async def test_no_helix_expected_skips_title_check(backend, tmp_path):
|
||||
"""Gating preserved: without helix_angle, a SPUR title raises no title finding."""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
|
||||
text="SPUR GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TEXT",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "nohelix.dxf"))
|
||||
result = await DrawingValidator().run(backend)
|
||||
@@ -116,10 +140,18 @@ async def test_titleblock_layer_takes_precedence_over_text_layer(backend, tmp_pa
|
||||
"""
|
||||
await _bootstrap(backend)
|
||||
await backend.entity_create_text(
|
||||
text="HELICAL GEAR", x=0, y=0, height=5, layer="TITLEBLOCK",
|
||||
text="HELICAL GEAR",
|
||||
x=0,
|
||||
y=0,
|
||||
height=5,
|
||||
layer="TITLEBLOCK",
|
||||
)
|
||||
await backend.entity_create_text(
|
||||
text="SPUR TOOTH DETAIL NOTE", x=0, y=-20, height=3.5, layer="TEXT",
|
||||
text="SPUR TOOTH DETAIL NOTE",
|
||||
x=0,
|
||||
y=-20,
|
||||
height=3.5,
|
||||
layer="TEXT",
|
||||
)
|
||||
await backend.drawing_save_as(str(tmp_path / "scope.dxf"))
|
||||
result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
|
||||
|
||||
@@ -29,9 +29,7 @@ async def test_ezdxf_table_returns_composite_contract(backend):
|
||||
|
||||
|
||||
async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
|
||||
result = await backend.entity_create_table(
|
||||
0, 30, rows=[["A", "1"]], headers=["Name", "Qty"]
|
||||
)
|
||||
result = await backend.entity_create_table(0, 30, rows=[["A", "1"]], headers=["Name", "Qty"])
|
||||
path = tmp_path / "table.dxf"
|
||||
await backend.drawing_save_as(str(path), fmt="dxf")
|
||||
await backend.drawing_open(str(path))
|
||||
@@ -39,9 +37,7 @@ async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
|
||||
entities = await backend.entity_list(limit=100)
|
||||
handles = {entity.handle for entity in entities}
|
||||
texts = {
|
||||
entity.properties.get("text")
|
||||
for entity in entities
|
||||
if entity.type in {"TEXT", "MTEXT"}
|
||||
entity.properties.get("text") for entity in entities if entity.type in {"TEXT", "MTEXT"}
|
||||
}
|
||||
assert set(result.properties["child_handles"]).issubset(handles)
|
||||
assert {"Name", "Qty", "A", "1"}.issubset(texts)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
|
||||
import config
|
||||
import server
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _restore_full_profile():
|
||||
"""Profiles mutate global server enablement; always restore full."""
|
||||
yield
|
||||
await server._apply_tool_profile("full")
|
||||
|
||||
|
||||
async def _registered_names() -> set[str]:
|
||||
return {tool.name for tool in await server._registered_tools() if getattr(tool, "name", None)}
|
||||
|
||||
|
||||
async def test_lean_names_all_exist_in_registry():
|
||||
registered = await _registered_names()
|
||||
missing = server.LEAN_TOOL_NAMES - registered
|
||||
assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}"
|
||||
|
||||
|
||||
async def test_core_exclusions_all_exist_in_registry():
|
||||
registered = await _registered_names()
|
||||
missing = server.CORE_EXCLUDED_TOOL_NAMES - registered
|
||||
assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}"
|
||||
|
||||
|
||||
async def test_full_profile_hides_only_gated_solids():
|
||||
"""Full hides nothing except the opt-in 3D tools while ENABLE_3D=false."""
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert info["profile"] == "full"
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_invalid_profile_falls_back_to_full():
|
||||
info = await server._apply_tool_profile("does-not-exist")
|
||||
assert info["profile"] == "full"
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_lean_profile_filters_client_view(monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "backend", "ezdxf")
|
||||
monkeypatch.setattr(config.settings, "tool_profile", "lean")
|
||||
async with Client(server.mcp) as client:
|
||||
visible = {tool.name for tool in await client.list_tools()}
|
||||
assert visible == set(server.LEAN_TOOL_NAMES)
|
||||
|
||||
|
||||
async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "backend", "ezdxf")
|
||||
monkeypatch.setattr(config.settings, "tool_profile", "core")
|
||||
async with Client(server.mcp) as client:
|
||||
visible = {tool.name for tool in await client.list_tools()}
|
||||
assert "system_run_command" not in visible
|
||||
assert "system_run_lisp" not in visible
|
||||
assert "gear_draw_spur_front_view" in visible
|
||||
assert "drawing_refine" in visible
|
||||
registered = await _registered_names()
|
||||
assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES
|
||||
|
||||
|
||||
async def test_profile_switch_is_idempotent():
|
||||
await server._apply_tool_profile("lean")
|
||||
info = await server._apply_tool_profile("full")
|
||||
assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES)
|
||||
lean_again = await server._apply_tool_profile("lean")
|
||||
assert lean_again["enabled_count"] == len(server.LEAN_TOOL_NAMES)
|
||||
Reference in New Issue
Block a user