release: v1.5.0 — discovery layer, honesty debt closed, Wave A
Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5a0ff5109
commit
b03a7615b1
+53
-3
@@ -1,8 +1,37 @@
|
||||
# AutoCAD MCP Pro — Environment Variables
|
||||
|
||||
# Rendering (view_screenshot / drawing_export_pdf) is an install-time choice,
|
||||
# not an env var — the core install cannot render at all. ezdxf's drawing
|
||||
# add-on imports Pillow unconditionally, so every render path needs it:
|
||||
# pip install "autocad-mcp-pro[pdf]" ezdxf backend, matplotlib renderer
|
||||
# (also pulls Pillow in transitively)
|
||||
# pip install "autocad-mcp-pro[com]" live AutoCAD Win32 window capture
|
||||
# pip install "autocad-mcp-pro[full]" both
|
||||
# With only [com] the ezdxf backend still cannot render (no matplotlib).
|
||||
|
||||
# Backend selection: auto | com | ezdxf
|
||||
AUTOCAD_MCP_BACKEND=auto
|
||||
|
||||
# COM ProgID the live backend attaches to. Escape hatch for AutoCAD-compatible
|
||||
# applications that expose the same ActiveX object model.
|
||||
#
|
||||
# CAD_PROGID only changes which COM application the backend attaches to — every
|
||||
# tool beyond the connection itself is developed and tested against AutoCAD
|
||||
# alone, and AutoCAD-clone compatibility is unverified, so treat a non-default
|
||||
# ProgID as experimental. An unrecognised ProgID fails loudly rather than
|
||||
# falling back to AutoCAD, because the fallback would launch the very
|
||||
# application you said you were not using.
|
||||
#
|
||||
# Candidates below are UNVERIFIED — none was tested against a running seat.
|
||||
# List what is actually registered on your machine with:
|
||||
# Get-ChildItem Registry::HKEY_CLASSES_ROOT |
|
||||
# Where-Object { $_.PSChildName -like '*.Application*' } |
|
||||
# Select-Object -Expand PSChildName
|
||||
# CAD_PROGID=BricscadApp.AcadApplication # UNVERIFIED
|
||||
# CAD_PROGID=ZWCAD.Application # UNVERIFIED
|
||||
# CAD_PROGID=GstarCAD.Application # UNVERIFIED
|
||||
CAD_PROGID=AutoCAD.Application
|
||||
|
||||
# Comma-separated list of allowed directories for file operations.
|
||||
# Leave empty to allow all paths (not recommended for production).
|
||||
# Example: ALLOWED_PATHS=C:\Drawings,D:\Projects\CAD
|
||||
@@ -18,10 +47,31 @@ LOG_LEVEL=INFO
|
||||
# Set to true only if you trust all MCP clients
|
||||
DANGEROUS_COMMANDS_ENABLED=false
|
||||
|
||||
# Exposed tool profile: full (default) | core | lean
|
||||
# core hides raw escape hatches and long-tail tools; lean is a curated
|
||||
# ~46-tool drafting/inspection core for clients with tight tool caps.
|
||||
# Exposed tool profile: full (default) | lean
|
||||
# lean is a curated ~47-tool drafting/inspection set for clients with tight
|
||||
# tool caps. The "core" profile was removed in v1.5.0 — the discovery layer
|
||||
# below solves the crowded-surface problem it existed for. TOOL_PROFILE=core
|
||||
# still boots, falling back to full with a warning.
|
||||
TOOL_PROFILE=full
|
||||
|
||||
# Tool discovery: off (default) | search
|
||||
# "search" replaces the advertised catalog with search_tools + call_tool, so a
|
||||
# client sees two tools instead of ~126 and searches by AutoCAD command name
|
||||
# ("QSELECT") or plain English ("round off this corner").
|
||||
DISCOVERY_MODE=off
|
||||
|
||||
# Per-call backend timeouts in seconds; 0 disables the check.
|
||||
# A hung call would otherwise hold the backend lock forever.
|
||||
COM_CALL_TIMEOUT=60
|
||||
EZDXF_CALL_TIMEOUT=120
|
||||
|
||||
# Undo/redo history for the headless backend, in steps. 0 disables it.
|
||||
# Not free: ezdxf has no journal, so each step is a full DXF snapshot.
|
||||
# Measured 37x slower entity creation with it on (0.18 -> 6.65 ms/call),
|
||||
# and a snapshot of a 5000-entity drawing costs 130 ms / 658 KB. For a
|
||||
# single checkpoint use transaction_begin/rollback instead.
|
||||
# The live COM backend uses AutoCAD's own undo and ignores this.
|
||||
EZDXF_UNDO_DEPTH=0
|
||||
|
||||
# Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported)
|
||||
ENABLE_3D=false
|
||||
|
||||
@@ -10,6 +10,10 @@ on:
|
||||
schedule:
|
||||
- cron: "17 5 * * 1"
|
||||
|
||||
env:
|
||||
# Keep in step with ci.yml; all three workflows read the same uv.lock.
|
||||
LOCKED_UV_VERSION: "0.12.1"
|
||||
|
||||
jobs:
|
||||
headless-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,13 +27,19 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
id: python
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install
|
||||
run: pip install -e ".[pdf]"
|
||||
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
|
||||
- name: Install the locked harness environment
|
||||
# This lane publishes evidence, so the harness must not float: an ezdxf
|
||||
# release would otherwise move our own numbers between runs and the
|
||||
# diff would be unattributable. Competitors keep their own pinned
|
||||
# checkouts and venvs (benchmarks/competitors_env.py) either way.
|
||||
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
|
||||
- name: Run benchmark (ezdxf lane)
|
||||
run: |
|
||||
python -m benchmarks.run_competitors \
|
||||
uv run --no-sync python -m benchmarks.run_competitors \
|
||||
--server "${{ matrix.server }}" \
|
||||
--backend ezdxf \
|
||||
--artifact-dir "benchmarks/results/ci/${{ matrix.server }}" \
|
||||
|
||||
+43
-13
@@ -11,6 +11,25 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Two deliberately different installation paths run in this file:
|
||||
#
|
||||
# lint / test-* install from uv.lock with `--locked`. Every version, down to
|
||||
# the transitive graph, is the one the suite was last run
|
||||
# against; `--locked` *fails* rather than re-resolving if
|
||||
# uv.lock has drifted from pyproject.toml. A release upstream
|
||||
# can no longer turn main red on its own schedule.
|
||||
# package installs the built wheel with plain pip, from the dependency
|
||||
# ranges in pyproject.toml — on purpose. That is the path a
|
||||
# user gets from PyPI, and it is the only job that exercises
|
||||
# it, so it must not be locked.
|
||||
#
|
||||
# Locked CI is blind to upstream drift by construction; deps-floating.yml is the
|
||||
# deliberate counterweight that resolves the ranges fresh on a schedule.
|
||||
env:
|
||||
# Pinned for the same reason ruff is: the tool that reads the lockfile is part
|
||||
# of the reproducible environment. Bump this and re-run `uv lock` together.
|
||||
LOCKED_UV_VERSION: "0.12.1"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,9 +38,14 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install ruff
|
||||
- run: ruff check .
|
||||
- run: ruff format --check .
|
||||
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
|
||||
# Dev group only: ruff comes from the lock, the project is not built.
|
||||
# The ruff version lives in [dependency-groups] dev in pyproject.toml and
|
||||
# nowhere else — the formatter's output changes between minors, so a
|
||||
# floating ruff fails `ruff format --check .` on unchanged code.
|
||||
- run: uv sync --locked --only-group dev
|
||||
- run: uv run --no-sync ruff check .
|
||||
- run: uv run --no-sync ruff format --check .
|
||||
|
||||
test-linux:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,14 +56,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
id: python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e ".[pdf]"
|
||||
pip install pytest pytest-asyncio pytest-cov
|
||||
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
|
||||
- name: Install the locked environment
|
||||
# uv.lock is a universal resolution over requires-python >=3.11, so the
|
||||
# same file serves both matrix legs; --python keeps uv on the
|
||||
# interpreter setup-python just provisioned instead of picking its own.
|
||||
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
|
||||
- name: Run tests
|
||||
run: pytest --cov=. --cov-report=xml --cov-report=term-missing -v
|
||||
run: uv run --no-sync pytest --cov=. --cov-report=xml --cov-report=term-missing -v
|
||||
- name: Upload coverage
|
||||
if: matrix.python-version == '3.11'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -54,14 +81,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
id: python
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e ".[full]"
|
||||
pip install pytest pytest-asyncio pytest-cov
|
||||
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
|
||||
- name: Install the locked environment
|
||||
run: uv sync --locked --extra full --python "${{ steps.python.outputs.python-path }}"
|
||||
- name: Run tests
|
||||
run: pytest -v
|
||||
run: uv run --no-sync pytest -v
|
||||
|
||||
package:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -75,6 +102,9 @@ jobs:
|
||||
pip install build twine
|
||||
python -m build
|
||||
twine check dist/*
|
||||
# Unlocked on purpose: this resolves the published dependency ranges the
|
||||
# way a user's `pip install autocad-mcp-pro` does. Locking it would mean
|
||||
# nothing in CI ever proves those ranges are installable.
|
||||
- name: Install wheel in a clean venv and smoke test
|
||||
run: |
|
||||
python -m venv /tmp/smoke
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Floating deps
|
||||
|
||||
# The counterweight to a locked CI.
|
||||
#
|
||||
# ci.yml installs from uv.lock, so a PR can never be broken by an upstream
|
||||
# release. That is the point — and it is also a blind spot: a new fastmcp,
|
||||
# ezdxf or pydantic inside the ranges declared in pyproject.toml would go
|
||||
# unnoticed here until a user hit it, because the only unlocked job in CI
|
||||
# (`package`) merely imports the wheel and never runs the suite.
|
||||
#
|
||||
# This job is the deliberate opposite: resolve the declared ranges fresh and
|
||||
# run the full suite against whatever the index serves today. It runs on a
|
||||
# schedule and on demand only, so it never gates a pull request — a red run
|
||||
# here means "the ceiling needs a look", not "your change is broken".
|
||||
#
|
||||
# Lint is intentionally absent: ruff's formatter output moves between minors,
|
||||
# so a floating ruff would fail on unchanged source and say nothing about the
|
||||
# runtime dependencies this job exists to watch.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Mondays, 06:17 UTC. Off the hour so it does not queue behind the
|
||||
# every-cron-on-the-hour crowd.
|
||||
- cron: "17 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
floating:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install from the declared ranges (uv.lock deliberately ignored)
|
||||
run: |
|
||||
pip install -e ".[pdf]"
|
||||
pip install pytest pytest-asyncio
|
||||
- name: Record what actually resolved
|
||||
# Printed unconditionally: when this job fails, the version list is the
|
||||
# first thing needed to tell which upstream moved.
|
||||
run: pip list
|
||||
- run: pytest -q
|
||||
@@ -13,20 +13,29 @@ on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
env:
|
||||
# Keep in step with ci.yml; both read the same uv.lock.
|
||||
LOCKED_UV_VERSION: "0.12.1"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
id: python
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e ".[pdf]"
|
||||
pip install pytest pytest-asyncio ruff
|
||||
- run: ruff check .
|
||||
- run: pytest -q
|
||||
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
|
||||
- name: Install the locked environment
|
||||
# `--locked` refuses to re-resolve, so the artifact that goes to PyPI is
|
||||
# gated by the exact dependency set the suite was run against — not by
|
||||
# whatever the index happens to serve at tag time. ruff's version comes
|
||||
# from [dependency-groups] dev via the lock; it is not restated here.
|
||||
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
|
||||
- run: uv run --no-sync ruff check .
|
||||
- run: uv run --no-sync ruff format --check .
|
||||
- run: uv run --no-sync pytest -q
|
||||
|
||||
build:
|
||||
needs: test
|
||||
|
||||
+4
-2
@@ -53,5 +53,7 @@ benchmarks/.competitors/
|
||||
# Local editor/agent state
|
||||
.cursor/
|
||||
|
||||
# Stale/unvetted lockfile (regenerate deliberately with 'uv lock' if adopting uv)
|
||||
uv.lock
|
||||
# NOTE: uv.lock is deliberately *not* ignored. It used to be — back when the
|
||||
# committed one was stale and unvetted — but CI now installs from it with
|
||||
# `uv sync --locked`, so ignoring it would silently disarm every locked lane.
|
||||
# Regenerate with `uv lock` whenever pyproject.toml dependencies change.
|
||||
|
||||
+727
-4
@@ -5,10 +5,264 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [1.5.0] — 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Selection filters — 3 tools.** `selection_window` (AutoCAD's ssget
|
||||
window/crossing), `selection_polygon` and `selection_filter` (QSELECT).
|
||||
Window returns only entities wholly inside; crossing also returns the ones
|
||||
straddling the edge — the modes are reported back and `window` is the
|
||||
default, because the over-selecting mode must never be the implicit one. A
|
||||
zero-area box is refused rather than answered with an empty list, which would
|
||||
read as "nothing there".
|
||||
`selection_filter` takes named parameters rather than a query string
|
||||
deliberately: ezdxf's query language answers an unknown attribute with an
|
||||
empty result, so `LINE[nosuchattr=='x']` is indistinguishable from "no
|
||||
matches". Here a typo is a `TypeError` at the boundary, and `filtered_by`
|
||||
reports which filters actually ran. `min_area` excludes shapes that have no
|
||||
area at all rather than treating them as zero, and a negative `min_area` is
|
||||
refused — no area is negative.
|
||||
- **Boundary tracing — 2 tools, and five deliberately not built.**
|
||||
`boundary_trace` is AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest
|
||||
enclosing closed polyline out. It never calls `edgeminer.find_all_loops` —
|
||||
measured at **63.9 s for 40 edges and a TimeoutError at 60**, because the
|
||||
docstring's O(n!) is real — and walks one loop per seed-adjacent edge
|
||||
instead, which is 0.0057 s at the same size. Straight edges are split where
|
||||
they cross, so a construction line drawn across a shape actually divides it;
|
||||
without that the line shares no endpoint with anything and is invisible to
|
||||
the edge graph. Curved edges are not split, and the capability says so.
|
||||
Two things the obvious implementation gets silently wrong, both now tested:
|
||||
`edgesmith.lwpolyline_from_chain` emits n+1 points with the last duplicating
|
||||
the first and `closed` left False — a polyline that draws like a square and
|
||||
behaves like an open path to hatch association, area queries and
|
||||
`entity_offset`; and `edgesmith.loop_area` works off edge endpoints, so two
|
||||
semicircular arcs closing into a circle measure **0.0** against a true
|
||||
314.159. The area now comes from the flattened curve and each curved edge
|
||||
keeps its bulge.
|
||||
**Not built: every REGION and 2D-boolean tool the plan listed.** ezdxf cannot
|
||||
author a REGION — `add_region()` returns one with zero ACIS bytes — and the
|
||||
substitute, `greiner_hormann`, takes flat vertex lists. Measured on a square
|
||||
with one edge bulged into a semicircle, routing it through that boolean loses
|
||||
**28.2% of the area and 12.5% of the perimeter**. That is the same magnitude
|
||||
as the `analysis_measure_area` bug this release fixed, and shipping it would
|
||||
have re-introduced the class.
|
||||
- **Hatch depth — 3 tools.** `hatch_set_gradient`, `hatch_edit` and
|
||||
`hatch_add_boundary`. `hatch_edit` leaves omitted parameters alone — a
|
||||
partial edit that resets the rest is data loss — and `changed` reports the
|
||||
attributes that actually *moved*, so re-setting a value to what it already
|
||||
was comes back empty rather than as a false positive. `hatch_add_boundary`
|
||||
takes typed edges (line/arc/ellipse) because a boundary API that only accepts
|
||||
vertex lists silently straightens every curve it is given; every edge is
|
||||
validated before any is written, so a malformed list refuses instead of
|
||||
leaving a half-built path. On COM it refuses with
|
||||
`capability: "hatch_edge_paths"`: ActiveX appends boundary loops as existing
|
||||
objects, not as typed edges, so the fidelity this tool exists for cannot be
|
||||
expressed there.
|
||||
- **`analysis_list_properties` — AutoCAD's LIST.** The raw DXF attribute set
|
||||
for one handle, which `entity_get` deliberately does not carry. It cannot be
|
||||
handed over as-is for two reasons, both fixed here: `dxfattribs()` holds
|
||||
`Vec3` values and is not JSON-serialisable, and its coordinates are in the
|
||||
entity's own frame, so for anything with an extrusion they would contradict
|
||||
every other coordinate this server reports.
|
||||
- **Annotation objects — 4 tools.** `entity_create_wipeout` (mask what is
|
||||
behind a closed polygon), `entity_create_revcloud`, `text_set_background`
|
||||
(MTEXT background mask) and `text_find_replace`. Each carries a refusal that
|
||||
was measured rather than assumed. A wipeout of fewer than three points
|
||||
encloses no area, so it is refused instead of returning a handle to a mask
|
||||
that masks nothing. A revision cloud *is* its arcs — `ezdxf.revcloud`'s own
|
||||
helper stamps the `RevcloudProps` xdata and takes the bulge sign from the
|
||||
winding direction, so the result satisfies `is_revcloud()` — and a
|
||||
`segment_length` longer than the shortest edge produces no arcs at all, which
|
||||
would be a plain rectangle reported as a cloud; that case is refused.
|
||||
`text_set_background` refuses TEXT (only MTEXT has a background-fill
|
||||
attribute, so setting one on TEXT would report success and change nothing)
|
||||
and refuses a scale below 1.0 (a box smaller than its text is a stripe
|
||||
through the text).
|
||||
`text_find_replace` searches TEXT, MTEXT, ATTRIB **and** ATTDEF, including
|
||||
inside block definitions — leaving the ATTDEF behind would fix the drawing
|
||||
and reintroduce the old text on the next insert. It returns
|
||||
`searched_types`, because "no matches" and "that type was never searched" are
|
||||
different answers and a caller cannot otherwise tell them apart. DIMENSION
|
||||
text is deliberately out of scope: its text field holds the `<>` override
|
||||
placeholder rather than the measurement, so editing it would either do
|
||||
nothing or detach the dimension from what it measures — the response says so.
|
||||
On COM, `entity_create_revcloud` refuses with `capability: "revcloud"`:
|
||||
ActiveX has no revision-cloud member and driving the command blind against a
|
||||
live drawing is unverified.
|
||||
- **The rest of the paper-space lifecycle — 8 tools.** v1.4 could make a sheet
|
||||
and never unmake it. Added `layout_delete`, `layout_rename`, `layout_copy`,
|
||||
`viewport_list`, `viewport_set_scale`, `viewport_lock`, `viewport_delete` and
|
||||
`entity_change_space` (CHSPACE). Verified headless (ezdxf); the COM paths for
|
||||
the first seven are implemented against the ActiveX reference but **never
|
||||
executed against a live AutoCAD**, and each is marked so in the source.
|
||||
Most of the work went into refusals, because ezdxf's own guards stop short of
|
||||
where a tool boundary has to: `layouts.delete("")` deletes the *first* sheet
|
||||
rather than refusing, `layouts.get("")` resolves to whichever is active, and
|
||||
`layouts.rename()` skips the name validation `new()` performs, so `Bad/Name`
|
||||
and even `""` land in the table and the file still audits clean. Deleting or
|
||||
renaming the current tab used to leave three different answers to "where does
|
||||
my next line go?" — `layout_list` said one thing, `$TILEMODE` another, and
|
||||
`_msp()` quietly wrote to model space; all three now agree. A handle from a
|
||||
deleted sheet refuses instead of dying with `AttributeError` (`entitydb.get`
|
||||
is documented not to filter destroyed entities, and `_get_entity` believed
|
||||
it).
|
||||
`layout_copy` is a construction — ezdxf has no layout-copy API and
|
||||
`xref.load_paperspace` is cross-document only — and every way it can go wrong
|
||||
is invisible to the Auditor, which reports zero errors on a duplicated tab
|
||||
order, two main viewports, and a hatch whose boundary still points into the
|
||||
layout it was copied from and dangles the moment that layout is deleted. All
|
||||
three are prevented structurally; `skipped` and `associativity_dropped` report
|
||||
what could not be carried over rather than letting a bare `ok: true` imply a
|
||||
complete copy.
|
||||
`viewport_list` reports the layout's own main viewport with `is_main: true`
|
||||
rather than hiding it — it is what remains after every drafting viewport is
|
||||
deleted — and refuses to report a scale on R12, where `view_height` and
|
||||
`flags` are dropped on export and `get_scale()` then returns a fabricated
|
||||
number (150.0 measured for a viewport storing no scale at all). The two
|
||||
writers refuse on R12 outright rather than reporting a success the file
|
||||
cannot keep, and `viewport_lock` read-modify-writes the flags bitfield, which
|
||||
also carries the UCS-icon and grid bits a bare assignment would silently drop.
|
||||
- **`entity_change_space` (CHSPACE), headless only.** AutoCAD moves objects
|
||||
*through* a viewport, rescaling them so they look identical on screen.
|
||||
ezdxf's `move_to_layout` applies no geometric change whatsoever, so the
|
||||
obvious implementation reports success, turns a 100 mm feature into 100 mm of
|
||||
paper inside a 1:2 viewport, and writes a perfectly clean file. The transform
|
||||
is therefore the tool, and it uses `viewport.get_transformation_matrix()`
|
||||
rather than a hand-rolled one: the hand-rolled version ignores
|
||||
`view_target_point` and misplaced geometry by 25 mm in testing while
|
||||
`is_top_view` was still true and the twist angle still zero — past every guard
|
||||
one would naturally write. A regression test pins the result against what
|
||||
ezdxf's own rendering pipeline projects.
|
||||
It refuses rather than guessing: non-plan and twisted viewports (ezdxf returns
|
||||
a meaningless matrix for the first and rotates about the paper origin for the
|
||||
second), dimensions unless `freeze_dimensions=true` bakes the measurement in
|
||||
first (`transform` halves `get_measurement()` while the baked block text keeps
|
||||
the old value, so one of the two is always lying), ACIS bodies (`transform`
|
||||
is accepted and moves nothing), tables and proxies (`transform` raises, and
|
||||
`hasattr(e, "transform")` is `True` for all of them), viewports themselves,
|
||||
and entities already in the target space. Geometry that lands outside the
|
||||
viewport or off the sheet is moved and **flagged** (`inside_viewport`,
|
||||
`on_sheet`), not refused — AutoCAD allows it too.
|
||||
On the COM backend this refuses with `capability: "chspace"`: ActiveX has no
|
||||
change-space member, so the alternatives are `CopyObjects` + delete or driving
|
||||
the command blind, and neither could be verified against a live seat.
|
||||
- **`CAD_PROGID` — the COM ProgID is configurable.** `"AutoCAD.Application"`
|
||||
was hardcoded at both connection sites, so AutoCAD-compatible applications
|
||||
(BricsCAD, ZWCAD, GstarCAD) were unreachable even though they expose the same
|
||||
ActiveX object model. The setting is honoured on *both* paths deliberately:
|
||||
`Dispatch` is not a passive probe — it COM-launches the application and sets
|
||||
`Visible = True` — so honouring the setting on `GetActiveObject` and then
|
||||
falling back would start the very AutoCAD the operator said they were not
|
||||
using. An unrecognised ProgID fails loudly for the same reason, and the error
|
||||
names both the ProgID it tried and the variable to change. `system_status`
|
||||
now reports `cad_progid`, without which a non-AutoCAD operator reads an
|
||||
`autocad_version` key naming a product they do not own.
|
||||
**Caveat, stated everywhere it appears:** `CAD_PROGID` only changes which COM
|
||||
application the backend attaches to — every tool beyond the connection itself
|
||||
is developed and tested against AutoCAD alone, and clone compatibility is
|
||||
unverified, so treat a non-default ProgID as experimental. The candidate
|
||||
ProgIDs in `.env.example` are commented out and marked UNVERIFIED: none has
|
||||
been tested against a running seat.
|
||||
|
||||
- **`analysis_measure_entity` — measure the drawing, not your memory of it.**
|
||||
Neither backend could answer "what is the area of the polyline I just drew":
|
||||
`analysis_measure_area` shoelaced points the *caller* typed in, and the live
|
||||
COM backend did the same despite having the document, a `HandleToObject`
|
||||
lookup it already used fourteen times, and an ActiveX `Area` property. The
|
||||
only available workflow was also silently wrong — `entity_get` read LWPOLYLINE
|
||||
vertices through the default format and kept x and y, dropping the **bulge**
|
||||
that makes an edge an arc. Measured on a 100×100 square with one edge bulged
|
||||
into a semicircle: 10000 reported against a true 13926.9908, **28.2% low**,
|
||||
perimeter **12.5% low**, nothing in the response saying so. And
|
||||
`props["length"] = ent.length()` was dead code — `LWPolyline` has no
|
||||
`length()`, so the key never appeared at all.
|
||||
Bulge geometry is always circular, so the new `engineering/measure.py` is
|
||||
exact rather than approximate: shoelace plus each arc's signed circular
|
||||
segment, and arc length for perimeter. `entity_get` now reports `area` and
|
||||
`length` from the same maths. The payload states its own accuracy — `exact`,
|
||||
`flatten_tolerance`, `assumed_closed` (an open boundary is closed the way
|
||||
AutoCAD's AREA does), and `self_intersecting`, because the shoelace *cancels*
|
||||
crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse with
|
||||
`capability: "measure_area_acis"` naming the live backend; LINE/TEXT/INSERT
|
||||
raise a plain error, since no engine can give them an area and a capability
|
||||
tag would falsely suggest switching helps. `AREA`/`MEASUREGEOM` now route here
|
||||
rather than at the points-based tool — a drafter typing AREA picks objects,
|
||||
they do not retype coordinates.
|
||||
- **Handle grounding: `view_screenshot(overlay_handles=true)`.** A screenshot
|
||||
showed the model what the drawing looks like and nothing it could act on —
|
||||
every modify tool takes a handle, and nothing connected "the circle at the
|
||||
top-left" to a hex string. Each entity is now labelled with its handle at its
|
||||
own bounding-box centre (taken from the renderer's extents, so the label lands
|
||||
where the entity is *drawn* even in a rotated frame). Off by default, because
|
||||
labels are ink on the drawing; past 40 entities the render is capped and the
|
||||
image itself says "handles shown for N of M" rather than letting a labelled
|
||||
subset read as the whole sheet. The live backend captures the AutoCAD window
|
||||
rather than rendering, so there is nothing to label there — it refuses with
|
||||
`capability: "handle_overlay"` instead of quietly returning an unlabelled
|
||||
image, since producing the overlay would mean creating and deleting entities
|
||||
in the user's open drawing.
|
||||
- **`block_create_from_entities` works on the live backend.** It refused
|
||||
outright and pointed at `system_run_command` with `_BLOCK`, pushing callers
|
||||
through the free-text escape hatch for something ActiveX does directly
|
||||
(`Blocks.Add` + `Document.CopyObjects`). The tool's own docstring said the
|
||||
opposite — "works by using AutoCAD's BLOCK command (COM backend only)" — so
|
||||
the one engine that could not do it was advertised as the one that could.
|
||||
Both engines now also report `skipped` handles instead of swallowing them, and
|
||||
a call where nothing resolves fails rather than leaving an empty definition
|
||||
behind. COM path unit-tested against a fake ActiveX object, not
|
||||
hardware-verified.
|
||||
- **Undo and redo on the headless backend (`EZDXF_UNDO_DEPTH`, default 0).**
|
||||
The plan called this "add redo". The larger half was that **undo never worked
|
||||
either**: `_undo_stack` was declared, read and cleaned up, but nothing ever
|
||||
appended to it, so `drawing_undo` could only answer "Nothing to undo" — while
|
||||
the tool summary said "Step back one operation" and the alias corpus routed
|
||||
`UNDO` and "take that back" straight at it. The old test asserted
|
||||
`isinstance(result, dict)`, which a permanent failure satisfies.
|
||||
It is opt-in because it is not free. ezdxf keeps no journal, so a history step
|
||||
is a whole DXF snapshot: measured 3.1 ms / 19 KB at 10 entities but 130 ms /
|
||||
658 KB at 5000, and switching it on costs **37×** on entity creation (0.18 →
|
||||
6.65 ms per call) even on a small drawing. Off, it costs nothing; on, you get
|
||||
real multi-step undo/redo. Both tools refuse with
|
||||
`capability: "undo_history"` naming the setting rather than pretending. The
|
||||
live COM backend uses AutoCAD's own undo and ignores it. Drawing after an undo
|
||||
discards the redo branch, as in AutoCAD — otherwise redo would restore a state
|
||||
that never existed, with geometry you had removed reappearing beside geometry
|
||||
you drew afterwards.
|
||||
- **BREAKING (behavior): geometry now goes into the layout you selected.**
|
||||
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current":
|
||||
"A3-Sheet"}` and then every entity created afterwards landed in **model
|
||||
space** anyway, on both engines — `_msp()` returned model space
|
||||
unconditionally, so the tool reported a state change that changed nothing.
|
||||
Proven: a line drawn straight after selecting a layout was in modelspace and
|
||||
not in the layout. That was also why a title block could not go on a sheet, so
|
||||
`titleblock_apply_iso_a3` gains a `layout` parameter — where a title block
|
||||
belongs, since the border frames the printed sheet rather than the model — and
|
||||
restores your current space afterwards rather than leaving you on the sheet.
|
||||
Existing model-space workflows are unaffected: a fresh drawing starts in model
|
||||
space, and a refused layout switch leaves the current space where it was.
|
||||
- **ISO 286 transition and interference holes (K, M, N, P).** `fit="P7"` used to
|
||||
raise by name. They are now derived by the standard's delta rule,
|
||||
`ES = -ei + Δ` with `Δ = IT(n) − IT(n−1)` — a *rule* over the shaft and IT
|
||||
tables already in the module, not new table data, which is why it can be
|
||||
pinned against published values (K7/M7/N7/P7 and K6/N6/P6 cross-checked by
|
||||
hand) instead of merely trusted. Unlocks the hole-basis pairs K7/h6, M7/h6,
|
||||
N7/h6, P7/h6.
|
||||
- **ISO 286 interference shafts (r, s, t, u).** `fit="s6"` and `fit="u6"` — the
|
||||
standard press and drive fits — used to raise. Unlike K/M/N/P these *are* new
|
||||
table data, sub-stepped more finely than the main size steps (ISO 286 splits
|
||||
18–30 into 18–24/24–30, 30–50 into 30–40/40–50, and every step above 50 mm in
|
||||
two or three; flattening that is a silent error of tens of microns at exactly
|
||||
the sizes press fits are used at). Because it is data rather than a rule, its
|
||||
**provenance is tested**: every value is cross-checked against ISO 286-1's own
|
||||
derivation formulae, an independent source. `r` reproduces as the geometric
|
||||
mean of `p` and `s` on **24/24** steps — with `p` pinned in this module before
|
||||
`r` existed — and `s`/`t`/`u` track their IT7-based formulae within 1 µm on
|
||||
every step from 18 mm up. The five places where the standard's rounding puts a
|
||||
published value 2–3 µm off its own formula are listed by name in the tests, so
|
||||
a reader holding ISO 286 checks five numbers rather than ninety-five. Shaft `t`
|
||||
is undefined below 24 mm in the standard and refuses there rather than being
|
||||
extrapolated. Unlocks H7/r6, H7/s6, H7/t6 and H7/u6.
|
||||
- **Headless performance lane** (`benchmarks/perf_suite.py` +
|
||||
`render_perf_chart.py`): fixed wall-time workloads (2k individual creates,
|
||||
10k build/export/reopen roundtrip, 10k region query, full premium quality
|
||||
@@ -17,18 +271,487 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Task-matrix chart** (`benchmarks/render_matrix_chart.py`): tasks × servers
|
||||
status heatmap rendered from the published live-run reports, so coverage
|
||||
gaps are visible per capability instead of one folded score.
|
||||
- **Tool discovery** (`DISCOVERY_MODE=search`, default `off`): replaces the
|
||||
advertised catalog with `search_tools` + `call_tool`, so a client sees two
|
||||
tools instead of 127. Ranking layers an AutoCAD command / synonym corpus
|
||||
(`discovery/aliases.py`, 132 tools · 148 command tokens · 583 synonyms) over
|
||||
fastmcp's BM25 index, and hits are returned as one compact line per tool
|
||||
rather than full JSON schemas. Measured on a 66-query golden set with a
|
||||
15-query holdout: **top-1 62/66 vs 23/66** for stock BM25, top-3 64/66 vs
|
||||
33/66 (holdout top-3 14/15 vs 9/15); stock returns *nothing at all* for 21 of
|
||||
the 66, and this transform for none of them — that gap is the corpus, not the
|
||||
ranking, and no BM25 parameter closes it. (Both sides moved slightly during
|
||||
the release as tool docstrings were rewritten: better prose feeds *both*
|
||||
indexes, which is why the headline is the top-1 gap rather than a ratio
|
||||
between two scores over a shared corpus.) A `risk` ceiling
|
||||
keeps destructive tools out of read-only answers and says when it hid one.
|
||||
Off by default — flipping it changes what every connected client sees on
|
||||
`list_tools`, which needs its own migration note.
|
||||
- **`@cad_tool` — a discovery-metadata channel next to the tool contract.**
|
||||
A decorator written *above* an `mcp.tool` registration attaches a `cad` key
|
||||
to that tool's MCP `meta`: a one-line `summary` for a search hit's preview,
|
||||
a `cost` from a closed set (`read`, `safe`, `mutate`, `destructive`,
|
||||
`escape`) for clients that want to gate or colour-code the surface before
|
||||
calling, and the tool's AutoCAD command names and synonyms — imported from
|
||||
`discovery/aliases.py`, never restated, so the corpus stays the one edit
|
||||
site. `tags=` and `annotations=` deliberately stay on the `mcp.tool` call, so
|
||||
the channel cannot perturb `_tool_groups()` or the registration-counting
|
||||
release gate. Misuse fails at import rather than silently no-opping: an
|
||||
unknown cost raises, and a decorator written below the registration (or on a
|
||||
bare function) raises with the ordering rule in the message. Applied to the
|
||||
three `transaction_*` tools so far; every other tool still falls back to the
|
||||
first line of its docstring in search results, which is why the rollout can
|
||||
be section-by-section instead of a 131-tool patch.
|
||||
- **`EZDXF_CALL_TIMEOUT`** (default `120`s, `0` disables): per-call timeout for
|
||||
the headless backend, mirroring `COM_CALL_TIMEOUT`. Every ezdxf method runs
|
||||
under one document lock, so a single hung call previously deadlocked every
|
||||
later tool call.
|
||||
- **`uv.lock` — the dependency graph CI actually installs.** 95 packages,
|
||||
resolved universally over `requires-python >=3.11`, so one file serves the
|
||||
Linux 3.11/3.12 matrix and the Windows leg (platform-only packages like
|
||||
pywin32 carry markers). Every lint and test lane now installs with
|
||||
`uv sync --locked`, which *fails* rather than re-resolving when the lock and
|
||||
`pyproject.toml` disagree — a fresh CI run can no longer pick up an upstream
|
||||
release nobody chose, and the locked set is the set the suite is green on.
|
||||
The `package` job stays unlocked on purpose: it is the only job that proves
|
||||
the published dependency *ranges* still resolve for a plain
|
||||
`pip install autocad-mcp-pro`. The `uv.lock` line in `.gitignore` (added when
|
||||
the committed lockfile was stale) is gone with it — a lockfile git ignores is
|
||||
not a lockfile.
|
||||
- **Scheduled floating-dependency job** (`.github/workflows/deps-floating.yml`):
|
||||
weekly and on demand, it ignores the lock, resolves the declared ranges fresh
|
||||
on 3.11/3.12 and re-runs the suite. Locked lanes cannot be broken by an
|
||||
upstream release, which also means they cannot *notice* one; this is where a
|
||||
range ceiling that needs moving shows up. It never gates a pull request.
|
||||
- **Benchmark matrix v3 — five tasks that can fail.** The v2 matrix scored this
|
||||
server 10/10, which was not evidence of anything: every task in it exercised
|
||||
something the server was built around, so full marks were the only available
|
||||
outcome. `benchmarks/tasks_v3.py` keeps the v2 ten byte-for-byte (their ids
|
||||
and weights key the published v1.4 reports) and adds `tool_discovery`,
|
||||
`token_budget`, `hatch_islands`, `selection_filter` and
|
||||
`measure_from_handle`. Each verifies against a value derived independently of
|
||||
the code under test — a closed-form area, entities placed on purpose, a token
|
||||
ceiling fixed before the measurement. **Three of the five failed while being
|
||||
written**, and the defects they found are in *Fixed* below.
|
||||
Two are renamed from the release plan, and the renames are the point rather
|
||||
than bookkeeping. `tool_discovery_bilingual` lost its "bilingual": the
|
||||
Turkish query normalizer was written and then removed, because this
|
||||
repository is English throughout and the Turkish was inferred from the
|
||||
author's chat language, not required by the product — a task named
|
||||
"bilingual" would benchmark a capability that does not exist.
|
||||
`measurement_massprops` became `measure_from_handle`: no centroid or
|
||||
moment-of-inertia tool ships, since F2 was cut to `entity_measure`.
|
||||
The competitor reports are **not** re-scored. Those servers were pinned and
|
||||
run against v2 at v1.4 and have never been asked these five questions; the
|
||||
matrix chart renders the five rows blank with a `not run` legend entry rather
|
||||
than zero, because an invented zero is indistinguishable from a measured one.
|
||||
`run_competitors` grows `--matrix v2|v3` (v2 stays addressable so a v1.4
|
||||
report can be reproduced rather than only described), records which matrix
|
||||
ran in the report, and grows `--publish`, which writes the artifact-path-free
|
||||
file that `results/published/` holds — that sanitisation used to be a manual
|
||||
edit.
|
||||
- A capability refusal in the benchmark runner is now recorded `unsupported`
|
||||
instead of `fail`. The result enum has carried `unsupported` since v2 for
|
||||
exactly this, but a refusal is still an exception and was being caught by the
|
||||
generic handler — "your engine cannot reach this" and "your server got this
|
||||
wrong" are different findings about a competitor.
|
||||
- Three measurement checks added to `correctness_suite.py` (24, was 21). They
|
||||
are new capability rather than repaired regressions — v1.4.0 misses them by
|
||||
not having the method — and they are there because all three broke during
|
||||
this release and nothing outside their own unit tests would have noticed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The backend contract is 19 modules instead of one 1854-line file, and
|
||||
optional methods are declarative.** `backends/base.py` held a single ABC with
|
||||
112 abstract methods; adding one made *both* backends fail at instantiation
|
||||
until both were written, and two test modules construct `ComBackend` at
|
||||
import time, so a half-finished contract edit took the entire suite red. The
|
||||
interface now lives in `backends/contracts/`, one module per domain, split
|
||||
along the section markers that were already in the file — the seams were not
|
||||
re-decided. `AutoCADBackend` is the composition of all 19 and stays the
|
||||
import surface, so `from backends.base import AutoCADBackend` and every
|
||||
`isinstance` check are unchanged, and the abstract-method count is identical
|
||||
before and after.
|
||||
The split surfaced something the file's name hid: roughly 740 of those lines
|
||||
are shared *concrete* implementation (premium meta-tools, GD&T, the settings
|
||||
facade), not interface. Those slices are mixins and are labelled as such
|
||||
rather than presented as pure contracts.
|
||||
New `backends/capability.py` carries `@capability(key, reason=...)`, which
|
||||
marks a contract method optional and supplies a default. The default
|
||||
**raises** `UnsupportedCapabilityError` rather than returning a quiet
|
||||
`{"ok": False}` — a method nobody implemented has to be indistinguishable
|
||||
from a deliberate refusal, not from a soft failure, which is the distinction
|
||||
this release spent a milestone restoring. A key used by the decorator must be
|
||||
declared in *both* capability maps or `tests/test_capability_contract.py`
|
||||
fails; without that gate the decorator would just be a way to ship refusals
|
||||
`system_capabilities` never mentions, which is worse than the build break it
|
||||
replaces. Its first user is COM's `entity_change_space`, which now inherits
|
||||
the refusal instead of hand-rolling it. The refusal vocabulary moved into
|
||||
this module as well (the contracts need it and `base` imports the contracts);
|
||||
`backends.base` re-exports it, so there is still exactly one exception class
|
||||
object in play.
|
||||
- **BREAKING (env): the `core` tool profile was removed.** `TOOL_PROFILE` is
|
||||
now `lean` or `full`; the discovery layer above solves the crowded-surface
|
||||
problem `core` existed for. `TOOL_PROFILE=core` still boots — it falls back
|
||||
to `full` with a warning naming the removal, and `system_about` reports the
|
||||
requested profile alongside the applied one.
|
||||
|
||||
- **BREAKING (install): `ezdxf>=1.4` is now the floor** (was `>=1.3`). The 1.4
|
||||
line is the only one this release is developed and tested against; 1.3 is no
|
||||
longer exercised by CI and is unsupported. Upgrade with
|
||||
`pip install -U "ezdxf>=1.4"`.
|
||||
- **Runtime pins narrowed.** A published library cannot pin exactly and stay
|
||||
co-installable, so what a user's `pip install` gets is still a range — and a
|
||||
range is the only guard that install has. They are therefore deliberately
|
||||
narrow. (`uv.lock`, above, is the exact guard on *our* side; the two are not
|
||||
substitutes for each other.)
|
||||
- `fastmcp>=3.0` → `fastmcp>=3.4.5,<3.5`. `fastmcp` is now a thin
|
||||
meta-package (`Requires-Dist: fastmcp-slim[client,server]`) and 3.x moves
|
||||
fast enough that a new minor can reshape the API the server is built on.
|
||||
Tested against 3.4.5; the ceiling moves only after the suite is re-run.
|
||||
- `ezdxf>=1.3` → `ezdxf>=1.4,<2` (see the BREAKING note above).
|
||||
- `pydantic>=2.0` → `pydantic>=2.0,<3`, so a future pydantic 3 cannot be
|
||||
pulled into an install of this release.
|
||||
- **Rendering requires an extra — documented, not changed.**
|
||||
`ezdxf.addons.drawing.__init__` imports `.frontend`, which does an
|
||||
unconditional top-level `import PIL.Image`. Pillow is therefore a hard
|
||||
requirement of *every* ezdxf render path (PNG screenshot and PDF export
|
||||
alike), not just of the COM window-capture path it is listed under. A bare
|
||||
`pip install autocad-mcp-pro` resolves 77 packages with neither Pillow nor
|
||||
matplotlib, so it cannot render at all; `[pdf]` (or `[full]`) is required,
|
||||
and satisfies Pillow transitively via matplotlib. Note that `[com]` alone
|
||||
installs Pillow but not matplotlib, so the ezdxf backend still cannot render
|
||||
under it. Pillow deliberately stays out of the core dependencies for now.
|
||||
- **CI lint pinned to `ruff==0.16.1`** (was an unpinned `pip install ruff` /
|
||||
`ruff>=0.8`). The version is now written in exactly one place —
|
||||
`[dependency-groups] dev` — and frozen there by `uv.lock`; the workflows
|
||||
install ruff *from* the lock instead of restating the pin, so the three
|
||||
copies that had to be bumped together no longer exist.
|
||||
- **Dev toolchain moved to a PEP 735 dependency group.** `[tool.uv]
|
||||
dev-dependencies`, which uv now warns is deprecated, became
|
||||
`[dependency-groups] dev` — same four tools, byte-identical resolution. The
|
||||
standard spelling is also understood by pip >=25.1, so `pip install --group
|
||||
dev` gives contributors who do not use uv the same list without a second
|
||||
copy of it.
|
||||
- **CI and the release gate install from the lock.** `lint` (dev group only —
|
||||
ruff without building the project), `test-linux`, `test-windows` and the
|
||||
`release.yml` test job now run `uv sync --locked` + `uv run --no-sync`, with
|
||||
uv itself pinned in the workflow env for the same reason ruff is. `package`,
|
||||
`docker` and `registry-validate` are unchanged. `benchmark.yml` is locked
|
||||
too: that lane publishes evidence, and an ezdxf release moving our own
|
||||
numbers between runs would make the diff unattributable.
|
||||
- README rebuilt: PyPI-first install, a four-lane benchmark section with
|
||||
Python-generated visuals (scores, task matrix, performance, source review),
|
||||
v1.4 feature table, updated client/config/roadmap sections.
|
||||
- `render_live_chart` now skips non-task-matrix reports so multiple lanes can
|
||||
share `benchmarks/results/published/`.
|
||||
|
||||
_Next (1.5): screenshot overlay + handle grounding v1, ezdxf redo (forward
|
||||
snapshots), COM `block_create_from_entities`, ISO 286 transition/interference
|
||||
hole letters (delta rule), titleblock on paper-space layouts._
|
||||
### Fixed
|
||||
|
||||
- **The COM paths were run against a live AutoCAD 2026, and four of them were
|
||||
broken.** Every COM implementation added in this release was written against
|
||||
the ActiveX reference and shipped labelled unverified. Executing them against
|
||||
a real seat — a fresh instance, a throwaway drawing, nothing saved — found
|
||||
four defects that no amount of reading would have caught, one of them
|
||||
pre-existing:
|
||||
* **`AcadPViewport` has no `ViewCenter` member.** `viewport_list` died on it
|
||||
outright. Worse, `viewport_create` has carried the same line since v1.4
|
||||
wrapped in a bare `except: pass`, so it raised on every single call and
|
||||
said nothing — every COM viewport has been looking at the model origin
|
||||
instead of the `view_center_x/y` the caller asked for. The member is
|
||||
`Target`, and the failure is no longer swallowed.
|
||||
* **`ModelSpace.AddWipeout` does not exist.** ActiveX has no wipeout
|
||||
constructor at all; WIPEOUT is a command. Now a typed refusal
|
||||
(`capability: "wipeout"`) instead of an `AttributeError`.
|
||||
* **`AcDbMText.BackgroundFillColor` does not exist** — it raises on read as
|
||||
well as on write, with an int and with an `AcCmColor` object alike.
|
||||
`BackgroundFill` (the on/off flag) does work, so `text_set_background`
|
||||
enables and disables the mask on COM and refuses a *colour* request with
|
||||
`capability: "mtext_background_color"`, rather than switching the fill on
|
||||
in the wrong colour and reporting success.
|
||||
* `analysis_list_properties` called a helper that does not exist on the class.
|
||||
After the fixes, all 25 verification calls behave correctly: 18 succeed, 1
|
||||
refuses per-argument (deleting model space), and 6 refuse with the capability
|
||||
keys they declare. The remaining COM refusals
|
||||
(`chspace`, `revcloud`, `boundary_trace`, `hatch_edge_paths`) were confirmed
|
||||
to hold rather than leak a result. The source comments now say VERIFIED with
|
||||
the date instead of UNVERIFIED.
|
||||
- **The headless renderer *does* project model content through viewports.
|
||||
v1.4 said it did not.** `viewport_render` was declared unsupported on ezdxf,
|
||||
every `drawing_export_pdf(layout=...)` response carried the note "projecting
|
||||
model content through viewports is COM-only", and the README table agreed —
|
||||
so a caller with no AutoCAD was told to go get one for something that already
|
||||
worked. Measured on ezdxf 1.4.4: a sheet holding *nothing but a viewport*
|
||||
renders the model geometry that viewport looks at, two viewports at 1:1 and
|
||||
1:2 render the same circle at two sizes in the right places, and geometry
|
||||
outside a viewport's window is correctly left out. Understating a capability
|
||||
is the same class of untruth as overstating one, and it is the more expensive
|
||||
one for the user. What is genuinely missing is the viewport *border*, which
|
||||
the headless renderer does not draw — the capability now says exactly that
|
||||
(`reason: viewport_borders_not_drawn`), and the old note's claim that
|
||||
"viewport frames are rendered" was wrong in the other direction. Two tests
|
||||
that pinned the false claim were corrected rather than deleted, and the
|
||||
replacement proves the behaviour by subtraction: the sheet renders less ink
|
||||
once the model geometry is gone.
|
||||
- **Layout names are matched case-insensitively, the way the engine underneath
|
||||
already matched them.** ezdxf resolves layout names case-insensitively in
|
||||
`get`, `new`, `rename`, `delete` and `set_active_layout` — but `names()`
|
||||
returns the *stored* spelling, so every guard written as
|
||||
`name in doc.layouts.names()` disagreed with the code it was guarding, in both
|
||||
directions. `layout_create("SHEET")` next to an existing `Sheet` walked past
|
||||
its own duplicate check and died on a raw `DXFValueError` from inside ezdxf;
|
||||
`layout_set_current("SHEET")` reported "Layout not found" for a tab that was
|
||||
right there. Both shipped in v1.4. One resolver now backs
|
||||
create/set_current/delete/rename/copy and `viewport_list` on both engines, and
|
||||
the current space is stored in the canonical spelling. Re-spelling a layout's
|
||||
own name (`Sheet` → `SHEET`) is a rename onto itself rather than a collision;
|
||||
ezdxf's `rename` refuses that outright, so it goes through a staging name.
|
||||
- **`entity_change_space` refuses an entity that lives on another sheet instead
|
||||
of half-applying.** `move_to_layout` raises `DXFValueError` from deep inside
|
||||
ezdxf when the entity is not in the source layout — and it raises *after* the
|
||||
transform has already been applied, so the failed call left the line rescaled
|
||||
and translated by a viewport it never moved through, still sitting on the
|
||||
wrong sheet. Membership is now checked before anything is written, and the
|
||||
refusal names the space the entity is actually in. One bad handle in a batch
|
||||
no longer aborts the good ones.
|
||||
- **BREAKING (behavior): coordinates crossing the boundary are now WCS. They were
|
||||
not.** DXF stores CIRCLE, ARC, LWPOLYLINE, TEXT, INSERT and a dimension's text
|
||||
position in a per-entity frame derived from the entity's `extrusion` vector —
|
||||
and the string "extrusion" appeared *nowhere* in this repository, so every such
|
||||
read returned a raw attribute rather than a world coordinate. Mirroring flips
|
||||
extrusion to `(0,0,-1)` and leaves the stored x unnegated, so after the
|
||||
server's own `entity_mirror` across the Y axis a circle drawn at (30, 20)
|
||||
reported `center [30.0, 20.0]` while the **same response's** `bounding_box`
|
||||
said `min [-35, 15] / max [-25, 25]`: the payload contradicted itself by 60 mm,
|
||||
silently, with no error anywhere. `point_from_snap` returned points on the
|
||||
wrong side of the axis, `entity_select_smart` found the entity where it wasn't,
|
||||
and `entity_offset` placed the new circle 60 mm away *and* grew it when asked
|
||||
to shrink. Reads are now normalised at the single `_entity_info_dxf` funnel, so
|
||||
all 38 call sites and every consumer of `EntityInfo.properties` are fixed with
|
||||
them. The write paths (`entity_edit_geometry`, `entity_offset`) got the
|
||||
matching inverse — mandatory, not cosmetic: read and write were *equally*
|
||||
wrong, which made a read-then-write round trip an accidental no-op, and fixing
|
||||
reads alone would have turned that no-op into a real 60 mm move.
|
||||
- **Not touched, deliberately:** LINE, MTEXT, ELLIPSE, SPLINE and POINT carry
|
||||
an `extrusion` attribute too and were always correct (ezdxf makes their
|
||||
`ocs()` a pass-through); `entity_rotate` was never affected — it preserves
|
||||
extrusion `(0,0,1)`. The trigger was `entity_mirror` and foreign DXF files.
|
||||
A dimension's `defpoint` is DXF group code 10 and WCS already; only
|
||||
`text_midpoint` (code 11) is in the entity frame.
|
||||
- **Tilted planes.** An entity whose plane is not parallel to WCS XY cannot be
|
||||
described by an xy pair — a tilted circle of radius 5.0 has a 5.0 × 3.99
|
||||
footprint. `entity_get` reports the WCS centre plus `plane_normal` and
|
||||
**omits** `radius` / `start_angle` / `end_angle` rather than reporting a
|
||||
number that is not true in the frame the rest of the dict uses, and
|
||||
`entity_edit_geometry` refuses with `capability: "ocs_tilted_plane"`,
|
||||
pointing at `entity_move` (which transforms the whole entity and preserves
|
||||
its plane). New capability keys `ocs_normalized` and `ocs_tilted_plane` on
|
||||
both backends make the boundary pre-checkable via `system_capabilities`.
|
||||
- **Named residuals** (in the `ocs_normalized` reason rather than left silent):
|
||||
the OCS z component is not reported, HATCH boundaries are not reported at all
|
||||
(silence, not a wrong number), and TEXT `rotation` stays in the entity frame
|
||||
because a mirrored TEXT is mirror-imaged and no single scalar angle expresses
|
||||
that — measured 29.999 reported against a true WCS 150.0.
|
||||
- **COM:** ActiveX returns WCS natively for every point property *except*
|
||||
`AcDbLWPolyline.Coordinates`, which is OCS; that one read is now translated.
|
||||
Verified only against a fake COM object — this machine has a live AutoCAD and
|
||||
connecting to it during development is forbidden, so the path ships
|
||||
unit-tested, not hardware-verified. `AcDb2dPolyline` is deliberately out of
|
||||
scope: its `Coordinates` may be 3D triples, and changing the stride on a
|
||||
documentation reading alone would risk a path that works today.
|
||||
- **`validate_path` handed out working handles to `C:\Windows`.** Windows names
|
||||
the same file through several anchors that compare unequal. The backslash
|
||||
spellings were rejected, but `pathlib` normalises the *forward-slash* forms
|
||||
into them only after `.resolve()` — so `//?/C:/Windows/win.ini` slipped past
|
||||
the pattern check and then failed to match `C:/Windows` in the
|
||||
system-directory loop. Proven: it returned a handle that `os.path.samefile`'d
|
||||
the real file. Non-local anchors are now rejected in every spelling. This
|
||||
removes no capability anyone had: the equivalent backslash paths were already
|
||||
refused, so it only makes one policy consistent across spellings.
|
||||
- **The AutoLISP guard read drawing text as code.** The symbol denylist scanned
|
||||
inside double-quoted literals, so an engineering server refused its own
|
||||
vocabulary: `(setq desc "Max load 500 kg")` matched the `load` pattern and
|
||||
`(setq layer "C:CENTER")` matched the custom-command pattern. Literals are now
|
||||
blanked before scanning. Every known-dangerous vector still blocks, including
|
||||
`(load "malicious.lsp")` and `(vl-catch-all-apply (quote command) ...)`, whose
|
||||
dangerous token is outside the quotes.
|
||||
- **The command denylist matched characters, not commands.** A word-boundary
|
||||
regex over the whole string refused `MTEXT 0,0 10,10 Do not erase this part`
|
||||
— annotation text — while missing spellings AutoCAD accepts. SendCommand takes
|
||||
a *macro*: segments split on newline or `;`, and only a segment's first token
|
||||
is a verb. Matching is now verb-scoped with decorators (`_`, `.`, `-`, `'`)
|
||||
stripped, the set gained the load/run channels it was missing (`ARX`,
|
||||
`VBASTMT`, `VBAIDE`, `CUILOAD`, `SCR`, `SAVE`, `EXPORT`, `ETRANSMIT`, …), and
|
||||
the refusal now names the offending verb — previously a user whose annotation
|
||||
was refused had no way to tell why. Single-letter aliases stay out on purpose:
|
||||
`E` is the ERASE alias but also ZOOM's Extents option, so blocking it would
|
||||
refuse `_ZOOM\nE\n`, the canonical zoom-extents macro.
|
||||
- **README no longer claims the denylist is a security boundary.** It said
|
||||
"blocked by default, with regression tests for known bypass patterns"; the
|
||||
bypasses above had no tests, and no denylist over a command language that
|
||||
loadable modules extend can be complete. It now says what the guard is — a
|
||||
guardrail against an agent accidentally issuing `ERASE ALL` — and names the
|
||||
actual boundaries: `ALLOWED_PATHS`, not exposing the server over the network,
|
||||
and the typed tools. The same sentence is in the two tool docstrings, so it
|
||||
reaches the model that calls them. *Not* changed: blocking `INSERT`/`SAVEAS`
|
||||
as command strings while `block_insert`/`drawing_save_as` exist was flagged as
|
||||
incoherent and is not — the policy is scoped to the free-text channel, and the
|
||||
typed tools take validated arguments where the raw commands take file paths.
|
||||
The reasoning is recorded in `security.py` so it is not re-opened.
|
||||
- **`config.settings.backend` was a control that controlled nothing.**
|
||||
`_make_backend` read `os.environ["AUTOCAD_MCP_BACKEND"]` directly, so the
|
||||
setting had no production reader at all and
|
||||
`monkeypatch.setattr(config.settings, "backend", "ezdxf")` — which two tests
|
||||
used — read like a safety guard while doing nothing. On a developer machine
|
||||
with AutoCAD open that meant `auto`, i.e. attaching to the operator's live
|
||||
unsaved drawing and creating entities in it. `Settings.backend` is now a
|
||||
read-only property that reads the environment live, `_make_backend` goes
|
||||
through it, and the test suite pins the headless backend for every test via an
|
||||
autouse fixture (opt out with `@pytest.mark.live_backend`). Assigning to
|
||||
`config.settings.backend` now raises `AttributeError` instead of silently
|
||||
protecting nothing. `monkeypatch.setenv` — what the correct call sites already
|
||||
used — is unaffected.
|
||||
- **A capability refusal now survives the trip to the client.** The backends
|
||||
always knew exactly why they could not do a thing — they raise
|
||||
`UnsupportedCapabilityError(capability, message)`, where `capability` is a key
|
||||
of the capability map, so `system_capabilities` answers the same question
|
||||
up-front. None of that reached anyone: `FastMCP.call_tool` rewraps a
|
||||
non-`FastMCPError` as `ToolError(f"Error calling tool {name!r}: {e}") from e`,
|
||||
and while the `from e` keeps the cause alive in-process (which is why
|
||||
`cad_batch` could classify refusals), `__cause__` does not serialise. A remote
|
||||
client got an English sentence, and telling "this backend cannot" from "your
|
||||
arguments were wrong" meant substring-matching prose. A refusal now returns
|
||||
`is_error: true` with
|
||||
`{"ok": false, "kind": "unsupported", "capability", "error", "tool",
|
||||
"backend"}` in `structured_content`, produced by one new
|
||||
`CapabilityRefusalMiddleware` — no tool signature changed. *Not* done by
|
||||
rebasing the exception on `FastMCPError`: that was measured and changes
|
||||
nothing a client can observe, since neither the payload nor the error flag
|
||||
travels with the exception class. Non-capability errors are byte-for-byte
|
||||
unchanged. `UnsupportedCapabilityError` moved to `backends/base.py` (still
|
||||
importable from `backends.ezdxf_backend`, and the *same* class object) so both
|
||||
backends raise one type, and the five headless `solid_*` methods now raise it
|
||||
instead of returning `{"ok": false}` — same payload, but `is_error` stops
|
||||
being quietly `false` on a call that did nothing.
|
||||
- **BREAKING (behavior): `cad_batch` counted a step that declined *by value* as
|
||||
a success.** A tool that returns `{"ok": false, ...}` rather than raising was
|
||||
recorded `status: "ok"` and folded into `succeeded`, so a batch could report
|
||||
`succeeded: 12` over work that never happened — the same class of lie as the
|
||||
DWG bytes and the unverified rollback. Such a step is now `status: "error"`
|
||||
with kind `unsupported` (when it names a capability) or `refused`, counts
|
||||
toward `failed`, and honours `on_error` like any other failure. Batches that
|
||||
relied on, say, a no-op `drawing_redo` passing silently will now stop or roll
|
||||
back; that is the point. **Read-only tools are excluded on purpose**:
|
||||
`validation_check` / `drawing_critique` / `drawing_preflight` answer
|
||||
`{"ok": len(issues) == 0}`, so their `ok: false` means *I found something*,
|
||||
not *I could not run* — reading the two the same way would let a read-only
|
||||
check trigger a rollback that destroys the batch's own geometry. The
|
||||
discriminator is the `readOnlyHint` annotation each tool already publishes.
|
||||
- **BREAKING (behavior): `drawing_audit` said "0 errors" about documents it had
|
||||
just silently rewritten — and then threw the repairs away.** `Drawing.audit()`
|
||||
is not a read: it fixes every fixable problem in place, so `auditor.fixes` is
|
||||
a log of mutations already applied. The tool discarded that list entirely,
|
||||
never marked the document dirty — so `drawing_close(save=True)` saw a "clean"
|
||||
document, skipped the save, and the repairs it had just made were lost — and
|
||||
rendered the errors it *did* report through `str()` on an `ErrorEntry` that
|
||||
defines no `__str__`, i.e. as `<ezdxf.audit.ErrorEntry object at 0x...>`.
|
||||
The tool now returns `repaired` / `fixes` / `fix_count` alongside
|
||||
`errors` / `error_count`, both as `{code, name, message, handle}` dicts, and
|
||||
marks the document dirty when it repaired something. **The behavioral break:
|
||||
an audit that repairs now leaves unsaved changes, so a following
|
||||
`drawing_close(save=True)` rewrites the file where it previously did not.**
|
||||
That is the repair finally persisting; the tool was already annotated
|
||||
`readOnlyHint: false` / `cost: mutate`. **Audit can delete entities** — an
|
||||
entity whose owner handle points nowhere is removed, not patched — and with
|
||||
the dirty flag fixed, that deletion now reaches disk on the next save where
|
||||
before it was accidentally discarded. ezdxf reports those particular fixes
|
||||
without the entity attached, so `handle` is `null` for them; the message names
|
||||
the type and handle in prose. Read `fixes` before saving. On the COM backend `_AUDIT Y` likewise
|
||||
repairs, but AutoCAD returns nothing machine-readable over `SendCommand`, so
|
||||
the counts come back as `null` with `detail: "unavailable"` and
|
||||
`capability: "audit_detail"` — never `0`, which would claim nothing was wrong
|
||||
— plus the path of the `.adt` log. That backend also no longer leaves the
|
||||
user's `AUDITCTL` permanently set to 1.
|
||||
- **BREAKING (behavior): the ezdxf backend no longer writes DXF bytes into a
|
||||
`.dwg` name.** `drawing_save("part.dwg")` previously called `doc.saveas()`
|
||||
and produced a file whose contents did not match its extension — AutoCAD
|
||||
cannot open it, and nothing in the response said so. Both save paths now
|
||||
refuse, driven by the extension, so `drawing_save_as("x.dwg", fmt="dxf")` is
|
||||
refused too. Refusals carry a machine-readable
|
||||
`{"ok": false, "capability": "dwg"}`, and `dwg` is now a declared capability
|
||||
in both backends (ezdxf: unsupported; COM: native). Save as `.dxf`, or use
|
||||
the COM backend for DWG.
|
||||
- **`drawing_open` no longer pretends it can read DWG.** ezdxf detects format
|
||||
by content, so a DXF renamed `.dwg` (exactly what the bug above produced)
|
||||
opened "successfully" as a document that does not exist in that format,
|
||||
while a genuine DWG died with a raw `OSError` blaming the file rather than
|
||||
the backend. The tool now refuses `.dwg` unless the active backend declares
|
||||
the capability, and its parameter text no longer advertises DWG
|
||||
unconditionally. Workflows holding files produced by the old `drawing_save`
|
||||
bug must rename them to `.dxf`; the error message says so.
|
||||
- **`TOOL_PROFILE` was a silent no-op whenever a tool transform was attached.**
|
||||
`_registered_tools()` read the post-transform tool list, so with a transform
|
||||
in place it saw 2 tools instead of 131 — the lean profile computed 0
|
||||
disabled tools and `system_about` reported a collapsed inventory. It now
|
||||
reads the underlying registry.
|
||||
- **`ruff format --check .` failed on unmodified `main`.** Ruff 0.16 began
|
||||
formatting Python code blocks inside Markdown, so the committed
|
||||
`docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md`
|
||||
started failing the format gate with no source change. Reformatted the file
|
||||
and pinned the CI ruff version so a future release cannot re-break the gate
|
||||
the same way.
|
||||
- `release.yml` now runs `ruff format --check .` alongside `ruff check .`,
|
||||
matching the CI lint job — the release gate previously skipped formatting.
|
||||
|
||||
- **A HATCH could not be measured, and the refusal listed HATCH as
|
||||
measurable.** `entity_measure` had no HATCH branch, so a hatch fell through
|
||||
to the generic error — whose own text named HATCH among the measurable types.
|
||||
Whichever half was right, the pair could not both be. It now walks the
|
||||
boundary paths and reports the area the hatch actually *fills*: outer loops
|
||||
minus the islands inside them, 300 rather than 400 on a 20×20 square with a
|
||||
10×10 island. Nesting depth is computed by containment rather than read off
|
||||
the boundary flags, because DXF marks a path external or outermost and never
|
||||
"two levels down" — and depth is exactly where AutoCAD's three island styles
|
||||
stop agreeing. `hatch_style` comes back with the number so it can be read
|
||||
against the drawing's own setting. On a curved edge `flatten_tolerance` is
|
||||
not the accuracy knob it looks like: ezdxf hands boundaries over as cubic
|
||||
Beziers, so tightening it converges on the Bezier's area, ~0.028% above the
|
||||
circle it stands for, which is why `exact` goes False and stays False.
|
||||
- **`boundary_trace` and `entity_measure` disagreed about the same shape.**
|
||||
The boundary tools reported an area taken from a copy of the loop flattened
|
||||
at a fixed 0.01 sag while `entity_measure` read the stored bulges
|
||||
analytically: 139.2177 against 139.2699 for one figure, with nothing in
|
||||
either payload marking one as the approximation. The polyline the boundary
|
||||
tools store carries exact bulges, so the exact answer was always available;
|
||||
both now read it and round to the same six decimals. The flattened figure
|
||||
survives only where it is harmless — ranking candidate loops, where a 0.01%
|
||||
error cannot change which is smallest.
|
||||
- **A circle stored as two bulged vertices measured 0.0.** Two semicircular
|
||||
arcs joined into a circle store as a *two*-vertex closed LWPOLYLINE with both
|
||||
bulges 1.0 — what AutoCAD writes for a JOINed pair of arcs, and what
|
||||
`boundary_from_entities` builds from any two-arc loop. `polygon_area_perimeter`
|
||||
treated fewer than three vertices as degenerate and returned zero, when the
|
||||
shoelace over two points is genuinely 0 and the two circular segments are the
|
||||
entire shape. Only zero or one vertex bounds nothing now.
|
||||
- **The v1.4 → v1.5 performance comparison was measured, and it is not
|
||||
flattering.** The published perf report was recorded on CPython 3.14 and the
|
||||
new one on 3.11.15, which alone accounts for nearly all of an apparent 2–6×
|
||||
"speedup". Re-running v1.4.0 in a worktree on the same interpreter with the
|
||||
same suite file gives the real picture: entity creation is **28% slower**
|
||||
(245 → 315 ms for 2,000 lines) and the 10,000-line roundtrip **22% slower**.
|
||||
Fully attributed — `EZDXF_CALL_TIMEOUT=0` returns v1.5.0 to 244.8 ms and
|
||||
1,679 ms, v1.4.0's numbers exactly. The whole cost is the per-call
|
||||
`asyncio.wait_for` this release wrapped around every headless call so that
|
||||
one hung ezdxf call can no longer wedge a server whose document lock is a
|
||||
single `asyncio.Lock`. Deliberate trade, documented knob, and on the 1.6
|
||||
roadmap to win back.
|
||||
|
||||
_The five items carried into this release from 1.4 are all done: screenshot
|
||||
overlay + handle grounding, ezdxf undo/redo, COM `block_create_from_entities`,
|
||||
ISO 286 transition/interference holes, and titleblock on paper-space layouts.
|
||||
ISO 286 shafts r/s/t/u are deliberately **not** done — they need table data
|
||||
nothing here can derive, and the refusal says so rather than guessing._
|
||||
|
||||
## [1.4.0] — 2026-07-23
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~131 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
|
||||
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~154 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
|
||||
|
||||
## Running the Server
|
||||
|
||||
@@ -53,7 +53,9 @@ Uses `pytest-asyncio` for async test support.
|
||||
|
||||
The server uses a strategy pattern with an abstract base:
|
||||
|
||||
- `backends/base.py` — `AutoCADBackend` ABC defining the full interface + shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`)
|
||||
- `backends/contracts/` — the interface, one module per domain (`drawing.py`, `layouts.py`, `entity_creation.py`, …). Split in v1.5.0 out of a single 1854-line ABC; some slices (premium, gdt, settings) also carry shared *concrete* implementations, so they are mixins rather than pure interfaces.
|
||||
- `backends/capability.py` — the refusal vocabulary (`UnsupportedCapabilityError`) plus `@capability(key, reason=...)`, which marks a contract method optional and gives it a default that **raises** that refusal. Use it only for genuine engine boundaries, never for an unfinished method; `@abstractmethod` stays the default. Every key used must be declared in *both* capability maps — `tests/test_capability_contract.py` fails otherwise, which is what stops the decorator becoming a way to ship undeclared holes.
|
||||
- `backends/base.py` — shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`, `CapabilityMap`) and the `AutoCADBackend` composition of every contract. Import surface is unchanged: `from backends.base import AutoCADBackend, UnsupportedCapabilityError` still works.
|
||||
- `backends/ezdxf_backend.py` — `EzdxfBackend`: file-based DXF operations using the `ezdxf` library. All sync ezdxf calls are wrapped with `asyncio.to_thread` via `_async()`. Transactions are implemented as full DXF snapshots on an `_undo_stack`.
|
||||
- `backends/com_backend.py` — `ComBackend`: live AutoCAD control via `pywin32` COM. All COM calls are routed through a `ThreadPoolExecutor` with a single thread to satisfy AutoCAD's STA (Single-Threaded Apartment) COM requirement.
|
||||
|
||||
@@ -84,10 +86,12 @@ Tools are organized into sections (counts are indicative — `system_about` is a
|
||||
12. Engineering / Deterministic CAD (8 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
|
||||
13. Premium meta-tools (12): `drawing_preflight`, `drawing_plan`, `drawing_critique`, `drawing_refine`, `drawing_deliver`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
|
||||
14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus
|
||||
15. Layouts & Paper Space (4 tools): `layout_list/create/set_current`, `viewport_create` (scaled model viewports); `drawing_export_pdf` takes an optional `layout` param. Viewport model-content projection is COM-only (`viewport_render` capability).
|
||||
15. Layouts & Paper Space (12 tools): tab lifecycle `layout_list/create/set_current/delete/rename/copy`, viewports `viewport_create/list/set_scale/lock/delete`, and `entity_change_space` (CHSPACE — tagged `layout` *and* `modify`, and `layout` wins the group priority, so it files here). `drawing_export_pdf` takes an optional `layout` param and **does** project model content through viewports on both engines (`viewport_render` — v1.4 declared this COM-only and was wrong; the headless renderer only omits the viewport borders). `entity_change_space` is the mirror case — ezdxf-only (`chspace`), because ActiveX exposes no change-space member and the COM route is unverified.
|
||||
16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS).
|
||||
|
||||
**Tool profiles:** `TOOL_PROFILE=lean|core|full` (default `full`) controls the advertised surface — `lean` ≈ 46 curated drafting tools, `core` hides raw escape hatches (`system_run_command/lisp`, low-level variables, long-tail tools). Applied in the lifespan; reported by `system_about`.
|
||||
**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` ≈ 47 curated drafting tools (including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
|
||||
|
||||
**Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index.
|
||||
|
||||
**ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text.
|
||||
|
||||
@@ -95,9 +99,13 @@ Tools are organized into sections (counts are indicative — `system_about` is a
|
||||
|
||||
**Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric.
|
||||
|
||||
**Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so.
|
||||
|
||||
**Benchmark matrix:** `benchmarks/tasks_v3.py` is the current set (15 tasks; `--matrix v2` reproduces the v1.4 ten). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (24 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.4.0`.
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
1. Add the abstract method to `AutoCADBackend` in `backends/base.py`
|
||||
1. Add the method to the matching module in `backends/contracts/` — `@abstractmethod` if both engines must have it, `@capability("key", reason=...)` only if it is a real engine boundary (and then declare `key` in both capability maps, or the gate fails)
|
||||
2. Implement it in `EzdxfBackend` (`backends/ezdxf_backend.py`) using the `_async(func)` wrapper
|
||||
3. Implement it in `ComBackend` (`backends/com_backend.py`) using `self._com(func)`
|
||||
4. Register the tool in `server.py` with `@mcp.tool(...)` calling `_backend(ctx).your_method(...)`
|
||||
@@ -107,6 +115,12 @@ Tools are organized into sections (counts are indicative — `system_about` is a
|
||||
|
||||
- **Entity handles**: hex strings (e.g. `"1A2B"`). Always returned from create/copy operations; required by all modify/query operations.
|
||||
- **Coordinates**: drawing units (mm by default); angles in degrees, counter-clockwise from X axis.
|
||||
**Every coordinate in or out of a tool is WCS on both engines.** DXF stores
|
||||
CIRCLE/ARC/LWPOLYLINE/TEXT/INSERT geometry in a per-entity frame (`extrusion`);
|
||||
`backends/ocs.py` translates at the backend boundary, so OCS never reaches a
|
||||
tool. An entity in a plane tilted out of WCS XY reports `plane_normal` and omits
|
||||
the fields xy cannot express; writing an xy into such an entity is refused with
|
||||
`capability: "ocs_tilted_plane"` — use `entity_move` instead.
|
||||
- **ACI colors**: 1–255 for specific colors, 256=ByLayer, 0=ByBlock.
|
||||
- **COM backend only**: `system_run_command`, `system_run_lisp`, and `view_zoom_extents/window` (view ops are no-ops in ezdxf).
|
||||
- **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib.
|
||||
|
||||
@@ -8,6 +8,7 @@ COPY pyproject.toml README.md LICENSE ./
|
||||
COPY server.py config.py security.py version.py ./
|
||||
COPY backends/ ./backends/
|
||||
COPY engineering/ ./engineering/
|
||||
COPY discovery/ ./discovery/
|
||||
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ query exact geometry, apply engineering standards (ISO 128/129/286/1101),
|
||||
refine quality issues inside transactions, and deliver hashed artifacts with
|
||||
validation evidence.
|
||||
|
||||
> **v1.4 release snapshot:** 131 tools · 6 resources · 5 prompt templates ·
|
||||
> 474 collected tests. Runtime discovery through `system_about` is authoritative.
|
||||
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates ·
|
||||
> 1242 collected tests. Runtime discovery through `system_about` is authoritative.
|
||||
|
||||
## Install in 10 seconds
|
||||
|
||||
@@ -68,6 +68,12 @@ locally executed evidence, **B** = deep source review without a full live run,
|
||||
**C** = documented tool surface only. This is a source review, not a shared
|
||||
live run — the lanes below are the runtime evidence.
|
||||
|
||||
**The table above is not re-scored per release.** It reads the dated review,
|
||||
and this repository's row is still the one written against v1.4.0. Raising our
|
||||
own number for v1.5.0 would mean re-reviewing eight other projects on the same
|
||||
day to keep the comparison fair, and that has not been done — so the score
|
||||
stays where the evidence is.
|
||||
|
||||
```bash
|
||||
python -m benchmarks.render_chart
|
||||
```
|
||||
@@ -76,23 +82,32 @@ python -m benchmarks.render_chart
|
||||
|
||||

|
||||
|
||||
| Server | Pinned commit | Score | Pass | Coverage |
|
||||
|---|---|---:|---:|---:|
|
||||
| **autocad-mcp-pro** (this repo) | working tree | **100.0** | 10/10 | 100% |
|
||||
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | `11f7c47` | 50.0 | 5/10 | 50% |
|
||||
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | `95476a3` | 45.0 | 4/10 | 50% |
|
||||
| Server | Matrix | Pinned commit | Score | Pass | Coverage |
|
||||
|---|---|---|---:|---:|---:|
|
||||
| **autocad-mcp-pro** (this repo) | v3 (15 tasks) | working tree | **100.0** | 15/15 | 100% |
|
||||
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | v2 (10 tasks) | `11f7c47` | 50.0 | 5/10 | 50% |
|
||||
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | v2 (10 tasks) | `95476a3` | 45.0 | 4/10 | 50% |
|
||||
|
||||
Ten fixed tasks, one runner, headless ezdxf lane. Competitors are driven
|
||||
**black-box over MCP stdio** using their own documented tool contracts at the
|
||||
pinned commit, and every geometry claim is verified by re-opening the exported
|
||||
DXF with ezdxf inside the harness — no self-reporting. `unsupported` scores
|
||||
zero in the fixed matrix; per-task reasons are committed under
|
||||
**Read the matrix column before the score column.** The five v3 tasks are new
|
||||
in v1.5.0; the competitor runs are pinned at v1.4 and were never put to them.
|
||||
Their scores are over the ten tasks they actually ran, and the five new rows
|
||||
are left blank in the chart rather than scored zero — an invented zero is
|
||||
indistinguishable from a measured one.
|
||||
|
||||
One runner, headless ezdxf lane. Competitors are driven **black-box over MCP
|
||||
stdio** using their own documented tool contracts at the pinned commit, and
|
||||
every geometry claim is verified by re-opening the exported DXF with ezdxf
|
||||
inside the harness — no self-reporting. `unsupported` scores zero within a
|
||||
server's own matrix; a capability refusal is recorded as `unsupported` rather
|
||||
than `fail`, because "cannot reach this" and "got this wrong" are different
|
||||
findings. Per-task reasons are committed under
|
||||
[`benchmarks/results/published/`](benchmarks/results/published/). File-IPC
|
||||
(live AutoCAD) lanes run locally, not in CI.
|
||||
|
||||
```bash
|
||||
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --list --matrix v3
|
||||
python -m benchmarks.run_competitors --server autocad-mcp-pro --matrix v3 \
|
||||
--publish benchmarks/results/published/autocad-mcp-pro.json
|
||||
python -m benchmarks.render_live_chart
|
||||
```
|
||||
|
||||
@@ -103,7 +118,34 @@ python -m benchmarks.render_live_chart
|
||||
The single score hides *which* capabilities exist, so the matrix shows every
|
||||
task against every server: green = verified pass, amber = partial, gray = the
|
||||
server documents no equivalent (planning, refinement, TABLE/MLEADER, hashed
|
||||
delivery), red = attempted but failed verification.
|
||||
delivery), red = attempted but failed verification, blank = never run against
|
||||
that server.
|
||||
|
||||
The v2 matrix scored this repository 10/10, and that was not a good sign —
|
||||
every task in it exercised something the server was built around, so full
|
||||
marks were the only possible outcome. **v3 adds five tasks chosen because they
|
||||
can fail**, and three of them did fail while being written: a hatch could not
|
||||
be measured at all, the boundary tools disagreed with the measurement tool
|
||||
about the same shape, and a circle stored as two bulged vertices measured zero.
|
||||
|
||||
| v3 task | What it pins | Verified against |
|
||||
|---|---|---|
|
||||
| `tool_discovery` | six AutoCAD command names resolve to the right tool | all six rank #1 of a 154-tool catalog |
|
||||
| `token_budget` | idle catalog cost in discovery mode | 40,305 → 356 tokens, ceiling 2,000 |
|
||||
| `hatch_islands` | a hatch reports the area it *fills* | 300 with the island, 400 ignoring it |
|
||||
| `selection_filter` | window ≠ crossing, polygon ≠ bounding box | 1 / 2 / 3 / 1 on placed geometry |
|
||||
| `measure_from_handle` | curved boundaries measured by handle | 139.2699 against a 100.0 vertex shoelace |
|
||||
|
||||
Token figures come from the offline ratio estimator, not a tokenizer — they are
|
||||
estimates and the report labels them so. `benchmarks/token_suite.py
|
||||
--tokenizer anthropic` counts for real.
|
||||
|
||||
Two tasks are renamed from the release plan, and the renames are the point.
|
||||
`tool_discovery_bilingual` lost its "bilingual": a Turkish query normalizer was
|
||||
written and then removed, because this repository is English throughout and the
|
||||
Turkish was inferred from the author's chat language rather than required by
|
||||
the product. `measurement_massprops` became `measure_from_handle`: no centroid
|
||||
or moment-of-inertia tool ships.
|
||||
|
||||
```bash
|
||||
python -m benchmarks.render_matrix_chart
|
||||
@@ -115,10 +157,31 @@ python -m benchmarks.render_matrix_chart
|
||||
|
||||
| Workload | Wall time | Throughput / result |
|
||||
|---|---:|---|
|
||||
| Create 2,000 lines (individual calls) | 0.64 s | ~3,100 entities/s |
|
||||
| 10,000 lines: build + DXF export + reopen | 13.9 s | ~700 entities/s end-to-end |
|
||||
| Region query over 10,000 entities | 3.4 s | 2,500 matched |
|
||||
| Premium pass (layers + part + dims + full critique) | 0.32 s | 0 issues |
|
||||
| Create 2,000 lines (individual calls) | 0.32 s | ~6,300 entities/s |
|
||||
| 10,000 lines: build + DXF export + reopen | 2.16 s | ~4,600 entities/s end-to-end |
|
||||
| Region query over 10,000 entities | 0.89 s | 2,500 matched |
|
||||
| Premium pass (layers + part + dims + full critique) | 0.18 s | 0 issues |
|
||||
|
||||
These are roughly 2–6× the numbers the v1.4 README printed, and **almost none
|
||||
of that is this release**: the old report was recorded on CPython 3.14 and this
|
||||
one on 3.11.15. Interpreter, not code. Re-measuring v1.4.0 in a worktree on the
|
||||
*same* interpreter with the *same* suite file gives the honest comparison, and
|
||||
it does not flatter us — median of three runs each:
|
||||
|
||||
| Workload | v1.4.0 | v1.5.0 | Change |
|
||||
|---|---:|---:|---|
|
||||
| Create 2,000 lines | 245 ms | 315 ms | **28% slower** |
|
||||
| 10,000-line roundtrip | 1,765 ms | 2,146 ms | **22% slower** |
|
||||
| Region query over 10,000 | 1,090 ms | 1,039 ms | within run-to-run spread |
|
||||
| Premium pass | 375 ms | 44 ms | faster, but the v1.4 spread (279–772 ms) is too wide to put a number on |
|
||||
|
||||
The regression is fully attributed: setting `EZDXF_CALL_TIMEOUT=0` returns
|
||||
v1.5.0 to **244.8 ms** and **1,679 ms** — v1.4.0's numbers exactly. The whole
|
||||
cost is the per-call `asyncio.wait_for` this release wrapped around every
|
||||
headless call, so that one hung ezdxf call can no longer wedge a server whose
|
||||
document lock is a single `asyncio.Lock`. That is a deliberate trade of ~25% of
|
||||
creation throughput for a deadlock that used to be unrecoverable, and the knob
|
||||
to take it back is documented rather than hidden.
|
||||
|
||||
Workloads call the same backend methods the MCP tools call, so server-side
|
||||
overhead is included. Self-measurement only — competitor servers would pay an
|
||||
@@ -133,21 +196,27 @@ python -m benchmarks.render_perf_chart
|
||||
|
||||
### 4 · Version A/B — every release re-proves correctness
|
||||
|
||||
`compare_versions.py` runs the same 21 deterministic, headless correctness
|
||||
`compare_versions.py` runs the same 24 deterministic, headless correctness
|
||||
checks against the previous release tag and the current tree — each check in
|
||||
its own subprocess, so a hard crash counts as a miss instead of killing the
|
||||
run.
|
||||
|
||||
**v1.4.0 release gate** (baseline `v1.3.0`, report:
|
||||
[`ab-v1.3.0-vs-v1.4.0.json`](benchmarks/results/published/ab-v1.3.0-vs-v1.4.0.json)):
|
||||
**v1.5.0 release gate** (baseline `v1.4.0`, report:
|
||||
[`ab-v1.4.0-vs-v1.5.0.json`](benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)):
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---|---:|---:|---:|---:|
|
||||
| v1.3.0 (baseline) | 21 / 21 | 100 % | — | — |
|
||||
| **v1.4.0** (this release) | **21 / 21** | **100 %** | 0 | **0** |
|
||||
| v1.4.0 (baseline) | 21 / 24 | 87.5 % | — | — |
|
||||
| **v1.5.0** (this release) | **24 / 24** | **100 %** | 3 | **0** |
|
||||
|
||||
The three the baseline misses are *new capability, not repaired regressions*:
|
||||
v1.4.0 has no `entity_measure` and no boundary tracing, so it misses them by
|
||||
not having the method. They are in the suite because all three broke during
|
||||
v1.5.0 development and nothing outside their own unit tests would have caught
|
||||
it. The 21 pre-existing checks are **unchanged and all still pass** — the whole
|
||||
of the discovery layer, the contract split, the layout/viewport family and Wave
|
||||
A landed with **zero correctness regressions**.
|
||||
|
||||
v1.4.0 added CI, packaging, benchmarks, tool profiles, paper space, ISO 286
|
||||
fits and opt-in 3D solids **without breaking a single correctness check**.
|
||||
For contrast, the same suite caught the v1.0.0 → v1.1.0 jump:
|
||||
|
||||
| Version | Checks passing | Pass rate |
|
||||
@@ -156,25 +225,40 @@ For contrast, the same suite caught the v1.0.0 → v1.1.0 jump:
|
||||
| v1.1.0 | 21 / 21 | 100 % |
|
||||
|
||||
```bash
|
||||
python benchmarks/compare_versions.py v1.3.0 --json ab.json
|
||||
python benchmarks/compare_versions.py v1.4.0 --json ab.json
|
||||
```
|
||||
|
||||
Full rubric, caveats, method and boundaries for every lane:
|
||||
[`benchmarks/README.md`](benchmarks/README.md).
|
||||
|
||||
## What ships in v1.4
|
||||
## What ships in v1.5
|
||||
|
||||
| Area | Tools |
|
||||
|---|---|
|
||||
| Drawing lifecycle | create, open, save, export DXF/PDF, audit, purge, undo/redo |
|
||||
| Drawing lifecycle | create, open, save, export DXF/PDF, audit **(now repairs)**, purge, undo/redo |
|
||||
| Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits |
|
||||
| Annotation | ISO 129 toleranced dimensions, **ISO 286 fits** (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) |
|
||||
| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) |
|
||||
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 titleblock |
|
||||
| **Paper space** *(new)* | `layout_list/create/set_current`, scaled `viewport_create`, `drawing_export_pdf(layout=...)` |
|
||||
| **3D solids** *(new, opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) |
|
||||
| **Discovery** *(new)* | `DISCOVERY_MODE=search` replaces the catalog with `search_tools` + `call_tool`, ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK` score nothing against a stock index because the words are simply not in the data |
|
||||
| **Batching** *(new)* | `cad_batch` runs a whole step list in one round trip; `fields=` projection trims result-heavy responses |
|
||||
| **Paper space** *(new)* | full tab lifecycle (`layout_list/create/set_current/delete/rename/copy`), viewports (`viewport_create/list/set_scale/lock/delete`), `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=...)` |
|
||||
| **Selection** *(new)* | `selection_window` / `selection_polygon` / `selection_filter` — window vs crossing stated back to the caller, and a polygon tested against its own shape rather than its bounding box |
|
||||
| **Boundaries** *(new)* | `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities` chain loose edges into one closed polyline, arcs kept as bulges |
|
||||
| **Measurement** *(new)* | `analysis_measure_entity` measures what is *in* the drawing by handle — reading vertices back and shoelacing them loses 28.2% of the area on a semicircular edge |
|
||||
| **Hatch depth** *(new)* | gradients, in-place edits, typed edge boundaries, island styles |
|
||||
| **Annotation objects** *(new)* | WIPEOUT, REVCLOUD, MTEXT background masks, text find/replace |
|
||||
| **3D solids** *(opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) |
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0-100 score) |
|
||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||
| Discovery | **tool profiles** *(new)*: `TOOL_PROFILE=lean` (~46 tools) / `core` / `full` for clients with tight tool caps |
|
||||
| Profiles | `TOOL_PROFILE=lean` (~47 curated tools) for clients with tight tool caps; `core` was removed — the discovery layer replaced it |
|
||||
|
||||
**Every coordinate in and out of a tool is WCS, on both engines.** DXF stores
|
||||
CIRCLE / ARC / LWPOLYLINE / TEXT / INSERT geometry in a per-entity frame, and
|
||||
before v1.5.0 the word `extrusion` did not appear anywhere in this codebase —
|
||||
so a mirrored or rotated entity reported coordinates that were quietly wrong.
|
||||
`backends/ocs.py` now translates at the backend boundary; an entity in a plane
|
||||
tilted out of WCS XY reports `plane_normal` and omits the fields xy cannot
|
||||
express, rather than answering with a number that looks fine.
|
||||
|
||||
The tool surface evolves — ask `system_about` for the live grouped inventory
|
||||
and `system_capabilities` for per-backend support modes instead of copying
|
||||
@@ -196,7 +280,9 @@ flowchart LR
|
||||
```
|
||||
|
||||
- `server.py` — FastMCP surface, lifespan, middleware, resources, prompts.
|
||||
- `backends/base.py` — shared typed contract + capability model.
|
||||
- `backends/contracts/` — the typed contract, one module per domain.
|
||||
- `backends/capability.py` — capability model, typed refusals, `@capability`.
|
||||
- `backends/base.py` — shared data models + the contract composition.
|
||||
- `backends/com_backend.py` — single-STA-thread COM executor with per-call
|
||||
timeouts.
|
||||
- `backends/ezdxf_backend.py` — `asyncio.to_thread`-wrapped DXF engine with
|
||||
@@ -214,7 +300,14 @@ flowchart LR
|
||||
| Cross-platform execution | — | ✓ |
|
||||
| Transactions and rollback | ✓ | ✓ |
|
||||
| Paper-space layouts + viewports | ✓ | ✓ |
|
||||
| Viewport model-content rendering | ✓ | — |
|
||||
| Viewport model-content rendering | ✓ | ✓ (no viewport borders) |
|
||||
| CHSPACE (`entity_change_space`) | — (no ActiveX member) | ✓ |
|
||||
| WIPEOUT / REVCLOUD | — (no ActiveX member) | ✓ |
|
||||
| Boundary tracing (BPOLY) | — (no ActiveX member) | ✓ |
|
||||
| Selection window / crossing / polygon | ✓ | ✓ |
|
||||
| Entity area by handle | ActiveX `.Area` | Analytic, bulges included |
|
||||
| HATCH filled area (islands subtracted) | AutoCAD's own number | Loops walked, `hatch_style` reported |
|
||||
| REGION / 3DSOLID area | ✓ | — (ACIS is opaque to ezdxf) |
|
||||
| TABLE and MLEADER semantics | Native | Portable composite |
|
||||
| 3D solids (opt-in) | ✓ | — (no headless ACIS) |
|
||||
| Screenshots | AutoCAD window | Matplotlib render |
|
||||
@@ -270,25 +363,49 @@ explicitly enabled **and** a bearer token is configured.
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
|
||||
| `TOOL_PROFILE` | `full` | `lean` (~46 curated tools), `core` (hides escape hatches), `full` |
|
||||
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to (see below) |
|
||||
| `TOOL_PROFILE` | `full` | `lean` (~47 curated tools) or `full` |
|
||||
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
|
||||
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM backend) |
|
||||
| `LOG_LEVEL` | `INFO` | Python logging level |
|
||||
| `ALLOWED_PATHS` | empty | Comma-separated absolute paths the server may access |
|
||||
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
|
||||
| `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables |
|
||||
| `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes |
|
||||
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds) |
|
||||
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds); `0` disables |
|
||||
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless ezdxf timeout (seconds); `0` disables |
|
||||
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP; reported as unsafe mode |
|
||||
| `ALLOW_REMOTE_HTTP` | `false` | Permit a non-loopback HTTP bind |
|
||||
| `MCP_AUTH_TOKEN` | empty | Bearer token required for remote HTTP |
|
||||
|
||||
`CAD_PROGID` only changes which COM application the backend attaches to — every
|
||||
tool beyond the connection itself is developed and tested against AutoCAD alone,
|
||||
and AutoCAD-clone compatibility is unverified, so treat a non-default ProgID as
|
||||
experimental. Candidates such as `BricscadApp.AcadApplication`,
|
||||
`ZWCAD.Application` and `GstarCAD.Application` are listed in `.env.example`
|
||||
commented out and marked UNVERIFIED: none has been tested against a seat. An
|
||||
unrecognised ProgID fails loudly rather than falling back, because the fallback
|
||||
path COM-launches the application and would start the very AutoCAD you said you
|
||||
were not using.
|
||||
|
||||
## Security model
|
||||
|
||||
Tool input is treated as untrusted:
|
||||
|
||||
- every path passes traversal + allowed-root validation;
|
||||
- dangerous AutoCAD commands and AutoLISP channels are blocked by default,
|
||||
with regression tests for known bypass patterns;
|
||||
- every path passes traversal + allowed-root validation, and non-local anchors
|
||||
(`\\?\`, UNC, `\\localhost\C$`) are rejected in every spelling — the
|
||||
forward-slash forms used to normalise past the pattern check and then compare
|
||||
unequal to the system-directory list;
|
||||
- `system_run_command` / `system_run_lisp` are free-text escape hatches into the
|
||||
live AutoCAD command line. A **verb** denylist (the first token of each macro
|
||||
segment, decorators stripped) refuses the obviously destructive ones by
|
||||
default, and quoted AutoLISP string literals are treated as data so drawing
|
||||
text is not mistaken for code. Treat this as a guardrail against an agent
|
||||
accidentally issuing `ERASE ALL` — **not** as a boundary against a hostile
|
||||
client. AutoCAD accepts hundreds of commands and any loaded ARX/LISP adds
|
||||
more, so the list cannot be complete. The real boundaries are `ALLOWED_PATHS`,
|
||||
not exposing the server over the network, and preferring the typed tools
|
||||
(which validate every argument) over raw commands;
|
||||
- remote HTTP requires explicit opt-in plus a bearer token;
|
||||
- DXF size, response sizes, undo history and COM call duration are bounded;
|
||||
- audit middleware records tool timing and failures;
|
||||
@@ -300,18 +417,35 @@ rather than public issues.
|
||||
|
||||
## Development
|
||||
|
||||
`uv.lock` pins the whole transitive graph — runtime *and* dev tools — to the
|
||||
set the suite was last run against, so this reproduces the CI environment
|
||||
exactly rather than approximately:
|
||||
|
||||
```bash
|
||||
pip install -e ".[full]"
|
||||
pip install pytest pytest-asyncio pytest-cov ruff build
|
||||
python -m pytest # 474 tests
|
||||
python -m ruff check . && python -m ruff format --check .
|
||||
python -m build
|
||||
uv sync --locked --all-extras
|
||||
uv run pytest
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
```
|
||||
|
||||
CI runs the same gates on Linux (3.11/3.12), Windows (mocked-COM suite),
|
||||
plus package, Docker and MCP-registry-schema jobs. Releases are tag-driven:
|
||||
`git tag vX.Y.Z && git push origin vX.Y.Z` builds, publishes to PyPI and cuts
|
||||
the GitHub Release. See
|
||||
Without uv, from the declared dependency ranges (needs pip ≥ 25.1 for
|
||||
`--group`; resolves fresh, so it can differ from CI):
|
||||
|
||||
```bash
|
||||
pip install -e ".[full]"
|
||||
pip install --group dev build
|
||||
python -m pytest && python -m build
|
||||
```
|
||||
|
||||
CI installs with `uv sync --locked`, which fails rather than re-resolving, so a
|
||||
fresh run cannot silently pick up an untested upstream — run `uv lock` after
|
||||
changing dependencies or the lint job will reject the drift. The gates run on
|
||||
Linux (3.11/3.12), Windows (mocked-COM suite), plus package, Docker and
|
||||
MCP-registry-schema jobs; the package job deliberately stays *unlocked*,
|
||||
because it is the one that proves a plain `pip install autocad-mcp-pro` still
|
||||
resolves. A scheduled `deps-floating` workflow resolves the ranges fresh and
|
||||
re-runs the suite, so a locked CI does not become blindness to upstream drift.
|
||||
Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z` builds,
|
||||
publishes to PyPI and cuts the GitHub Release. See
|
||||
[`docs/RELEASE-DISTRIBUTION.md`](docs/RELEASE-DISTRIBUTION.md).
|
||||
|
||||
Repository map:
|
||||
@@ -321,20 +455,37 @@ server.py FastMCP surface and orchestration
|
||||
config.py Environment-driven settings
|
||||
security.py Path, command, and AutoLISP guards
|
||||
version.py Canonical package version
|
||||
uv.lock Exact dependency graph CI installs from
|
||||
backends/ COM and ezdxf implementations
|
||||
engineering/ Standards, generators, critique, fits, delivery
|
||||
benchmarks/ Runners, adapters, published reports, chart renderers
|
||||
tests/ Headless, mocked-COM, contract, and security tests
|
||||
```
|
||||
|
||||
## Roadmap (1.5)
|
||||
## Roadmap (1.6)
|
||||
|
||||
Screenshot overlay + handle grounding, ezdxf redo (forward snapshots), COM
|
||||
`block_create_from_entities`, ISO 286 transition/interference hole letters
|
||||
(delta rule), titleblock on paper-space layouts. Features are published when
|
||||
**Give back the 25% creation throughput** the per-call ezdxf timeout costs
|
||||
(measured and attributed above) — arm it only for calls that can actually block,
|
||||
or find a deadline cheaper than an `asyncio.wait_for` per call. ISO 286
|
||||
interference **shafts** r/s/t/u (they need sub-stepped table data nothing in the
|
||||
module can derive, and guessing them would put wrong tolerances on a production
|
||||
drawing — so 1.5 refuses them by name instead). HATCH boundary *geometry* in
|
||||
`entity_get` — 1.5 measures a hatch's filled area but still does not hand back
|
||||
its loops. REGION/3DSOLID area on the headless engine, which needs a modelling
|
||||
kernel ezdxf does not have. An *allowlist* of permitted AutoLISP heads,
|
||||
replacing the denylist — enumerating dangerous symbols does not terminate.
|
||||
`ezdxf.recover` as a fallback on `drawing_open`. Features are published when
|
||||
their contracts and limitations are testable — not when they make a longer
|
||||
checklist.
|
||||
|
||||
Cut from 1.5 on evidence, not on time: Wave A shipped 13 of a planned 39 tools.
|
||||
The 2D-boolean and REGION family went because `add_region()` produces a REGION
|
||||
with zero ACIS bytes and the `greiner_hormann` substitute loses **28.2% of the
|
||||
area** on a square with one semicircular edge; most of the planned measurement
|
||||
family was already covered by the eight `analysis_*` tools that existed. The
|
||||
full cut list and its measurements are in
|
||||
[`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome — especially reproducible competitor adapters,
|
||||
|
||||
+68
-1746
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
"""Optional contract methods: declared once, refused loudly, gated by a test.
|
||||
|
||||
The backend contract is large (112 abstract methods before this file existed,
|
||||
and the plan adds ~39 more). Every one of them is an `@abstractmethod`, which
|
||||
means adding a single method to the contract makes **both** backends fail at
|
||||
instantiation until both are written — and two test modules construct
|
||||
`ComBackend` at import time, so the whole suite goes red on a half-finished
|
||||
edit. That is a real cost paid on every contract change.
|
||||
|
||||
The obvious fix is to give new methods a default body. The obvious default is
|
||||
wrong: returning ``{"ok": False, "capability": ...}`` from a method nobody
|
||||
implemented produces exactly the quiet "this didn't work and nothing told you"
|
||||
that the v1.5.0 honesty pass spent a milestone deleting. A default that lies
|
||||
quietly is worse than a build break.
|
||||
|
||||
So `@capability` gives a default that **raises**
|
||||
`UnsupportedCapabilityError` — the same typed refusal the deliberate
|
||||
boundaries raise, carrying the same `capability` key, surfaced by the same
|
||||
`CapabilityRefusalMiddleware`, and indistinguishable to a client from a
|
||||
hand-written refusal. Three properties follow:
|
||||
|
||||
* An unimplemented method refuses instead of pretending. It cannot silently
|
||||
succeed and it cannot silently do nothing.
|
||||
* The refusal is *declared*: `test_capability.py` fails if a key used here is
|
||||
missing from either backend's `CapabilityMap`, so `system_capabilities` can
|
||||
never disagree with what a call actually does. This is the part that keeps
|
||||
the escape hatch honest — without it, `@capability` would just be a way to
|
||||
ship undeclared holes.
|
||||
* Overriding is ordinary subclassing. A backend that implements the method
|
||||
simply defines it; nothing needs to be un-registered.
|
||||
|
||||
`@abstractmethod` remains the default for anything both backends must have.
|
||||
Reach for `@capability` only when a method is genuinely optional — an engine
|
||||
boundary (no ACIS kernel headlessly) rather than an unfinished chore.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"UnsupportedCapabilityError",
|
||||
"capability",
|
||||
"capability_keys",
|
||||
"is_capability_default",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The refusal vocabulary
|
||||
#
|
||||
# This lives here rather than in ``backends.base`` because the contracts import
|
||||
# the decorator and ``base`` imports the contracts — putting the exception in
|
||||
# ``base`` makes that a cycle. ``backends.base`` re-exports both names, so
|
||||
# ``from backends.base import UnsupportedCapabilityError`` is unchanged for the
|
||||
# rest of the repo.
|
||||
#
|
||||
# There is exactly one authoring rule: a backend that cannot do a thing RAISES
|
||||
# ``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
|
||||
# key of ``capabilities().features`` so a client can pre-check the same boundary
|
||||
# through ``system_capabilities`` instead of parsing English. Tools never catch
|
||||
# it and never hand-build refusal payloads — one middleware turns it into the
|
||||
# wire payload, and ``cad_batch`` turns it into a step row. One class object,
|
||||
# defined once: two look-alike types would silently break every ``isinstance``
|
||||
# classifier.
|
||||
#
|
||||
# The ``{"ok": False, "error": ...}`` returned-dict shape stays legal only for
|
||||
# soft failures that are not capability boundaries ("Layout not found", "No
|
||||
# active transaction").
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _unsupported(error: str, capability_key: str) -> dict:
|
||||
"""Build a backend's machine-readable "this backend can't" payload."""
|
||||
return {"ok": False, "error": error, "capability": capability_key}
|
||||
|
||||
|
||||
class UnsupportedCapabilityError(RuntimeError):
|
||||
"""Raised when a call needs a capability this backend does not have.
|
||||
|
||||
Stays a plain ``RuntimeError``: rebasing it on ``FastMCPError`` was measured
|
||||
and changes nothing a client can observe, because the refusal is carried by
|
||||
``ToolResult(structured_content=..., is_error=True)`` rather than by the
|
||||
exception class. ``to_dict()`` keeps the ``{ok, error, capability}`` payload
|
||||
attached to the raise so the boundary survives the ``__cause__`` chain.
|
||||
"""
|
||||
|
||||
def __init__(self, capability: str, error: str):
|
||||
super().__init__(error)
|
||||
self.capability = capability
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return _unsupported(str(self), self.capability)
|
||||
|
||||
|
||||
#: ``{capability_key: (method_name, ...)}`` for every method declared optional.
|
||||
#: Populated at import by the decorator and read by the parity gate.
|
||||
_REGISTRY: dict[str, set[str]] = {}
|
||||
|
||||
|
||||
def capability(key: str, *, reason: str = "") -> Callable[[Callable], Callable]:
|
||||
"""Mark a contract method optional, defaulting to a typed refusal.
|
||||
|
||||
Args:
|
||||
key: capability name; must appear in **both** backends' capability maps.
|
||||
reason: appended to the refusal message. Say what the caller can do
|
||||
instead, not merely that they cannot do this.
|
||||
"""
|
||||
|
||||
def decorate(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def default(self, *args: Any, **kwargs: Any):
|
||||
message = f"{func.__name__}: not supported by the {self.name} backend"
|
||||
raise UnsupportedCapabilityError(key, f"{message}. {reason}".rstrip())
|
||||
|
||||
default.__capability__ = key
|
||||
default.__is_capability_default__ = True
|
||||
_REGISTRY.setdefault(key, set()).add(func.__name__)
|
||||
return default
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def capability_keys() -> dict[str, set[str]]:
|
||||
"""Every optional-capability key and the methods declared under it."""
|
||||
return {key: set(names) for key, names in _REGISTRY.items()}
|
||||
|
||||
|
||||
def is_capability_default(owner: type, method_name: str) -> bool:
|
||||
"""True when ``owner`` inherited the refusing default rather than implementing it."""
|
||||
attribute = getattr(owner, method_name, None)
|
||||
return bool(getattr(attribute, "__is_capability_default__", False))
|
||||
+1177
-19
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
"""Per-domain slices of the backend contract.
|
||||
|
||||
One 1854-line ABC with 112 abstract methods made every contract change a
|
||||
whole-file edit, and made a half-finished change break both backends at
|
||||
instantiation at once. The seams here are the section markers that were
|
||||
already in the file, so the split moved code without re-deciding anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backends.contracts.analysis import AnalysisContract
|
||||
from backends.contracts.blocks import BlockContract
|
||||
from backends.contracts.corner_ops import CornerOpsContract
|
||||
from backends.contracts.dimensions import DimensionContract
|
||||
from backends.contracts.drawing import DrawingContract
|
||||
from backends.contracts.entity_creation import EntityCreationContract
|
||||
from backends.contracts.entity_modification import EntityModificationContract
|
||||
from backends.contracts.entity_query import EntityQueryContract
|
||||
from backends.contracts.gdt import GdtContract
|
||||
from backends.contracts.identity import IdentityContract
|
||||
from backends.contracts.layers import LayerContract
|
||||
from backends.contracts.layouts import LayoutContract
|
||||
from backends.contracts.linetypes import LinetypeContract
|
||||
from backends.contracts.premium import PremiumContract
|
||||
from backends.contracts.settings import SettingsContract
|
||||
from backends.contracts.solids import SolidContract
|
||||
from backends.contracts.system import SystemContract
|
||||
from backends.contracts.transactions import TransactionContract
|
||||
from backends.contracts.view import ViewContract
|
||||
|
||||
__all__ = [
|
||||
"AnalysisContract",
|
||||
"BlockContract",
|
||||
"CornerOpsContract",
|
||||
"DimensionContract",
|
||||
"DrawingContract",
|
||||
"EntityCreationContract",
|
||||
"EntityModificationContract",
|
||||
"EntityQueryContract",
|
||||
"GdtContract",
|
||||
"IdentityContract",
|
||||
"LayerContract",
|
||||
"LayoutContract",
|
||||
"LinetypeContract",
|
||||
"PremiumContract",
|
||||
"SettingsContract",
|
||||
"SolidContract",
|
||||
"SystemContract",
|
||||
"TransactionContract",
|
||||
"ViewContract",
|
||||
]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Measurement and analysis.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from backends.capability import capability
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
|
||||
|
||||
class AnalysisContract(ABC):
|
||||
# ── boundary tracing (M8 / F3) ───────────────────────────────────────────
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"boundary_trace",
|
||||
reason=(
|
||||
"ActiveX exposes no BOUNDARY member; the live path would have to drive the "
|
||||
"-BOUNDARY command blind, which has not been verified against a live AutoCAD. "
|
||||
"Use the headless backend (AUTOCAD_MCP_BACKEND=ezdxf), or run BOUNDARY in "
|
||||
"AutoCAD directly."
|
||||
),
|
||||
)
|
||||
async def boundary_trace(
|
||||
self, x: float, y: float, layer: str | None = None, tolerance: float = 1e-9
|
||||
) -> dict:
|
||||
"""AutoCAD's BOUNDARY/BPOLY: trace the closed loop enclosing a seed point.
|
||||
|
||||
Returns ``{ok, handle, vertices, area, closed, source_handles}``.
|
||||
``vertices`` is a count, ``area`` is unsigned.
|
||||
|
||||
Implementations must not enumerate every loop in the drawing:
|
||||
``edgeminer.find_all_loops`` is O(n!) and was measured at 63.9 s for 40
|
||||
edges and a timeout at 60. The seed point picks a starting edge and one
|
||||
loop is walked from it.
|
||||
|
||||
A seed with no enclosing loop is refused. Returning an empty boundary
|
||||
would be a boundary that bounds nothing, reported as success.
|
||||
"""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"boundary_trace",
|
||||
reason=(
|
||||
"Chaining arbitrary entities into a loop needs the same edge graph BOUNDARY "
|
||||
"does, which ActiveX does not expose. Use the headless backend "
|
||||
"(AUTOCAD_MCP_BACKEND=ezdxf)."
|
||||
),
|
||||
)
|
||||
async def boundary_from_entities(self, handles: list[str], tolerance: float = 1e-9) -> dict:
|
||||
"""Chain the given entities into one closed polyline.
|
||||
|
||||
Returns ``{ok, handle, vertices, area, closed}``. Refuses a chain that
|
||||
does not close, naming the coordinates of the endpoint that dangles —
|
||||
"these do not form a loop" is not actionable without the gap.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_list_properties(self, handle: str) -> dict:
|
||||
"""AutoCAD's LIST: the full DXF attribute set for one handle.
|
||||
|
||||
Returns ``{ok, handle, type, layer, properties, dxf_attributes}``.
|
||||
``properties`` mirrors what ``entity_get`` reports; ``dxf_attributes``
|
||||
is the raw attribute set that ``entity_get`` deliberately does not
|
||||
carry.
|
||||
|
||||
Two things the raw dump cannot be handed over as-is. It holds ``Vec3``
|
||||
values, so it is not JSON-serialisable and would die at the wire
|
||||
boundary; and its coordinates are stored in the entity's own frame, so
|
||||
for anything with an extrusion they would contradict every other
|
||||
coordinate this server reports. Both are converted here, and
|
||||
``extrusion`` stays in the dump so the original frame is still visible.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_stats(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_entities_in_region(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
) -> list[EntityInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_measure_distance(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
) -> float: ...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_measure_area(self, points: list[list[float]]) -> float: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_measure(self, handle: str, flatten_tolerance: float = 0.001) -> dict:
|
||||
"""Area and perimeter of ONE existing entity, addressed by handle.
|
||||
|
||||
Returns ``{"handle", "type", "area", "perimeter", "closed",
|
||||
"assumed_closed", "method", "exact", "flatten_tolerance", "backend",
|
||||
"self_intersecting", "perimeter_exact", "loop_count"}``.
|
||||
|
||||
A HATCH is measured as the area it *fills* — outer loops minus the
|
||||
islands inside them — and the headless engine adds ``hatch_style``
|
||||
(``normal``/``outer``/``ignore``) so the number can be read against the
|
||||
drawing's own island setting. The live backend does not carry that key:
|
||||
it asks AutoCAD for ``.Area``, which has already applied the style, so
|
||||
there is no house rule left to disclose.
|
||||
|
||||
``exact`` and
|
||||
``flatten_tolerance`` are the accuracy disclosure: when one engine can
|
||||
answer in closed form and the other has to flatten, the payload says so
|
||||
rather than leaving two numbers to quietly disagree.
|
||||
|
||||
Two different refusals, and the difference matters. A type that bounds no
|
||||
area at all (LINE, TEXT, a 3D polyline) raises a plain ``RuntimeError``:
|
||||
no engine can measure it, so tagging a capability would falsely suggest
|
||||
switching backends helps. A type this *engine* cannot evaluate (ACIS
|
||||
solids headlessly) raises ``UnsupportedCapabilityError``, which names the
|
||||
engine that can.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_bounding_box(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def selection_get(self) -> dict:
|
||||
"""Read the live viewport's implied ("pickfirst") selection set.
|
||||
|
||||
COM-only / meaningful only with live AutoCAD. Returns a dict::
|
||||
|
||||
{
|
||||
"ok": bool, # True on COM even for an empty pick
|
||||
"count": int,
|
||||
"handles": list[str], # entity handles to act on
|
||||
"entities": list[EntityInfo], # _dc()-converted by the server
|
||||
"pickfirst": bool | None, # state of the PICKFIRST sysvar
|
||||
"message" / "error": str, # optional guidance / failure reason
|
||||
}
|
||||
|
||||
The ezdxf headless backend has no viewport, so it returns ``ok=False``
|
||||
with an empty ``handles`` list (same shape, never raises).
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Blocks and block references.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import BlockInfo, EntityInfo
|
||||
|
||||
|
||||
class BlockContract(ABC):
|
||||
@abstractmethod
|
||||
async def block_list(self) -> list[BlockInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def block_insert(
|
||||
self,
|
||||
name: str,
|
||||
x: float,
|
||||
y: float,
|
||||
scale_x: float = 1.0,
|
||||
scale_y: float = 1.0,
|
||||
rotation: float = 0.0,
|
||||
attributes: dict | None = None,
|
||||
layer: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def block_explode(self, handle: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def block_get_attributes(self, handle: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def block_create_from_entities(
|
||||
self,
|
||||
name: str,
|
||||
handles: list[str],
|
||||
base_x: float = 0.0,
|
||||
base_y: float = 0.0,
|
||||
) -> dict: ...
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Trim, extend, fillet, chamfer.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
|
||||
|
||||
class CornerOpsContract(ABC):
|
||||
@abstractmethod
|
||||
async def entity_trim(
|
||||
self,
|
||||
target_handle: str,
|
||||
cutter_handle: str,
|
||||
keep_x: float,
|
||||
keep_y: float,
|
||||
) -> EntityInfo:
|
||||
"""Trim `target` against `cutter`; keep the segment containing
|
||||
(keep_x, keep_y). Raises ToolError if no intersection."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_extend(
|
||||
self,
|
||||
target_handle: str,
|
||||
boundary_handle: str,
|
||||
end_x: float | None = None,
|
||||
end_y: float | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
|
||||
target endpoint nearest the boundary is auto-selected."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_fillet(
|
||||
self,
|
||||
handle1: str,
|
||||
handle2: str,
|
||||
radius: float,
|
||||
trim: bool = True,
|
||||
) -> EntityInfo:
|
||||
"""Fillet two entities with the given radius. Returns the new ARC.
|
||||
When `trim` is True (default), the source entities are shortened
|
||||
to the tangent points (AutoCAD default behaviour)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_chamfer(
|
||||
self,
|
||||
handle1: str,
|
||||
handle2: str,
|
||||
dist1: float,
|
||||
dist2: float | None = None,
|
||||
trim: bool = True,
|
||||
) -> EntityInfo:
|
||||
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
|
||||
(symmetric chamfer). Returns the new chamfer LINE."""
|
||||
...
|
||||
@@ -0,0 +1,89 @@
|
||||
"""ISO 129 dimensions.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
|
||||
|
||||
class DimensionContract(ABC):
|
||||
@abstractmethod
|
||||
async def dimension_linear(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
dim_x: float,
|
||||
dim_y: float,
|
||||
rotation: float = 0.0,
|
||||
layer: str | None = None,
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def dimension_aligned(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
dim_x: float,
|
||||
dim_y: float,
|
||||
layer: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def dimension_angular(
|
||||
self,
|
||||
vx: float,
|
||||
vy: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
tx: float,
|
||||
ty: float,
|
||||
layer: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def dimension_radius(
|
||||
self,
|
||||
cx: float,
|
||||
cy: float,
|
||||
chord_x: float,
|
||||
chord_y: float,
|
||||
leader_length: float = 10.0,
|
||||
layer: str | None = None,
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def dimension_diameter(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
leader_length: float = 10.0,
|
||||
layer: str | None = None,
|
||||
tol_upper: float | None = None,
|
||||
tol_lower: float | None = None,
|
||||
tol_mode: str = "none",
|
||||
text_override: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Document lifecycle.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import DrawingInfo
|
||||
|
||||
|
||||
class DrawingContract(ABC):
|
||||
@abstractmethod
|
||||
async def drawing_info(self) -> DrawingInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_new(self, template: str | None = None) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_open(self, path: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_save(self, path: str | None = None) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_export_dxf(self, path: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_purge(self) -> dict: ...
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Creating geometry.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from backends.capability import capability
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
|
||||
|
||||
class EntityCreationContract(ABC):
|
||||
@abstractmethod
|
||||
async def entity_create_line(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
z1: float = 0.0,
|
||||
z2: float = 0.0,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
linetype: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_circle(
|
||||
self,
|
||||
cx: float,
|
||||
cy: float,
|
||||
radius: float,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_arc(
|
||||
self,
|
||||
cx: float,
|
||||
cy: float,
|
||||
radius: float,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_polyline(
|
||||
self,
|
||||
points: list[list[float]],
|
||||
closed: bool = False,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_text(
|
||||
self,
|
||||
text: str,
|
||||
x: float,
|
||||
y: float,
|
||||
height: float = 2.5,
|
||||
rotation: float = 0.0,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_mtext(
|
||||
self,
|
||||
text: str,
|
||||
x: float,
|
||||
y: float,
|
||||
width: float = 100.0,
|
||||
height: float = 2.5,
|
||||
rotation: float = 0.0,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_table(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
rows: list[list[str]],
|
||||
headers: list[str] | None = None,
|
||||
column_widths: list[float] | None = None,
|
||||
row_height: float = 7.0,
|
||||
text_height: float = 2.5,
|
||||
title: str | None = None,
|
||||
layer: str = "TEXT",
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def leader_create_mleader(
|
||||
self,
|
||||
points: list[list[float]],
|
||||
text: str,
|
||||
text_height: float = 2.5,
|
||||
landing_gap: float = 1.0,
|
||||
arrow_size: float = 2.5,
|
||||
layer: str = "DIM",
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_hatch(
|
||||
self,
|
||||
pattern: str,
|
||||
boundary_points: list[list[float]],
|
||||
scale: float = 1.0,
|
||||
angle: float = 0.0,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_spline(
|
||||
self,
|
||||
fit_points: list[list[float]],
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_ellipse(
|
||||
self,
|
||||
cx: float,
|
||||
cy: float,
|
||||
major_x: float,
|
||||
major_y: float,
|
||||
ratio: float = 0.5,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_point(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_block_ref(
|
||||
self,
|
||||
name: str,
|
||||
x: float,
|
||||
y: float,
|
||||
scale_x: float = 1.0,
|
||||
scale_y: float = 1.0,
|
||||
rotation: float = 0.0,
|
||||
layer: str | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
# ── hatch depth (M8 / F4) ────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def hatch_set_gradient(
|
||||
self,
|
||||
handle: str,
|
||||
color1: list[int],
|
||||
color2: list[int],
|
||||
rotation: float = 0.0,
|
||||
centered: float = 0.0,
|
||||
one_color: bool = False,
|
||||
tint: float = 0.0,
|
||||
name: str = "LINEAR",
|
||||
) -> dict:
|
||||
"""Replace a hatch's fill with a two-colour gradient. ``{ok, handle, gradient}``."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def hatch_edit(
|
||||
self,
|
||||
handle: str,
|
||||
pattern: str = "",
|
||||
scale: float | None = None,
|
||||
angle: float | None = None,
|
||||
color: int | None = None,
|
||||
style: str = "",
|
||||
) -> dict:
|
||||
"""Edit an existing hatch in place. Returns ``{ok, handle, changed}``.
|
||||
|
||||
``changed`` names exactly the attributes that moved: an omitted
|
||||
parameter must leave its attribute alone, because a partial edit that
|
||||
quietly resets the rest is data loss. ``style`` is the island style —
|
||||
``normal``, ``outer`` or ``ignore``.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def hatch_add_boundary(self, handle: str, edges: list[dict]) -> dict:
|
||||
"""Add one boundary path built from typed edges.
|
||||
|
||||
Returns ``{ok, handle, path_count, edge_types}``. Edges are
|
||||
``{"type": "line"|"arc"|"ellipse", ...}``. Typed edges exist because a
|
||||
boundary API that only accepts vertex lists silently straightens every
|
||||
curve it is given.
|
||||
"""
|
||||
...
|
||||
|
||||
# ── annotation objects (M8 / F15) ────────────────────────────────────────
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"wipeout",
|
||||
reason=(
|
||||
"ActiveX has no wipeout constructor — ModelSpace.AddWipeout does not exist "
|
||||
"(verified against AutoCAD 2026); WIPEOUT is a command only. Use the headless "
|
||||
"backend (AUTOCAD_MCP_BACKEND=ezdxf), or run WIPEOUT in AutoCAD directly."
|
||||
),
|
||||
)
|
||||
async def entity_create_wipeout(
|
||||
self, points: list[list[float]], layer: str | None = None
|
||||
) -> dict:
|
||||
"""Mask the drawing behind a closed polygon. Returns ``{ok, handle, points}``.
|
||||
|
||||
Refuses fewer than three points: a zero-area mask masks nothing while
|
||||
reporting success.
|
||||
"""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"revcloud",
|
||||
reason=(
|
||||
"ActiveX exposes no revision-cloud member and driving the REVCLOUD command "
|
||||
"blind against a live drawing is unverified. Use the headless backend "
|
||||
"(AUTOCAD_MCP_BACKEND=ezdxf), or run REVCLOUD in AutoCAD directly."
|
||||
),
|
||||
)
|
||||
async def entity_create_revcloud(
|
||||
self,
|
||||
points: list[list[float]],
|
||||
segment_length: float,
|
||||
layer: str | None = None,
|
||||
closed: bool = True,
|
||||
) -> dict:
|
||||
"""Draw a revision cloud along a path. Returns ``{ok, handle, segments}``.
|
||||
|
||||
A revision cloud *is* a polyline whose every segment carries an arc
|
||||
bulge; a segment length longer than the path yields no arcs at all, so
|
||||
that case is refused rather than returned as a rectangle.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Moving, copying and editing geometry.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
|
||||
|
||||
class EntityModificationContract(ABC):
|
||||
@abstractmethod
|
||||
async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_copy(
|
||||
self, handle: str, dx: float, dy: float, dz: float = 0.0
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_rotate(
|
||||
self,
|
||||
handle: str,
|
||||
base_x: float,
|
||||
base_y: float,
|
||||
angle_deg: float,
|
||||
) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_scale(
|
||||
self,
|
||||
handle: str,
|
||||
base_x: float,
|
||||
base_y: float,
|
||||
factor: float,
|
||||
) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_mirror(
|
||||
self,
|
||||
handle: str,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
delete_original: bool = False,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_offset(
|
||||
self,
|
||||
handle: str,
|
||||
distance: float,
|
||||
side_x: float | None = None,
|
||||
side_y: float | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_delete(self, handle: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_array_rectangular(
|
||||
self,
|
||||
handle: str,
|
||||
rows: int,
|
||||
cols: int,
|
||||
row_spacing: float,
|
||||
col_spacing: float,
|
||||
) -> list[EntityInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_array_polar(
|
||||
self,
|
||||
handle: str,
|
||||
count: int,
|
||||
fill_angle: float,
|
||||
center_x: float,
|
||||
center_y: float,
|
||||
) -> list[EntityInfo]: ...
|
||||
|
||||
# ── text annotation (M8 / F15) ───────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def text_set_background(
|
||||
self,
|
||||
handle: str,
|
||||
enabled: bool = True,
|
||||
color: int | None = None,
|
||||
scale: float = 1.5,
|
||||
) -> dict:
|
||||
"""Put an opaque background box behind an MTEXT. ``{ok, handle, enabled}``.
|
||||
|
||||
MTEXT only — TEXT has no background-fill attribute, so setting one on
|
||||
it would report success and change nothing. ``scale`` is a multiple of
|
||||
the text box and must be >= 1: a box smaller than its text is a stripe
|
||||
through the text, not a mask.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def text_find_replace(
|
||||
self,
|
||||
find: str,
|
||||
replace: str,
|
||||
layer: str | None = None,
|
||||
match_case: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Replace text across the drawing.
|
||||
|
||||
Returns ``{ok, replaced, entities, searched_types, note, dry_run}``.
|
||||
``searched_types`` is reported rather than assumed: "no matches" and
|
||||
"that type was never searched" are different answers, and an editor
|
||||
that silently skips block attributes is the classic version of this
|
||||
bug. DIMENSION text is deliberately out of scope — its ``text`` field
|
||||
holds the ``<>`` override placeholder rather than the measurement, so
|
||||
editing it would either do nothing or break the association.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Reading entities back.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
|
||||
|
||||
class EntityQueryContract(ABC):
|
||||
# ── selection filters (M8 / F1) ──────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def selection_window(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
mode: str = "window",
|
||||
entity_type: str = "",
|
||||
layer: str = "",
|
||||
) -> dict:
|
||||
"""AutoCAD's ssget window/crossing. ``{ok, handles, count, mode}``.
|
||||
|
||||
``window`` returns only entities wholly inside the box; ``crossing``
|
||||
also returns the ones that straddle its edge. Getting those backwards
|
||||
is silent and wrong, so ``mode`` is reported back and defaults to the
|
||||
narrower one. A zero-area box is refused rather than answered with an
|
||||
empty list, which would read as "nothing there".
|
||||
|
||||
Selection is by *drawn* position: an entity in a mirrored frame is
|
||||
found where ``entity_get`` reports it, not where the file stores it.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def selection_polygon(
|
||||
self,
|
||||
points: list[list[float]],
|
||||
mode: str = "window",
|
||||
entity_type: str = "",
|
||||
layer: str = "",
|
||||
) -> dict:
|
||||
"""Window/crossing selection against an arbitrary polygon.
|
||||
|
||||
A polygon is not its bounding box — the difference is the whole point
|
||||
of the tool, so the implementation must test against the shape itself.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def selection_filter(
|
||||
self,
|
||||
entity_type: str = "",
|
||||
layer: str = "",
|
||||
color: int | None = None,
|
||||
linetype: str = "",
|
||||
min_area: float | None = None,
|
||||
) -> dict:
|
||||
"""Property filter. Returns ``{ok, handles, count, filtered_by}``.
|
||||
|
||||
Named parameters rather than a query string, deliberately: ezdxf's
|
||||
query language answers an unknown attribute with an empty result, so a
|
||||
typo is indistinguishable from "no matches". Here a typo is a
|
||||
``TypeError`` at the boundary, and ``filtered_by`` tells the caller
|
||||
which filters actually ran — "I filtered by layer and found none" and
|
||||
"I never filtered by layer" are different answers.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_get(self, handle: str) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_set_properties(
|
||||
self,
|
||||
handle: str,
|
||||
layer: str | None = None,
|
||||
color: int | None = None,
|
||||
linetype: str | None = None,
|
||||
lineweight: float | None = None,
|
||||
visible: bool | None = None,
|
||||
) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_edit_text(
|
||||
self,
|
||||
handle: str,
|
||||
text: str | None = None,
|
||||
height: float | None = None,
|
||||
rotation: float | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Edit an existing TEXT or MTEXT entity in place. Any argument left
|
||||
None is unchanged. Returns the updated EntityInfo. Raises if the handle
|
||||
is not a TEXT/MTEXT entity."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_edit_geometry(
|
||||
self,
|
||||
handle: str,
|
||||
cx: float | None = None,
|
||||
cy: float | None = None,
|
||||
radius: float | None = None,
|
||||
x1: float | None = None,
|
||||
y1: float | None = None,
|
||||
x2: float | None = None,
|
||||
y2: float | None = None,
|
||||
start_angle: float | None = None,
|
||||
end_angle: float | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Edit the defining geometry of an existing entity in place.
|
||||
- CIRCLE: cx / cy / radius
|
||||
- LINE: x1 / y1 (start), x2 / y2 (end)
|
||||
- ARC: cx / cy / radius / start_angle / end_angle (degrees)
|
||||
Any argument left None is unchanged. Raises for unsupported types."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_list(
|
||||
self,
|
||||
type_filter: str | None = None,
|
||||
layer_filter: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[EntityInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_count(
|
||||
self,
|
||||
type_filter: str | None = None,
|
||||
layer_filter: str | None = None,
|
||||
) -> int:
|
||||
"""How many entities ``entity_list`` would match, ignoring limit/offset.
|
||||
|
||||
Exists so a *paged* listing can report an honest total without paying
|
||||
for the rows it is not going to return. ``entity_list`` stops building
|
||||
``EntityInfo`` objects the moment it has ``limit`` of them, so it can
|
||||
never tell a caller whether anything followed the page — and a
|
||||
truncated list read as complete is a correctness bug, not a token one.
|
||||
|
||||
Two obligations, both load-bearing:
|
||||
|
||||
* apply **exactly** the filter semantics of ``entity_list`` (same
|
||||
case-insensitive type/layer comparison), or ``truncated`` would be
|
||||
computed against a different set than the one that was paged;
|
||||
* do not build ``EntityInfo`` rows. A count implemented by listing
|
||||
would cost more than the listing it is meant to make cheap.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Geometric tolerancing.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class GdtContract:
|
||||
# Composed from LINE + TEXT primitives so the exact same frame renders on
|
||||
# both COM and ezdxf (ezdxf's native TOLERANCE entity renders blank through
|
||||
# the matplotlib frontend). Deterministic layout math lives in engineering/
|
||||
# gdt.py; datum letters are tracked so the `gdt` critique focus can verify
|
||||
# every referenced datum is actually established on the part.
|
||||
|
||||
async def _place_boxed_text(
|
||||
self,
|
||||
text: str,
|
||||
cx: float,
|
||||
cy: float,
|
||||
text_h: float,
|
||||
layer: str,
|
||||
) -> str:
|
||||
"""Create a TEXT entity roughly centred on (cx, cy). Returns its handle.
|
||||
entity_create_text has no alignment arg, so approximate the centring with
|
||||
the same width heuristic the title block uses."""
|
||||
approx_w = len(text) * text_h * 0.7
|
||||
t = await self.entity_create_text(
|
||||
text,
|
||||
cx - approx_w / 2.0,
|
||||
cy - text_h / 2.0,
|
||||
height=text_h,
|
||||
layer=layer,
|
||||
)
|
||||
return t.handle
|
||||
|
||||
async def draw_feature_control_frame(
|
||||
self,
|
||||
symbol: str,
|
||||
tolerance: str | float,
|
||||
x: float,
|
||||
y: float,
|
||||
datums: list[str] | None = None,
|
||||
height: float = 5.0,
|
||||
diameter: bool = False,
|
||||
modifier: str | None = None,
|
||||
layer: str | None = None,
|
||||
) -> dict:
|
||||
"""Draw an ISO 1101 feature control frame with bottom-left corner at (x, y).
|
||||
|
||||
symbol: one of the 14 geometric characteristics (straightness, flatness,
|
||||
circularity, cylindricity, profile_line, profile_surface, angularity,
|
||||
perpendicularity, parallelism, position, concentricity, symmetry,
|
||||
circular_runout, total_runout). `datums` is the ordered datum reference
|
||||
list (e.g. ["A", "B"]); `diameter=True` prefixes ⌀ for a cylindrical zone;
|
||||
`modifier` ∈ {M, L, S} appends the material-condition symbol.
|
||||
|
||||
Orientation/location/runout characteristics require at least one datum
|
||||
(raises otherwise). The referenced datums are recorded for the `gdt`
|
||||
critique focus.
|
||||
"""
|
||||
from engineering.gdt import fcf_compartments, fcf_layout
|
||||
|
||||
comps = fcf_compartments(
|
||||
symbol,
|
||||
tolerance,
|
||||
datums,
|
||||
diameter=diameter,
|
||||
modifier=modifier,
|
||||
)
|
||||
layer = layer or self._role_layer("dim")
|
||||
await self._ensure_layer(layer, color=2)
|
||||
lay = fcf_layout(comps, float(x), float(y), float(height))
|
||||
handles: list[str] = []
|
||||
|
||||
box = lay["box"]
|
||||
box_poly = await self.entity_create_polyline(
|
||||
[[bx, by] for bx, by in box],
|
||||
closed=False,
|
||||
layer=layer,
|
||||
)
|
||||
handles.append(box_poly.handle)
|
||||
for (sx, sy), (ex, ey) in lay["dividers"]:
|
||||
ln = await self.entity_create_line(sx, sy, ex, ey, layer=layer)
|
||||
handles.append(ln.handle)
|
||||
th = lay["text_height"]
|
||||
for text, tcx, tcy in lay["labels"]:
|
||||
handles.append(await self._place_boxed_text(text, tcx, tcy, th, layer))
|
||||
|
||||
refs = getattr(self, "_gdt_datums_referenced", None)
|
||||
if refs is None:
|
||||
refs = set()
|
||||
self._gdt_datums_referenced = refs
|
||||
for d in datums or []:
|
||||
d = str(d).strip().upper()
|
||||
if d:
|
||||
refs.add(d)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": symbol,
|
||||
"compartments": comps,
|
||||
"handles": handles,
|
||||
"width": lay["width"],
|
||||
"height": lay["height"],
|
||||
"layer": layer,
|
||||
}
|
||||
|
||||
async def draw_datum_feature(
|
||||
self,
|
||||
letter: str,
|
||||
x: float,
|
||||
y: float,
|
||||
size: float = 5.0,
|
||||
layer: str | None = None,
|
||||
) -> dict:
|
||||
"""Place an ISO 1101 datum feature symbol (filled triangle + boxed letter)
|
||||
with its apex at (x, y). Records the datum letter so a feature control
|
||||
frame that references it passes the `gdt` critique focus."""
|
||||
from engineering.gdt import datum_triangle, fcf_layout
|
||||
|
||||
letter = str(letter).strip().upper()
|
||||
if not letter:
|
||||
raise RuntimeError("draw_datum_feature: a datum letter is required.")
|
||||
layer = layer or self._role_layer("dim")
|
||||
await self._ensure_layer(layer, color=2)
|
||||
size = float(size)
|
||||
handles: list[str] = []
|
||||
|
||||
tri = datum_triangle(float(x), float(y), size, down=True)
|
||||
tri_poly = await self.entity_create_polyline(
|
||||
[[px, py] for px, py in tri],
|
||||
closed=True,
|
||||
layer=layer,
|
||||
)
|
||||
handles.append(tri_poly.handle)
|
||||
|
||||
box_h = size * 1.4
|
||||
lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h)
|
||||
box_poly = await self.entity_create_polyline(
|
||||
[[bx, by] for bx, by in lay["box"]],
|
||||
closed=False,
|
||||
layer=layer,
|
||||
)
|
||||
handles.append(box_poly.handle)
|
||||
for text, tcx, tcy in lay["labels"]:
|
||||
handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer))
|
||||
|
||||
defined = getattr(self, "_gdt_datums_defined", None)
|
||||
if defined is None:
|
||||
defined = set()
|
||||
self._gdt_datums_defined = defined
|
||||
defined.add(letter)
|
||||
|
||||
return {"ok": True, "datum": letter, "handles": handles, "layer": layer}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Connecting and describing the backend.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import CapabilityMap
|
||||
|
||||
|
||||
class IdentityContract(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def capabilities(self) -> CapabilityMap: ...
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Layers.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import LayerInfo
|
||||
|
||||
|
||||
class LayerContract(ABC):
|
||||
@abstractmethod
|
||||
async def layer_list(self) -> list[LayerInfo]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_create(
|
||||
self,
|
||||
name: str,
|
||||
color: int = 7,
|
||||
linetype: str = "Continuous",
|
||||
lineweight: float = -3,
|
||||
) -> LayerInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_delete(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_set_current(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_modify(
|
||||
self,
|
||||
name: str,
|
||||
color: int | None = None,
|
||||
linetype: str | None = None,
|
||||
lineweight: float | None = None,
|
||||
) -> LayerInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_freeze(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_thaw(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_lock(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_unlock(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_hide(self, name: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def layer_show(self, name: str) -> dict: ...
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Layouts, viewports and change-space.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from backends.capability import capability
|
||||
|
||||
|
||||
class LayoutContract(ABC):
|
||||
@abstractmethod
|
||||
async def layout_list(self) -> dict:
|
||||
"""Return {ok, layouts: [names], current: name} incl. 'Model'."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def layout_create(self, name: str) -> dict:
|
||||
"""Create a new paper-space layout."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def layout_set_current(self, name: str) -> dict:
|
||||
"""Activate a layout tab ('Model' or a paper-space layout name)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def layout_delete(self, name: str) -> dict:
|
||||
"""Delete a paper-space layout and everything drawn on it.
|
||||
|
||||
Returns ``{ok, deleted, entities_destroyed, current}``. ``current`` is
|
||||
the space geometry goes into afterwards, which the implementation must
|
||||
keep consistent with ``layout_list()`` — deleting the active tab
|
||||
otherwise leaves the reported current layout, the DXF tile mode and the
|
||||
space entities actually land in disagreeing with each other.
|
||||
|
||||
Refuses ``'Model'``, a blank name (which ezdxf resolves to the *first*
|
||||
paper-space layout), a name that does not exist, and the last remaining
|
||||
paper-space layout.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def layout_rename(self, old_name: str, new_name: str) -> dict:
|
||||
"""Rename a paper-space layout, keeping its entities and their handles.
|
||||
|
||||
Returns ``{ok, old, new, current}``. Refuses ``'Model'`` on either side,
|
||||
a blank or otherwise invalid new name, and a name already in use.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def layout_copy(self, source: str, new_name: str) -> dict:
|
||||
"""Copy a paper-space layout, its page setup and its geometry.
|
||||
|
||||
Returns ``{ok, source, layout, entities_copied, skipped,
|
||||
associativity_remapped, associativity_dropped}``. ``skipped`` names the
|
||||
DXF types that could not be cloned — a copy missing geometry must never
|
||||
report a bare success. Associative boundaries (hatches) are re-pointed
|
||||
at the cloned entities where possible and cleared otherwise, because a
|
||||
boundary still referencing the source layout dangles as soon as that
|
||||
layout is deleted and no auditor reports it.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def viewport_create(
|
||||
self,
|
||||
layout: str,
|
||||
center_x: float,
|
||||
center_y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
view_center_x: float,
|
||||
view_center_y: float,
|
||||
scale: float = 1.0,
|
||||
) -> dict:
|
||||
"""Place a scaled model-space viewport on a paper-space layout.
|
||||
|
||||
``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows
|
||||
the model window centered at (view_center_x, view_center_y) with
|
||||
view height = height / scale.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def viewport_list(self, layout: str | None = None) -> dict:
|
||||
"""List the viewports on one paper-space layout, or on all of them.
|
||||
|
||||
Returns ``{ok, viewports: [...], count}``. Each row carries ``handle``,
|
||||
``layout``, ``center``, ``width``, ``height``, ``view_center``,
|
||||
``view_height``, ``scale``, ``locked``, ``status``, ``id`` and
|
||||
``is_main``.
|
||||
|
||||
The layout's own main viewport is reported with ``is_main: True`` rather
|
||||
than filtered out — it is what remains after deleting every drafting
|
||||
viewport, and a caller who cannot see it cannot explain that. ``scale``
|
||||
and ``locked`` are ``None`` on documents that cannot store them (R12),
|
||||
never a fabricated number. Viewports are addressed by handle; ``id`` is
|
||||
metadata only.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def viewport_set_scale(self, handle: str, scale: float) -> dict:
|
||||
"""Set a viewport's paper:model scale. Returns ``{ok, handle, scale,
|
||||
view_height}`` with ``scale`` read back from the entity.
|
||||
|
||||
Refuses the main viewport (whose view height is the tab's own pan/zoom
|
||||
state, not a drafting scale) and documents that cannot persist it.
|
||||
Geometric scale only: annotative text and dimensions do not resize.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def viewport_lock(self, handle: str, locked: bool = True) -> dict:
|
||||
"""Lock or unlock a viewport's display scale. Returns ``{ok, handle,
|
||||
locked}`` with ``locked`` read back from the entity."""
|
||||
...
|
||||
|
||||
# B027 (empty method, no @abstractmethod) is exactly what this pattern
|
||||
# looks like from the outside: the decorator replaces the `...` body with
|
||||
# the refusing default, so the source really is empty on purpose.
|
||||
@capability( # noqa: B027
|
||||
"chspace",
|
||||
reason=(
|
||||
"ActiveX exposes no change-space member, so a live backend would have to recreate "
|
||||
"geometry through CopyObjects or drive the CHSPACE command blind — neither has been "
|
||||
"verified against a live AutoCAD. Use the headless backend "
|
||||
"(AUTOCAD_MCP_BACKEND=ezdxf), or run CHSPACE in AutoCAD directly."
|
||||
),
|
||||
)
|
||||
async def entity_change_space(
|
||||
self,
|
||||
handles: list[str],
|
||||
viewport_handle: str,
|
||||
direction: str = "to_paper",
|
||||
freeze_dimensions: bool = False,
|
||||
) -> dict:
|
||||
"""Move entities between model space and a sheet, *through* a viewport.
|
||||
|
||||
Returns ``{ok, moved, refused, scale, viewport}``. Each ``moved`` row
|
||||
carries ``handle``, ``from``, ``to``, ``inside_viewport`` and
|
||||
``on_sheet``; each ``refused`` row carries ``handle`` and ``reason``.
|
||||
|
||||
The geometric transform is the whole point: a move without it leaves a
|
||||
100 mm feature as 100 mm of paper inside a 1:2 viewport, produces a file
|
||||
that audits clean, and is indistinguishable from success. Backends that
|
||||
cannot apply the viewport transform must refuse rather than move.
|
||||
|
||||
Refuses per entity for dimensions (unless ``freeze_dimensions`` bakes
|
||||
the pre-transform measurement into the text first), ACIS bodies, tables
|
||||
and proxies, viewports, and entities already in the target space; and
|
||||
refuses the whole call for a twisted or non-top-view viewport.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def viewport_delete(self, handle: str, force: bool = False) -> dict:
|
||||
"""Delete a viewport. Returns ``{ok, handle, layout, was_main}``.
|
||||
|
||||
The layout's main viewport needs ``force=True`` and the implementation
|
||||
must repair the layout's current-viewport pointer afterwards; a dangling
|
||||
pointer is invisible to every headless audit and shows up only in CAD.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Linetypes.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class LinetypeContract(ABC):
|
||||
@abstractmethod
|
||||
async def linetype_list(self) -> list[str]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def linetype_load(
|
||||
self,
|
||||
name: str,
|
||||
file: str | None = None,
|
||||
) -> dict: ...
|
||||
@@ -0,0 +1,763 @@
|
||||
"""Planning, critique, snapping and construction geometry.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
from engineering.plan_spec import (
|
||||
CritiqueFocus,
|
||||
DimStyle,
|
||||
Issue,
|
||||
LayerSetId,
|
||||
PlanSpec,
|
||||
SheetSize,
|
||||
SnapType,
|
||||
)
|
||||
|
||||
|
||||
class PremiumContract(ABC):
|
||||
# These are CONCRETE and backend-agnostic: they orchestrate the abstract
|
||||
# primitives above plus the shared EntityInfo.properties contract (start/
|
||||
# end/center/radius/start_angle/end_angle — populated identically by both
|
||||
# backends), so the ezdxf and COM backends inherit identical premium
|
||||
# behaviour. The only backend-specific primitive is `_create_xline` — XLINE
|
||||
# has no cross-backend equivalent among the standard entity_create_* tools.
|
||||
|
||||
_plan_spec: PlanSpec | None = None
|
||||
_preflight_result: Any = None
|
||||
|
||||
def _reset_document_state(self) -> None:
|
||||
"""Clear metadata that is valid only for the current drawing."""
|
||||
self._plan_spec = None
|
||||
self._preflight_result = None
|
||||
self._gdt_datums_defined = set()
|
||||
self._gdt_datums_referenced = set()
|
||||
|
||||
async def _ensure_document_state(self) -> None:
|
||||
"""Allow live backends to detect an out-of-band document switch."""
|
||||
return None
|
||||
|
||||
async def drawing_preflight(
|
||||
self,
|
||||
intent: str,
|
||||
requirements: dict | None = None,
|
||||
sheet_size: SheetSize = "A3",
|
||||
scale: float = 1.0,
|
||||
layer_set_id: LayerSetId = "mech",
|
||||
view_count: int = 1,
|
||||
dim_style: DimStyle = "chain",
|
||||
allow_assumptions: bool = False,
|
||||
):
|
||||
from engineering.preflight import preflight_drawing
|
||||
|
||||
await self._ensure_document_state()
|
||||
|
||||
result = preflight_drawing(
|
||||
intent,
|
||||
requirements,
|
||||
sheet_size,
|
||||
scale,
|
||||
layer_set_id,
|
||||
view_count,
|
||||
dim_style,
|
||||
allow_assumptions=allow_assumptions,
|
||||
)
|
||||
self._preflight_result = result
|
||||
return result
|
||||
|
||||
async def drawing_plan(
|
||||
self,
|
||||
intent: str,
|
||||
sheet_size: SheetSize = "A3",
|
||||
scale: float = 1.0,
|
||||
layer_set_id: LayerSetId = "mech",
|
||||
view_count: int = 1,
|
||||
dim_style: DimStyle = "chain",
|
||||
notes: list[str] | None = None,
|
||||
requirements: dict | None = None,
|
||||
spec_hash: str | None = None,
|
||||
) -> PlanSpec:
|
||||
"""Commit a drawing intent before any geometry is created.
|
||||
Returns a PlanSpec and stores it on the backend; read it back with
|
||||
`get_plan_spec()`. (N8: the previous claim that `drawing_critique`
|
||||
replays this plan was untrue — critique runs geometric checks, not a
|
||||
plan diff.)"""
|
||||
from engineering.plan_spec import PlanSpec
|
||||
|
||||
await self._ensure_document_state()
|
||||
preflight_status = "skipped"
|
||||
normalized_plan: dict[str, Any] | None = None
|
||||
if spec_hash is not None:
|
||||
from engineering.preflight import preflight_drawing
|
||||
|
||||
preflight = getattr(self, "_preflight_result", None)
|
||||
if preflight is None or preflight.spec_hash != spec_hash:
|
||||
raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.")
|
||||
if not preflight.ready:
|
||||
raise RuntimeError(
|
||||
"drawing_plan: preflight is not ready; resolve questions/conflicts first."
|
||||
)
|
||||
candidate = preflight_drawing(
|
||||
intent,
|
||||
requirements
|
||||
if requirements is not None
|
||||
else preflight.normalized_spec["requirements"],
|
||||
sheet_size,
|
||||
scale,
|
||||
layer_set_id,
|
||||
view_count,
|
||||
dim_style,
|
||||
)
|
||||
if candidate.spec_hash != spec_hash:
|
||||
raise RuntimeError(
|
||||
"drawing_plan: supplied fields do not match the hashed preflight spec."
|
||||
)
|
||||
normalized_plan = preflight.normalized_spec
|
||||
preflight_status = "ready"
|
||||
|
||||
if normalized_plan is not None:
|
||||
intent = normalized_plan["intent"]
|
||||
sheet_size = normalized_plan["sheet_size"]
|
||||
scale = normalized_plan["scale"]
|
||||
layer_set_id = normalized_plan["layer_set_id"]
|
||||
view_count = normalized_plan["view_count"]
|
||||
dim_style = normalized_plan["dim_style"]
|
||||
requirements = normalized_plan["requirements"]
|
||||
|
||||
plan = PlanSpec(
|
||||
intent=str(intent),
|
||||
sheet_size=sheet_size,
|
||||
scale=float(scale),
|
||||
layer_set_id=layer_set_id,
|
||||
view_count=int(view_count),
|
||||
dim_style=dim_style,
|
||||
notes=list(notes) if notes else [],
|
||||
requirements=dict(requirements or {}),
|
||||
spec_hash=spec_hash,
|
||||
preflight_status=preflight_status,
|
||||
)
|
||||
self._plan_spec = plan
|
||||
return plan
|
||||
|
||||
async def drawing_critique(
|
||||
self,
|
||||
focus: list[CritiqueFocus] | None = None,
|
||||
) -> list[Issue]:
|
||||
"""Run premium-quality geometric checks; return zero issues before
|
||||
`drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT
|
||||
replay the committed PlanSpec — it inspects the live drawing geometry.)"""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
await self._ensure_document_state()
|
||||
return await run_critique(self, focus)
|
||||
|
||||
def get_plan_spec(self) -> dict | None:
|
||||
"""Return the PlanSpec committed by `drawing_plan` as a dict, or None
|
||||
if no plan has been committed. (N8: makes the stored `_plan_spec`
|
||||
actually readable instead of being write-only dead state.)"""
|
||||
plan = getattr(self, "_plan_spec", None)
|
||||
return plan.to_dict() if plan is not None else None
|
||||
|
||||
async def point_from_snap(
|
||||
self,
|
||||
handle: str,
|
||||
snap: SnapType,
|
||||
ref_x: float | None = None,
|
||||
ref_y: float | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""Return a deterministic snap point on `handle`.
|
||||
snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`,
|
||||
`ref_x/y` is the reference point. Sources geometry from the shared
|
||||
EntityInfo.properties contract — works on every backend."""
|
||||
ent = await self.entity_get(handle)
|
||||
et = ent.type
|
||||
p = ent.properties
|
||||
ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None
|
||||
|
||||
def _line_pts():
|
||||
s = p.get("start")
|
||||
e = p.get("end")
|
||||
if s is None or e is None:
|
||||
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.")
|
||||
return (float(s[0]), float(s[1])), (float(e[0]), float(e[1]))
|
||||
|
||||
def _circle_geom():
|
||||
c = p.get("center")
|
||||
r = p.get("radius")
|
||||
if c is None or r is None:
|
||||
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.")
|
||||
return float(c[0]), float(c[1]), float(r)
|
||||
|
||||
def _arc_angles():
|
||||
# N7: use .get() + descriptive RuntimeError (matching _line_pts /
|
||||
# _circle_geom) instead of bracket indexing that raises an opaque
|
||||
# KeyError when start_angle/end_angle are missing.
|
||||
sa = p.get("start_angle")
|
||||
ea = p.get("end_angle")
|
||||
if sa is None or ea is None:
|
||||
raise RuntimeError(
|
||||
f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle."
|
||||
)
|
||||
return math.radians(float(sa)), math.radians(float(ea))
|
||||
|
||||
if snap == "end":
|
||||
if et == "LINE":
|
||||
s, e = _line_pts()
|
||||
if ref is None:
|
||||
return s
|
||||
ds = (s[0] - ref[0]) ** 2 + (s[1] - ref[1]) ** 2
|
||||
de = (e[0] - ref[0]) ** 2 + (e[1] - ref[1]) ** 2
|
||||
return s if ds <= de else e
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa, ea = _arc_angles()
|
||||
sp = (cx + r * math.cos(sa), cy + r * math.sin(sa))
|
||||
ep = (cx + r * math.cos(ea), cy + r * math.sin(ea))
|
||||
if ref is None:
|
||||
return sp
|
||||
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
|
||||
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
|
||||
return sp if ds <= de else ep
|
||||
raise RuntimeError(f"point_from_snap: 'end' snap not supported on {et}")
|
||||
|
||||
if snap == "mid":
|
||||
if et == "LINE":
|
||||
s, e = _line_pts()
|
||||
return ((s[0] + e[0]) / 2.0, (s[1] + e[1]) / 2.0)
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa, ea = _arc_angles()
|
||||
if ea < sa:
|
||||
ea += 2 * math.pi
|
||||
ma = (sa + ea) / 2.0
|
||||
return (cx + r * math.cos(ma), cy + r * math.sin(ma))
|
||||
raise RuntimeError(f"point_from_snap: 'mid' snap not supported on {et}")
|
||||
|
||||
if snap == "center":
|
||||
if et in ("CIRCLE", "ARC", "ELLIPSE"):
|
||||
c = p.get("center")
|
||||
if c is None:
|
||||
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.")
|
||||
return (float(c[0]), float(c[1]))
|
||||
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
|
||||
|
||||
# NEW-snap-quad-arc / NEW-snap-near-arc: an ARC only covers part of the
|
||||
# full circle, so quad/near candidates derived from the circle may fall
|
||||
# outside the [start_angle, end_angle] sweep. Restrict (quad) / clamp
|
||||
# (near) the projected angle to the sweep, with 2π wraparound.
|
||||
def _arc_sweep():
|
||||
sa, ea = _arc_angles()
|
||||
if ea < sa:
|
||||
ea += 2 * math.pi
|
||||
return sa, ea
|
||||
|
||||
def _angle_in_sweep(theta: float, sa: float, ea: float) -> bool:
|
||||
# Normalise theta into [sa, sa+2π) and test against [sa, ea].
|
||||
t = sa + ((theta - sa) % (2 * math.pi))
|
||||
return t <= ea + 1e-9
|
||||
|
||||
def _arc_endpoints(cx: float, cy: float, r: float, sa: float, ea: float):
|
||||
return (
|
||||
(cx + r * math.cos(sa), cy + r * math.sin(sa)),
|
||||
(cx + r * math.cos(ea), cy + r * math.sin(ea)),
|
||||
)
|
||||
|
||||
if snap == "quad":
|
||||
if et == "CIRCLE":
|
||||
cx, cy, r = _circle_geom()
|
||||
pts = [(cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r)]
|
||||
if ref is None:
|
||||
return pts[0]
|
||||
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa, ea = _arc_sweep()
|
||||
quad_angles = (0.0, math.pi / 2.0, math.pi, 3 * math.pi / 2.0)
|
||||
pts = [
|
||||
(cx + r * math.cos(a), cy + r * math.sin(a))
|
||||
for a in quad_angles
|
||||
if _angle_in_sweep(a, sa, ea)
|
||||
]
|
||||
if not pts:
|
||||
# No quadrant lies on the sweep — fall back to arc endpoints.
|
||||
pts = list(_arc_endpoints(cx, cy, r, sa, ea))
|
||||
if ref is None:
|
||||
return pts[0]
|
||||
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
|
||||
raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}")
|
||||
|
||||
if snap == "perp":
|
||||
if ref is None:
|
||||
raise RuntimeError("point_from_snap: 'perp' requires ref_x/ref_y.")
|
||||
if et == "LINE":
|
||||
s, e = _line_pts()
|
||||
dx = e[0] - s[0]
|
||||
dy = e[1] - s[1]
|
||||
L2 = dx * dx + dy * dy
|
||||
if L2 < 1e-18:
|
||||
raise RuntimeError("point_from_snap: line has zero length.")
|
||||
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
|
||||
return (s[0] + t * dx, s[1] + t * dy)
|
||||
raise RuntimeError(f"point_from_snap: 'perp' snap not supported on {et}")
|
||||
|
||||
if snap == "near":
|
||||
if ref is None:
|
||||
raise RuntimeError("point_from_snap: 'near' requires ref_x/ref_y.")
|
||||
if et == "LINE":
|
||||
s, e = _line_pts()
|
||||
dx = e[0] - s[0]
|
||||
dy = e[1] - s[1]
|
||||
L2 = dx * dx + dy * dy
|
||||
if L2 < 1e-18:
|
||||
return s
|
||||
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
|
||||
t = max(0.0, min(1.0, t))
|
||||
return (s[0] + t * dx, s[1] + t * dy)
|
||||
if et == "CIRCLE":
|
||||
cx, cy, r = _circle_geom()
|
||||
vx, vy = ref[0] - cx, ref[1] - cy
|
||||
L = math.hypot(vx, vy)
|
||||
if L < 1e-18:
|
||||
return (cx + r, cy)
|
||||
return (cx + r * vx / L, cy + r * vy / L)
|
||||
if et == "ARC":
|
||||
cx, cy, r = _circle_geom()
|
||||
sa, ea = _arc_sweep()
|
||||
vx, vy = ref[0] - cx, ref[1] - cy
|
||||
L = math.hypot(vx, vy)
|
||||
if L < 1e-18:
|
||||
# Degenerate ref at center: return the start endpoint.
|
||||
return (cx + r * math.cos(sa), cy + r * math.sin(sa))
|
||||
theta = math.atan2(vy, vx)
|
||||
if _angle_in_sweep(theta, sa, ea):
|
||||
return (cx + r * math.cos(theta), cy + r * math.sin(theta))
|
||||
# Projected angle is off the sweep — fall back to the nearer endpoint.
|
||||
sp, ep = _arc_endpoints(cx, cy, r, sa, ea)
|
||||
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
|
||||
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
|
||||
return sp if ds <= de else ep
|
||||
raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}")
|
||||
|
||||
if snap == "int":
|
||||
raise RuntimeError(
|
||||
"point_from_snap: 'int' (intersection) requires a 2nd entity; "
|
||||
"compute via two endpoints. V2 will accept a 2nd handle."
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"point_from_snap: unknown snap type '{snap}'. "
|
||||
"Use one of: end, mid, center, quad, perp, near."
|
||||
)
|
||||
|
||||
async def point_intersection(
|
||||
self,
|
||||
handle1: str,
|
||||
handle2: str,
|
||||
ref_x: float | None = None,
|
||||
ref_y: float | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""Return the intersection point of two geometry entities.
|
||||
|
||||
Supports LINE-LINE, LINE-CIRCLE, and CIRCLE-CIRCLE cases. When
|
||||
multiple intersections exist (e.g. two circles), ``ref_x``/``ref_y``
|
||||
selects the candidate nearest to that reference point. If omitted the
|
||||
first candidate is returned.
|
||||
|
||||
Raises RuntimeError when no real intersection exists or entity types
|
||||
are unsupported.
|
||||
"""
|
||||
info1 = await self.entity_get(handle1)
|
||||
info2 = await self.entity_get(handle2)
|
||||
t1, t2 = info1.type.upper(), info2.type.upper()
|
||||
props1, props2 = info1.properties, info2.properties
|
||||
|
||||
candidates: list[tuple[float, float]] = []
|
||||
|
||||
if t1 == "LINE" and t2 == "LINE":
|
||||
# Line-line intersection via parametric form
|
||||
x1, y1 = props1["start"][0], props1["start"][1]
|
||||
x2, y2 = props1["end"][0], props1["end"][1]
|
||||
x3, y3 = props2["start"][0], props2["start"][1]
|
||||
x4, y4 = props2["end"][0], props2["end"][1]
|
||||
dx1, dy1 = x2 - x1, y2 - y1
|
||||
dx2, dy2 = x4 - x3, y4 - y3
|
||||
denom = dx1 * dy2 - dy1 * dx2
|
||||
if abs(denom) < 1e-12:
|
||||
raise RuntimeError("point_intersection: lines are parallel or coincident.")
|
||||
t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom
|
||||
candidates = [(x1 + t * dx1, y1 + t * dy1)]
|
||||
|
||||
elif {t1, t2} == {"LINE", "CIRCLE"}:
|
||||
line_p = props1 if t1 == "LINE" else props2
|
||||
circ_p = props2 if t1 == "LINE" else props1
|
||||
lx1, ly1 = line_p["start"][0], line_p["start"][1]
|
||||
lx2, ly2 = line_p["end"][0], line_p["end"][1]
|
||||
cx, cy = circ_p["center"][0], circ_p["center"][1]
|
||||
r = circ_p["radius"]
|
||||
dx, dy = lx2 - lx1, ly2 - ly1
|
||||
fx, fy = lx1 - cx, ly1 - cy
|
||||
a = dx * dx + dy * dy
|
||||
b = 2.0 * (fx * dx + fy * dy)
|
||||
c = fx * fx + fy * fy - r * r
|
||||
disc = b * b - 4.0 * a * c
|
||||
if disc < 0:
|
||||
raise RuntimeError("point_intersection: line does not intersect circle.")
|
||||
sq = math.sqrt(disc)
|
||||
for sign in (1, -1):
|
||||
t = (-b + sign * sq) / (2.0 * a)
|
||||
candidates.append((lx1 + t * dx, ly1 + t * dy))
|
||||
|
||||
elif t1 == "CIRCLE" and t2 == "CIRCLE":
|
||||
cx1, cy1, r1 = props1["center"][0], props1["center"][1], props1["radius"]
|
||||
cx2, cy2, r2 = props2["center"][0], props2["center"][1], props2["radius"]
|
||||
dx, dy = cx2 - cx1, cy2 - cy1
|
||||
d = math.hypot(dx, dy)
|
||||
if d < 1e-12:
|
||||
raise RuntimeError("point_intersection: circles are concentric.")
|
||||
if d > r1 + r2 + 1e-9 or d < abs(r1 - r2) - 1e-9:
|
||||
raise RuntimeError("point_intersection: circles do not intersect.")
|
||||
a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d)
|
||||
h_sq = r1 * r1 - a * a
|
||||
h = math.sqrt(max(h_sq, 0.0))
|
||||
mx = cx1 + a * dx / d
|
||||
my = cy1 + a * dy / d
|
||||
candidates = [
|
||||
(mx + h * dy / d, my - h * dx / d),
|
||||
(mx - h * dy / d, my + h * dx / d),
|
||||
]
|
||||
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"point_intersection: unsupported entity combination {t1}+{t2}. "
|
||||
"Supported: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE."
|
||||
)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if ref_x is not None and ref_y is not None:
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2,
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
async def point_tangent(
|
||||
self,
|
||||
circle_handle: str,
|
||||
from_x: float,
|
||||
from_y: float,
|
||||
ref_x: float | None = None,
|
||||
ref_y: float | None = None,
|
||||
) -> tuple[float, float]:
|
||||
"""Return the tangent point on a circle from an external point.
|
||||
|
||||
When two tangent points exist, ``ref_x``/``ref_y`` selects the
|
||||
nearer one; without it, the first is returned (counter-clockwise from
|
||||
the from-point—circle-center line).
|
||||
|
||||
Raises RuntimeError if the point is inside the circle.
|
||||
"""
|
||||
info = await self.entity_get(circle_handle)
|
||||
if info.type.upper() != "CIRCLE":
|
||||
raise RuntimeError(
|
||||
f"point_tangent: handle {circle_handle!r} is {info.type}, expected CIRCLE."
|
||||
)
|
||||
cx, cy = info.properties["center"][0], info.properties["center"][1]
|
||||
r = info.properties["radius"]
|
||||
fx, fy = float(from_x), float(from_y)
|
||||
dx, dy = cx - fx, cy - fy
|
||||
d = math.hypot(dx, dy)
|
||||
if d < r - 1e-9:
|
||||
raise RuntimeError(
|
||||
f"point_tangent: from-point ({fx}, {fy}) is inside the circle "
|
||||
f"(center ({cx}, {cy}), radius {r})."
|
||||
)
|
||||
if d < 1e-12:
|
||||
raise RuntimeError("point_tangent: from-point coincides with circle center.")
|
||||
# Tangent point T lies on the circle such that (T-F)⊥(T-C).
|
||||
# Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two
|
||||
# circle angles θ at which the tangent touches the perimeter.
|
||||
alpha = math.atan2(dy, dx) # direction from F to C
|
||||
# NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the
|
||||
# narrow band [r-1e-9, r) (past the internal-point guard but with
|
||||
# |-r/d| marginally > 1 from float error) degenerates gracefully to the
|
||||
# tangent-at-perimeter case instead of raising a raw ValueError
|
||||
# ("math domain error"). Genuine internal points (d < r) are already
|
||||
# rejected by the RuntimeError guard above.
|
||||
acos_neg = math.acos(max(-1.0, min(1.0, -r / d))) # (π/2, π] for external pt
|
||||
candidates: list[tuple[float, float]] = [
|
||||
(cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)),
|
||||
(cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)),
|
||||
]
|
||||
if ref_x is not None and ref_y is not None:
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2,
|
||||
)
|
||||
return candidates[0]
|
||||
|
||||
@abstractmethod
|
||||
async def _create_xline(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
dx: float,
|
||||
dy: float,
|
||||
layer: str,
|
||||
) -> EntityInfo:
|
||||
"""Create an infinite construction line (XLINE) through (x, y) with
|
||||
unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no
|
||||
cross-backend entity_create_* equivalent."""
|
||||
...
|
||||
|
||||
async def _ensure_layer(self, name: str, color: int = 7) -> None:
|
||||
"""Idempotently make sure `name` exists (no-op if already present)."""
|
||||
try:
|
||||
existing = {lyr.name for lyr in await self.layer_list()}
|
||||
except Exception:
|
||||
existing = set()
|
||||
if name not in existing:
|
||||
try:
|
||||
await self.layer_create(name, color=color)
|
||||
except Exception:
|
||||
pass # already created / racy create — harmless
|
||||
|
||||
def _active_layer_set_id(self) -> str:
|
||||
"""Best-effort active layer set: an explicit `drawing_apply_iso_layers`
|
||||
wins, else the committed PlanSpec, else 'mech' (the default bootstrap)."""
|
||||
explicit = getattr(self, "_active_layer_set", None)
|
||||
if explicit:
|
||||
return explicit
|
||||
plan = getattr(self, "_plan_spec", None)
|
||||
if plan is not None:
|
||||
return getattr(plan, "layer_set_id", "mech")
|
||||
return "mech"
|
||||
|
||||
def _role_layer(self, role: str) -> str:
|
||||
"""Resolve the layer name for a role ('dim' | 'construction') in the
|
||||
active layer set, so dims/scaffolding land on the right layer for
|
||||
iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION)."""
|
||||
from engineering.layers import resolve_role_layer
|
||||
|
||||
return resolve_role_layer(self._active_layer_set_id(), role)
|
||||
|
||||
async def construction_xline(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
angle_deg: float,
|
||||
layer: str | None = None,
|
||||
) -> EntityInfo:
|
||||
"""Create an infinite construction line (XLINE) on the active layer set's
|
||||
construction layer (auto-created with color 250 = lightest if missing)."""
|
||||
layer = layer or self._role_layer("construction")
|
||||
await self._ensure_layer(layer, color=250)
|
||||
ang = math.radians(float(angle_deg))
|
||||
return await self._create_xline(
|
||||
float(x),
|
||||
float(y),
|
||||
math.cos(ang),
|
||||
math.sin(ang),
|
||||
layer,
|
||||
)
|
||||
|
||||
async def construction_clear(
|
||||
self,
|
||||
layer: str | None = None,
|
||||
) -> dict:
|
||||
"""Delete every entity on the active layer set's construction layer.
|
||||
Must be called before `drawing_finalize`."""
|
||||
layer = layer or self._role_layer("construction")
|
||||
try:
|
||||
ents = await self.entity_list(layer_filter=layer, limit=5000)
|
||||
except Exception:
|
||||
ents = []
|
||||
deleted = 0
|
||||
for ent in ents:
|
||||
try:
|
||||
await self.entity_delete(ent.handle)
|
||||
deleted += 1
|
||||
except Exception:
|
||||
continue
|
||||
return {"ok": True, "layer": layer, "deleted": deleted}
|
||||
|
||||
async def drawing_apply_iso_layers(
|
||||
self,
|
||||
standard: LayerSetId = "mech",
|
||||
) -> dict:
|
||||
"""Bootstrap a full ISO-conformant layer set with correct colors
|
||||
and lineweights. Idempotent."""
|
||||
from engineering.layers import apply_layer_set
|
||||
|
||||
result = await apply_layer_set(self, standard)
|
||||
self._active_layer_set = standard
|
||||
return {"ok": True, "standard": standard, "layers": result}
|
||||
|
||||
async def dimension_auto(
|
||||
self,
|
||||
handles: list[str],
|
||||
style: DimStyle = "chain",
|
||||
offset: float = 10.0,
|
||||
layer: str | None = None,
|
||||
) -> list[EntityInfo]:
|
||||
"""Generate ISO 129 dimensions across `handles` in the chosen style
|
||||
(chain | baseline | ordinate). `offset` is the dimension-line
|
||||
distance from the reference geometry (mm). `layer` defaults to the
|
||||
active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567)."""
|
||||
if not handles:
|
||||
return []
|
||||
dim_layer = layer or self._role_layer("dim")
|
||||
if style not in ("chain", "baseline", "ordinate"):
|
||||
raise RuntimeError(
|
||||
f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'."
|
||||
)
|
||||
segs: list[tuple[float, float, float, float]] = []
|
||||
for h in handles:
|
||||
ent = await self.entity_get(h)
|
||||
if ent.type != "LINE":
|
||||
raise RuntimeError(
|
||||
f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})."
|
||||
)
|
||||
s = ent.properties.get("start")
|
||||
e = ent.properties.get("end")
|
||||
if s is None or e is None:
|
||||
raise RuntimeError(f"dimension_auto: line {h} missing start/end.")
|
||||
segs.append((float(s[0]), float(s[1]), float(e[0]), float(e[1])))
|
||||
|
||||
off = float(offset)
|
||||
results: list[EntityInfo] = []
|
||||
|
||||
if style == "chain":
|
||||
# Each line gets its own linear (rotated) dimension, offset clear of
|
||||
# the geometry along its own perpendicular.
|
||||
for sx, sy, ex, ey in segs:
|
||||
mx = (sx + ex) / 2.0
|
||||
my = (sy + ey) / 2.0
|
||||
dx, dy = ex - sx, ey - sy
|
||||
L = math.hypot(dx, dy) or 1.0
|
||||
nx, ny = -dy / L, dx / L
|
||||
dim = await self.dimension_linear(
|
||||
sx,
|
||||
sy,
|
||||
ex,
|
||||
ey,
|
||||
mx + nx * off,
|
||||
my + ny * off,
|
||||
rotation=math.degrees(math.atan2(dy, dx)),
|
||||
layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
|
||||
elif style == "baseline":
|
||||
sx0, sy0, ex0, ey0 = segs[0]
|
||||
dx, dy = ex0 - sx0, ey0 - sy0
|
||||
L0 = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / L0, dy / L0
|
||||
base_rot = math.degrees(math.atan2(dy, dx))
|
||||
for i, (_sx, _sy, ex, ey) in enumerate(segs):
|
||||
dim = await self.dimension_linear(
|
||||
sx0,
|
||||
sy0,
|
||||
ex,
|
||||
ey,
|
||||
sx0 - uy * (off + i * off),
|
||||
sy0 + ux * (off + i * off),
|
||||
# Dimension line must run parallel to the (possibly rotated)
|
||||
# baseline, not horizontal.
|
||||
rotation=base_rot,
|
||||
layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
|
||||
else: # ordinate — stacked X/Y distances; each feature offset so dims don't overlap
|
||||
for i, (sx, sy, ex, ey) in enumerate(segs):
|
||||
rung = off + i * off # stagger per feature to avoid coincident dims
|
||||
if abs(ex - sx) > 1e-9:
|
||||
dim = await self.dimension_linear(
|
||||
sx,
|
||||
sy,
|
||||
ex,
|
||||
sy,
|
||||
sx + (ex - sx) / 2.0,
|
||||
max(sy, ey) + rung,
|
||||
layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
if abs(ey - sy) > 1e-9:
|
||||
dim = await self.dimension_linear(
|
||||
ex,
|
||||
sy,
|
||||
ex,
|
||||
ey,
|
||||
max(sx, ex) + rung,
|
||||
(sy + ey) / 2.0,
|
||||
rotation=90.0,
|
||||
layer=dim_layer,
|
||||
)
|
||||
results.append(dim)
|
||||
|
||||
return results
|
||||
|
||||
async def entity_select_smart(
|
||||
self,
|
||||
predicate: dict,
|
||||
) -> list[EntityInfo]:
|
||||
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
|
||||
- type: "LINE" | "CIRCLE" | ...
|
||||
- layer: layer name
|
||||
- near: [x, y, radius] — entity reference point within circle
|
||||
- length_range: [min, max] — applies to LINE/ARC
|
||||
- color: ACI int
|
||||
"""
|
||||
if not isinstance(predicate, dict):
|
||||
raise RuntimeError("entity_select_smart: predicate must be a dict.")
|
||||
ptype = predicate.get("type")
|
||||
player = predicate.get("layer")
|
||||
pnear = predicate.get("near") # [x, y, radius]
|
||||
plen = predicate.get("length_range") # [min, max]
|
||||
pcolor = predicate.get("color")
|
||||
|
||||
ents = await self.entity_list(
|
||||
type_filter=ptype.upper() if isinstance(ptype, str) else None,
|
||||
layer_filter=player if isinstance(player, str) else None,
|
||||
limit=5000,
|
||||
)
|
||||
|
||||
def _ok(e: EntityInfo) -> bool:
|
||||
if pcolor is not None and int(e.color) != int(pcolor):
|
||||
return False
|
||||
if pnear is not None:
|
||||
try:
|
||||
nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2])
|
||||
except Exception:
|
||||
return False
|
||||
pt = (
|
||||
e.properties.get("start")
|
||||
or e.properties.get("center")
|
||||
or e.properties.get("insertion")
|
||||
or e.properties.get("insertion_point")
|
||||
)
|
||||
if pt is None:
|
||||
return False
|
||||
if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr**2:
|
||||
return False
|
||||
if plen is not None and e.type in ("LINE", "ARC"):
|
||||
try:
|
||||
lmin, lmax = float(plen[0]), float(plen[1])
|
||||
except Exception:
|
||||
return False
|
||||
length = e.properties.get("length")
|
||||
if length is None or not (lmin <= float(length) <= lmax):
|
||||
return False
|
||||
return True
|
||||
|
||||
return [e for e in ents if _ok(e)]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Friendly facade over system variables.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SettingsContract:
|
||||
# A user-facing wrapper over system_get_variable / system_set_variable that
|
||||
# maps memorable names ("units", "dimscale", …) to AutoCAD system variables.
|
||||
# Concrete + backend-agnostic: both engines accept the bare sysvar name, so
|
||||
# the same call reads/writes on live COM and headless ezdxf alike.
|
||||
|
||||
async def drawing_settings(self, settings: dict | None = None) -> dict:
|
||||
"""Read (no args) or apply (with args) common drawing settings.
|
||||
|
||||
With ``settings=None`` returns a snapshot of every known setting. With a
|
||||
dict, applies each provided key and returns ``{applied, errors, current}``.
|
||||
Friendly keys: units, linear_precision, angular_precision, ltscale,
|
||||
dimscale, text_size, point_mode, point_size, osmode, fillet_radius.
|
||||
`units` accepts mm/cm/m/inch/feet (mapped to the INSUNITS code)."""
|
||||
if not settings:
|
||||
snapshot: dict = {}
|
||||
for key, (var, kind) in _SETTING_MAP.items():
|
||||
try:
|
||||
raw = await self.system_get_variable(var)
|
||||
except Exception as exc:
|
||||
snapshot[key] = {"error": str(exc)}
|
||||
continue
|
||||
snapshot[key] = _decode_setting(key, kind, raw)
|
||||
return {"ok": True, "settings": snapshot}
|
||||
|
||||
applied: dict = {}
|
||||
errors: dict = {}
|
||||
for key, value in settings.items():
|
||||
spec = _SETTING_MAP.get(key)
|
||||
if spec is None:
|
||||
errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
|
||||
continue
|
||||
var, kind = spec
|
||||
try:
|
||||
encoded = _encode_setting(key, kind, value)
|
||||
await self.system_set_variable(var, encoded)
|
||||
applied[key] = value
|
||||
except Exception as exc:
|
||||
errors[key] = str(exc)
|
||||
|
||||
result = {"ok": not errors, "applied": applied}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Friendly setting name -> (AutoCAD system variable, value kind).
|
||||
_SETTING_MAP: dict[str, tuple[str, str]] = {
|
||||
"units": ("INSUNITS", "units"),
|
||||
"linear_precision": ("LUPREC", "int"),
|
||||
"angular_precision": ("AUPREC", "int"),
|
||||
"ltscale": ("LTSCALE", "float"),
|
||||
"dimscale": ("DIMSCALE", "float"),
|
||||
"text_size": ("TEXTSIZE", "float"),
|
||||
"point_mode": ("PDMODE", "int"),
|
||||
"point_size": ("PDSIZE", "float"),
|
||||
"osmode": ("OSMODE", "int"),
|
||||
"fillet_radius": ("FILLETRAD", "float"),
|
||||
}
|
||||
|
||||
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
|
||||
_UNIT_TO_CODE: dict[str, int] = {
|
||||
"unitless": 0,
|
||||
"inch": 1,
|
||||
"inches": 1,
|
||||
"in": 1,
|
||||
"feet": 2,
|
||||
"ft": 2,
|
||||
"foot": 2,
|
||||
"mm": 4,
|
||||
"millimeter": 4,
|
||||
"millimeters": 4,
|
||||
"cm": 5,
|
||||
"centimeter": 5,
|
||||
"centimeters": 5,
|
||||
"m": 6,
|
||||
"meter": 6,
|
||||
"meters": 6,
|
||||
}
|
||||
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
|
||||
|
||||
|
||||
def _encode_setting(key: str, kind: str, value: Any) -> Any:
|
||||
"""Coerce a friendly value into the raw system-variable value."""
|
||||
if kind == "units":
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return int(value)
|
||||
code = _UNIT_TO_CODE.get(str(value).strip().lower())
|
||||
if code is None:
|
||||
raise ValueError(
|
||||
f"units: unknown value {value!r}. Use one of "
|
||||
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
|
||||
)
|
||||
return code
|
||||
if kind == "int":
|
||||
return int(float(value))
|
||||
if kind == "float":
|
||||
return float(value)
|
||||
return value
|
||||
|
||||
|
||||
def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
||||
"""Present a raw system-variable value in friendly form."""
|
||||
if raw is None:
|
||||
return None
|
||||
if kind == "units":
|
||||
try:
|
||||
code = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
|
||||
if kind == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
if kind == "float":
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return raw
|
||||
@@ -0,0 +1,79 @@
|
||||
"""ACIS solids (live AutoCAD only).
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class SolidContract(ABC):
|
||||
@abstractmethod
|
||||
async def solid_box(
|
||||
self, cx: float, cy: float, cz: float, length: float, width: float, height: float
|
||||
) -> dict:
|
||||
"""Create a 3D solid box centered at (cx, cy, cz)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def solid_cylinder(
|
||||
self, cx: float, cy: float, cz: float, radius: float, height: float
|
||||
) -> dict:
|
||||
"""Create a 3D solid cylinder centered at (cx, cy, cz)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def solid_extrude(
|
||||
self, profile_handle: str, height: float, taper_angle: float = 0.0
|
||||
) -> dict:
|
||||
"""Extrude a closed profile (circle/closed polyline) into a solid."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def solid_revolve(
|
||||
self,
|
||||
profile_handle: str,
|
||||
axis_x1: float,
|
||||
axis_y1: float,
|
||||
axis_x2: float,
|
||||
axis_y2: float,
|
||||
angle: float = 360.0,
|
||||
) -> dict:
|
||||
"""Revolve a closed profile around an axis into a solid."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict:
|
||||
"""Boolean two solids: operation is union | subtract | intersect."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_audit(self) -> dict:
|
||||
"""Audit the drawing, applying every repair the backend can make.
|
||||
|
||||
This MUTATES the drawing. Returns ``{"ok", "repaired", "fixes",
|
||||
"fix_count", "errors", "error_count"}``, where ``fixes`` are problems
|
||||
already repaired and ``errors`` are problems that could not be; both are
|
||||
lists of ``{"code": int, "name": str, "message": str, "handle": str |
|
||||
None}``.
|
||||
|
||||
A backend that repairs but cannot observe its own result reports
|
||||
``repaired``/``fix_count``/``error_count`` as ``None`` — never ``0``,
|
||||
which would claim nothing was wrong — plus ``"detail": "unavailable"``
|
||||
and ``"capability": "audit_detail"`` so a client can pre-check the
|
||||
boundary. Any implementation that repairs MUST mark the document dirty,
|
||||
or the repair is discarded the next time the document is closed.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_close(self, save: bool = True) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_undo(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def drawing_redo(self) -> dict: ...
|
||||
@@ -0,0 +1,28 @@
|
||||
"""System variables and raw commands.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SystemContract(ABC):
|
||||
@abstractmethod
|
||||
async def system_status(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def system_get_variable(self, name: str) -> Any: ...
|
||||
|
||||
@abstractmethod
|
||||
async def system_set_variable(self, name: str, value: Any) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def system_run_command(self, command: str) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def system_run_lisp(self, expression: str) -> dict: ...
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Transactions.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class TransactionContract(ABC):
|
||||
@abstractmethod
|
||||
async def transaction_begin(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def transaction_commit(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def transaction_rollback(self) -> dict: ...
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Views and screenshots.
|
||||
|
||||
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class ViewContract(ABC):
|
||||
@abstractmethod
|
||||
async def view_zoom_extents(self) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def view_zoom_window(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
async def view_screenshot(self, overlay_handles: bool = False) -> bytes | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def view_screenshot_grounded(
|
||||
self,
|
||||
overlay_handles: bool = True,
|
||||
max_labels: int = 40,
|
||||
) -> dict:
|
||||
"""Screenshot plus the handle-to-position mapping it drew.
|
||||
|
||||
Returns ``{"png", "labels", "labelled", "total", "truncated"}``.
|
||||
``labels`` maps handle -> ``[x, y]`` in drawing units, so the picture and
|
||||
the handle space a caller can act on are the same thing. ``truncated``
|
||||
says a crowded drawing was capped rather than letting a labelled subset
|
||||
read as the whole sheet.
|
||||
"""
|
||||
...
|
||||
+2604
-85
File diff suppressed because it is too large
Load Diff
+100
@@ -0,0 +1,100 @@
|
||||
"""OCS (Object Coordinate System) to WCS translation, shared by both backends.
|
||||
|
||||
DXF does not store 2D geometry in world coordinates. CIRCLE, ARC, LWPOLYLINE,
|
||||
TEXT, INSERT and a dimension's text position live in a per-entity frame derived
|
||||
from the entity's ``extrusion`` vector by the Arbitrary Axis Algorithm. Mirroring
|
||||
flips extrusion to ``(0, 0, -1)`` and leaves the stored x *unnegated*, so a raw
|
||||
``dxf.center`` read reports a point the entity is not at — by 60 mm in the
|
||||
measured case, while the same response's bounding box (which ezdxf computes
|
||||
through the renderer, in WCS) said otherwise.
|
||||
|
||||
The rule this module exists to enforce: **everything crossing the MCP boundary is
|
||||
WCS.** OCS never leaves the backends.
|
||||
|
||||
Both engines share this. ``ezdxf`` is a core dependency, not an extra, so the COM
|
||||
backend may import it too — and should, rather than hand-rolling the Arbitrary
|
||||
Axis Algorithm a second time. The functions take a raw extrusion/normal triple
|
||||
rather than an entity, because a COM object exposes ``.Normal`` where an ezdxf
|
||||
one exposes ``.dxf.extrusion``.
|
||||
|
||||
Not every entity that *carries* extrusion is affected: ezdxf makes ``ocs()`` a
|
||||
pass-through for LINE, MTEXT, ELLIPSE, SPLINE and POINT, which are WCS-native.
|
||||
Keying any fix off "has an extrusion attribute" breaks those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from ezdxf.math import OCS, Vec3
|
||||
|
||||
_IDENTITY = (0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def is_wcs_frame(extrusion) -> bool:
|
||||
"""True when the entity's frame is the identity — nothing to translate."""
|
||||
try:
|
||||
return OCS(Vec3(extrusion)).uz.isclose(_IDENTITY)
|
||||
except Exception:
|
||||
return True # an unreadable extrusion is not evidence of a rotated frame
|
||||
|
||||
|
||||
def is_flat_frame(extrusion) -> bool:
|
||||
"""True when the entity's plane is parallel to WCS XY (extrusion is +/-Z).
|
||||
|
||||
Only these frames admit an exact 2D write: given a tilted plane, an ``(x, y)``
|
||||
pair does not determine a point in it, so writing one would displace the
|
||||
entity out of its own plane by an amount nobody supplied.
|
||||
"""
|
||||
try:
|
||||
uz = OCS(Vec3(extrusion)).uz
|
||||
except Exception:
|
||||
return True
|
||||
return abs(uz.x) < 1e-12 and abs(uz.y) < 1e-12
|
||||
|
||||
|
||||
def to_wcs_2d(extrusion, x, y, z=0.0) -> list[float]:
|
||||
"""OCS ``(x, y, z)`` to WCS ``[x, y]``.
|
||||
|
||||
``z`` is ``dxf.elevation`` for an LWPOLYLINE (whose vertices carry no z of
|
||||
their own) and the point's own z otherwise.
|
||||
"""
|
||||
v = OCS(Vec3(extrusion)).to_wcs(Vec3(float(x), float(y), float(z)))
|
||||
return [float(v.x), float(v.y)]
|
||||
|
||||
|
||||
def from_wcs(extrusion, x, y, z=0.0) -> tuple[float, float, float]:
|
||||
"""WCS ``(x, y, z)`` to OCS ``(x, y, z)`` — the inverse of :func:`to_wcs_2d`.
|
||||
|
||||
Needed on every write path. The read and write halves used to be *equally*
|
||||
wrong, which made a read-then-write round trip an accidental no-op; fixing
|
||||
reads alone would have converted that no-op into a real move.
|
||||
"""
|
||||
v = OCS(Vec3(extrusion)).from_wcs(Vec3(float(x), float(y), float(z)))
|
||||
return (float(v.x), float(v.y), float(v.z))
|
||||
|
||||
|
||||
def wcs_arc_angles(extrusion, center_ocs, radius, start_deg, end_deg) -> tuple[float, float]:
|
||||
"""OCS sweep angles to CCW-in-WCS angles about the WCS centre.
|
||||
|
||||
Computed by mapping the two endpoints through the frame rather than by
|
||||
arithmetic on the angles, so it stays right for any frame. A left-handed
|
||||
frame (``uz.z < 0``, which is what a mirror produces) reverses the direction
|
||||
of the sweep, so the endpoints swap roles.
|
||||
"""
|
||||
ocs = OCS(Vec3(extrusion))
|
||||
c0 = Vec3(center_ocs)
|
||||
centre = ocs.to_wcs(c0)
|
||||
angles = []
|
||||
for angle in (float(start_deg), float(end_deg)):
|
||||
point = ocs.to_wcs(Vec3.from_deg_angle(angle, abs(float(radius))) + c0)
|
||||
angles.append(math.degrees(math.atan2(point.y - centre.y, point.x - centre.x)) % 360.0)
|
||||
if ocs.uz.z < 0:
|
||||
angles.reverse()
|
||||
return angles[0], angles[1]
|
||||
|
||||
|
||||
def plane_normal(extrusion) -> list[float]:
|
||||
"""The entity's plane normal, for reporting a frame that xy cannot express."""
|
||||
uz = OCS(Vec3(extrusion)).uz
|
||||
return [float(uz.x), float(uz.y), float(uz.z)]
|
||||
+123
-30
@@ -11,7 +11,7 @@ This repository publishes two deliberately separate forms of evidence:
|
||||
delivery, backend reach, engineering production, tests, and security. The
|
||||
dated data lives in [`source_review.json`](source_review.json), and the README
|
||||
graphic is generated—not hand-edited—from that file.
|
||||
2. **Fixed-task runtime benchmark.** Adapters execute the same ten tasks and
|
||||
2. **Fixed-task runtime benchmark.** Adapters execute the same task matrix and
|
||||
return `pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`.
|
||||
AutoCAD MCP Pro currently has the reference adapter; other projects do not
|
||||
receive runtime scores until an adapter actually runs their public interface.
|
||||
@@ -26,38 +26,80 @@ python -m benchmarks.render_chart
|
||||
The source-review chart is not presented as a shared live AutoCAD run. Review
|
||||
dates, evidence grades, project URLs, and the boundary are stored with the data.
|
||||
|
||||
## Fixed-task runtime runner v2
|
||||
## Fixed-task runtime runner
|
||||
|
||||
The v2 runner separates runtime evidence from the source-review rubric. Every
|
||||
adapter receives the same ten tasks and produces the same closed result enum:
|
||||
The runner separates runtime evidence from the source-review rubric. Every
|
||||
adapter receives the same tasks and produces the same closed result enum:
|
||||
`pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`. A timeout is
|
||||
reported per task. Execution is in-process, so adapters that delegate blocking
|
||||
work must provide cancellation-safe task implementations or be wrapped by an
|
||||
external process supervisor. Reports include commit SHA, Python/platform
|
||||
details, backend capability claims, durations, coverage, and hashes for returned
|
||||
artifacts. Unsupported tasks remain in the fixed-matrix denominator with score
|
||||
zero, preventing partial implementations from receiving an inflated score.
|
||||
reported per task, and a capability refusal is recorded `unsupported` rather
|
||||
than `fail` — "cannot reach this" and "got this wrong" are different findings.
|
||||
Execution is in-process, so adapters that delegate blocking work must provide
|
||||
cancellation-safe task implementations or be wrapped by an external process
|
||||
supervisor. Reports include commit SHA, Python/platform details, backend
|
||||
capability claims, durations, coverage, and hashes for returned artifacts.
|
||||
Unsupported tasks remain in the fixed-matrix denominator with score zero,
|
||||
preventing partial implementations from receiving an inflated score.
|
||||
|
||||
```bash
|
||||
python -m benchmarks.run_competitors --list
|
||||
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json
|
||||
python -m benchmarks.run_competitors --task table_mleader --task auditable_delivery --json
|
||||
python -m benchmarks.run_competitors --list --matrix v3
|
||||
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v3 --json
|
||||
python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json
|
||||
```
|
||||
|
||||
The release-machine ezdxf self-check is **10/10 (100.0)**. Repository stars and
|
||||
raw tool counts do not contribute to the score. Adapter registration lives in
|
||||
`competitors.yaml`.
|
||||
The release-machine ezdxf self-check is **15/15 (100.0)** on the v3 matrix.
|
||||
Repository stars and raw tool counts do not contribute to the score. Adapter
|
||||
registration lives in `competitors.yaml`.
|
||||
|
||||
### Matrix v3 (v1.5.0) — five tasks that can fail
|
||||
|
||||
v2 scored this server 10/10, and that number carried no information: every task
|
||||
in it exercised something the server was designed around. `tasks_v3.py` keeps
|
||||
the v2 ten unchanged — their ids and weights key the published v1.4 reports —
|
||||
and adds five chosen because they *can* fail. Three of them did, while being
|
||||
written:
|
||||
|
||||
| Task | Category | Verified against |
|
||||
|---|---|---|
|
||||
| `tool_discovery` | discovery | six AutoCAD command names, each ranking #1 of 154 tools |
|
||||
| `token_budget` | efficiency | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
|
||||
| `hatch_islands` | hatch | 300 filled with the island, 400 ignoring it |
|
||||
| `selection_filter` | selection | window 1, crossing 2, bounding box 3, polygon 1 |
|
||||
| `measure_from_handle` | measurement | 139.2699 against the 100.0 a vertex shoelace gives |
|
||||
|
||||
Token counts come from the offline ratio estimator and the report labels them
|
||||
`ratio/v1 (estimate)`; `token_suite.py --tokenizer anthropic` counts for real.
|
||||
|
||||
**Two renames from the release plan, and they are the point.**
|
||||
`tool_discovery_bilingual` → `tool_discovery`: a Turkish query normalizer was
|
||||
written and then removed, because this repository is English throughout and the
|
||||
Turkish was inferred from the author's chat language rather than required by the
|
||||
product. `measurement_massprops` → `measure_from_handle`: no centroid or
|
||||
moment-of-inertia tool ships.
|
||||
|
||||
**The competitor reports are not re-scored.** Those servers are pinned at v1.4
|
||||
commits and were run against the v2 matrix; they have never been asked these
|
||||
five questions. Their scores stand over the ten they ran, and the matrix chart
|
||||
leaves the five new rows blank (`not run`) instead of scoring them zero — a
|
||||
zero we invented would be indistinguishable from a zero we measured.
|
||||
|
||||
`--matrix v2` stays available so a v1.4 report can be reproduced rather than
|
||||
only described, and `--publish` writes the artifact-path-free file that
|
||||
`results/published/` holds.
|
||||
|
||||
## Live competitor lane (v1.4)
|
||||
|
||||
Two competitor adapters now execute the exact same task matrix, black-box over
|
||||
MCP stdio, against commits pinned in `competitors.yaml`:
|
||||
|
||||
| Server | Pinned | Score | Pass | Coverage |
|
||||
|---|---|---:|---:|---:|
|
||||
| autocad-mcp-pro (reference) | working tree | 100.0 | 10/10 | 100% |
|
||||
| beiming183-cloud/AutoCAD-MCP | `11f7c47e` | 50.0 | 5/10 | 50% |
|
||||
| puran-water/autocad-mcp | `95476a33` | 45.0 | 4/10 | 50% |
|
||||
| Server | Matrix | Pinned | Score | Pass | Coverage |
|
||||
|---|---|---|---:|---:|---:|
|
||||
| autocad-mcp-pro (reference) | v3 (15) | working tree | 100.0 | 15/15 | 100% |
|
||||
| beiming183-cloud/AutoCAD-MCP | v2 (10) | `11f7c47e` | 50.0 | 5/10 | 50% |
|
||||
| puran-water/autocad-mcp | v2 (10) | `95476a33` | 45.0 | 4/10 | 50% |
|
||||
|
||||
The competitor rows are the v1.4 runs, unchanged. Read the matrix column
|
||||
before the score column: the denominators differ because the five v3 tasks
|
||||
were added after those runs and have not been put to them.
|
||||
|
||||
Method and boundaries:
|
||||
|
||||
@@ -84,12 +126,16 @@ live under [`results/published/`](results/published/); the README charts are
|
||||
regenerated from them:
|
||||
|
||||
```bash
|
||||
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
|
||||
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf --matrix v2
|
||||
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf --matrix v2
|
||||
python -m benchmarks.render_live_chart # score bars
|
||||
python -m benchmarks.render_matrix_chart # tasks x servers status heatmap
|
||||
```
|
||||
|
||||
`--publish <path>` writes the same report with artifact paths reduced to
|
||||
filenames, which is how `results/published/` is produced; the sha256 stays,
|
||||
because that is what makes an artifact checkable.
|
||||
|
||||
## Headless performance lane
|
||||
|
||||
`perf_suite.py` measures wall time on fixed workloads through the same backend
|
||||
@@ -107,6 +153,36 @@ python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.js
|
||||
python -m benchmarks.render_perf_chart
|
||||
```
|
||||
|
||||
### v1.4.0 → v1.5.0, same interpreter
|
||||
|
||||
The published v1.4 report was recorded on CPython 3.14 and the v1.5 one on
|
||||
3.11.15, so comparing the two files directly shows an apparent 2–6× speedup
|
||||
that is almost entirely the interpreter. The honest comparison re-runs v1.4.0
|
||||
in a `git worktree` with the *current* suite file — same instrument, different
|
||||
code under test, one interpreter. Median of three runs each:
|
||||
|
||||
| Workload | v1.4.0 | v1.5.0 | Change |
|
||||
|---|---:|---:|---|
|
||||
| `create_lines_2k` | 245 ms | 315 ms | **28% slower** |
|
||||
| `roundtrip_10k` | 1,765 ms | 2,146 ms | **22% slower** |
|
||||
| `region_query_10k` | 1,090 ms | 1,039 ms | within spread |
|
||||
| `premium_pass` | 375 ms | 44 ms | faster; the v1.4 spread (279–772 ms) is too wide to quantify |
|
||||
|
||||
Attributed rather than reported: `EZDXF_CALL_TIMEOUT=0` puts v1.5.0 back to
|
||||
244.8 ms and 1,679 ms, which are v1.4.0's numbers. The entire cost is the
|
||||
per-call `asyncio.wait_for` wrapped around every headless call in this release,
|
||||
so a single hung ezdxf call can no longer wedge a server whose document lock is
|
||||
one `asyncio.Lock`. Reproduce:
|
||||
|
||||
```bash
|
||||
git worktree add /tmp/wt-v140 v1.4.0
|
||||
cp benchmarks/perf_suite.py /tmp/wt-v140/benchmarks/
|
||||
(cd /tmp/wt-v140 && PYTHONPATH=. python benchmarks/perf_suite.py --out /tmp/v140.json)
|
||||
python -m benchmarks.perf_suite --out /tmp/v150.json
|
||||
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/v150-notimeout.json
|
||||
git worktree remove --force /tmp/wt-v140
|
||||
```
|
||||
|
||||
## Correctness A/B suite
|
||||
|
||||
`correctness_suite.py` is a set of deterministic, headless (ezdxf-backend) checks
|
||||
@@ -123,19 +199,36 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
|
||||
python benchmarks/compare_versions.py --json results.json
|
||||
```
|
||||
|
||||
### Result — v1.4.0 vs v1.3.0 (release gate)
|
||||
### Result — v1.5.0 vs v1.4.0 (release gate)
|
||||
|
||||
21 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
24 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
[`results/published/ab-v1.4.0-vs-v1.5.0.json`](results/published/ab-v1.4.0-vs-v1.5.0.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.4.0** (baseline) | 21 / 24 | 87.5 % | — | — |
|
||||
| **v1.5.0** (this release) | 24 / 24 | 100 % | 3 | **0** |
|
||||
|
||||
Read the three "fixed" carefully: they are **new capability, not repaired
|
||||
regressions**. v1.4.0 has no `entity_measure` and no boundary tracing, so it
|
||||
misses `hatch_area_subtracts_its_island`, `boundary_area_agrees_with_measure`
|
||||
and `two_vertex_circle_has_area` by not having the method rather than by
|
||||
getting it wrong. They are in the suite because all three broke during v1.5.0
|
||||
development and nothing outside their own unit tests would have caught it.
|
||||
|
||||
The 21 pre-existing checks are unchanged and all still pass: the discovery
|
||||
layer, `cad_batch`, the 19-module contract split, the layout/viewport family
|
||||
and Wave A landed with **zero correctness regressions**.
|
||||
|
||||
### Result — v1.4.0 vs v1.3.0
|
||||
|
||||
21 checks. Report:
|
||||
[`results/published/ab-v1.3.0-vs-v1.4.0.json`](results/published/ab-v1.3.0-vs-v1.4.0.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.3.0** (baseline) | 21 / 21 | 100 % | — | — |
|
||||
| **v1.4.0** (this release) | 21 / 21 | 100 % | 0 | **0** |
|
||||
|
||||
The v1.4.0 feature and infrastructure work (CI, packaging, competitor lane,
|
||||
tool profiles, paper space, ISO 286 fits, opt-in solids) introduced **zero
|
||||
correctness regressions**.
|
||||
| **v1.4.0** | 21 / 21 | 100 % | 0 | **0** |
|
||||
|
||||
### Result — v1.1.0 vs v1.0.0 (`origin/main`, commit 15fa2bc)
|
||||
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
"""Reference v2 adapter for this repository."""
|
||||
"""Reference adapter for this repository, covering the v3 task matrix.
|
||||
|
||||
Every task verifies against a value worked out independently of the code under
|
||||
test — a closed-form area, a count of entities placed on purpose, a token
|
||||
ceiling fixed before the measurement. A task that only checks the server agrees
|
||||
with itself is a task that cannot fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.tasks_v2 import TaskSpec
|
||||
from benchmarks.tasks_v3 import TaskSpec
|
||||
from engineering.delivery import deliver_drawing
|
||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||
from engineering.refiner import refine_drawing
|
||||
|
||||
#: AutoCAD command names a drafter types, none of which appeared in any tool
|
||||
#: name or description before v1.5.0 — measured `df = 0` against the stock BM25
|
||||
#: index, so no amount of tuning could have surfaced them.
|
||||
#: Each command is answered by a *set*, because more than one tool can be a
|
||||
#: correct answer -- QSELECT is both "filter by properties" and "select
|
||||
#: similar", and picking a winner between them would test the tie-break rather
|
||||
#: than the discovery.
|
||||
DISCOVERY_QUERIES: tuple[tuple[str, frozenset[str]], ...] = (
|
||||
("FILLET", frozenset({"entity_fillet"})),
|
||||
("BPOLY", frozenset({"boundary_trace"})),
|
||||
("QSELECT", frozenset({"selection_filter", "entity_select_smart"})),
|
||||
("WBLOCK", frozenset({"block_create_from_entities"})),
|
||||
("OVERKILL", frozenset({"drawing_refine"})),
|
||||
("CHSPACE", frozenset({"entity_change_space"})),
|
||||
)
|
||||
|
||||
#: How far down the ranking still counts as found. A drafter reads the top few.
|
||||
DISCOVERY_TOP_N = 3
|
||||
|
||||
#: The advertised catalog must stay under this in discovery mode. Fixed here
|
||||
#: rather than derived from the measurement, so the measurement can miss it.
|
||||
TOKEN_CEILING = 2_000
|
||||
|
||||
|
||||
class AutoCADMCPProAdapter(BenchmarkAdapter):
|
||||
name = "autocad-mcp-pro"
|
||||
@@ -138,6 +168,202 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
|
||||
await reopened.disconnect()
|
||||
return count == 1, {"entity_count": count}, [str(path)]
|
||||
|
||||
# ── v3 (v1.5.0) ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _task_tool_discovery(self):
|
||||
"""Can a drafter find the tool by typing the command they know?
|
||||
|
||||
Ranked through the real transform, not a stub: the corpus is what makes
|
||||
these commands findable at all, and testing anything else would test
|
||||
the fixture.
|
||||
"""
|
||||
import server
|
||||
from discovery.transform import CadSearchTransform
|
||||
|
||||
tools = list(await server.mcp._list_tools())
|
||||
transform = CadSearchTransform()
|
||||
|
||||
ranks: dict[str, int | None] = {}
|
||||
for command, accepted in DISCOVERY_QUERIES:
|
||||
hits = [
|
||||
tool.name for tool in transform.rank(tools, command, limit=DISCOVERY_TOP_N).hits
|
||||
]
|
||||
found = next((index for index, name in enumerate(hits) if name in accepted), None)
|
||||
ranks[command] = None if found is None else found + 1
|
||||
|
||||
passed = all(rank is not None for rank in ranks.values())
|
||||
return passed, {"ranks": ranks, "top_n": DISCOVERY_TOP_N}, []
|
||||
|
||||
async def _task_token_budget(self):
|
||||
"""What the client pays before it has asked for anything.
|
||||
|
||||
The ceiling is a constant, so a regression that doubles the catalog
|
||||
fails this rather than quietly reporting a bigger number.
|
||||
"""
|
||||
import server
|
||||
from benchmarks.token_suite import (
|
||||
FORMAT_JSON_SCHEMA,
|
||||
_client,
|
||||
_wire_payload,
|
||||
build_counter,
|
||||
)
|
||||
|
||||
async def _advertised(mode: str) -> tuple[int, int]:
|
||||
"""Tokens on the wire for `tools/list`, and how many tools that is.
|
||||
|
||||
Measured through a real client rather than the registry: the
|
||||
registry is what the transform hides, so counting it would make
|
||||
discovery mode look free.
|
||||
"""
|
||||
server._apply_discovery_mode(mode)
|
||||
async with _client() as client:
|
||||
tools = await client.list_tools()
|
||||
return counter.count(_wire_payload(tools), FORMAT_JSON_SCHEMA), len(tools)
|
||||
|
||||
counter = build_counter("ratio")
|
||||
try:
|
||||
full, catalog_count = await _advertised("off")
|
||||
discovery, advertised_count = await _advertised("search")
|
||||
finally:
|
||||
server._apply_discovery_mode("off")
|
||||
|
||||
passed = discovery < TOKEN_CEILING and discovery < full
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"catalog_tokens": full,
|
||||
"discovery_tokens": discovery,
|
||||
"ceiling": TOKEN_CEILING,
|
||||
"reduction_x": round(full / discovery, 1) if discovery else None,
|
||||
"catalog_tools": catalog_count,
|
||||
"advertised_tools": advertised_count,
|
||||
# Named, because "40,305 tokens" reads like a count and is not
|
||||
# one. `benchmarks/token_suite.py --tokenizer anthropic` counts
|
||||
# for real; this lane has to stay offline and deterministic.
|
||||
"tokenizer": f"{counter.name} (estimate)" if counter.estimated else counter.name,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
async def _task_hatch_islands(self):
|
||||
"""A section view is mostly holes, so the island is the measurement.
|
||||
|
||||
20x20 outer square with a 10x10 island: the filled area is 300, and a
|
||||
server that records the island without subtracting it reports 400 while
|
||||
looking entirely correct on screen.
|
||||
"""
|
||||
await ensure_engineering_layers(self.backend)
|
||||
hatch = await self.backend.entity_create_hatch(
|
||||
"ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]], layer="HATCH"
|
||||
)
|
||||
island = [
|
||||
{"type": "line", "start": [5, 5], "end": [15, 5]},
|
||||
{"type": "line", "start": [15, 5], "end": [15, 15]},
|
||||
{"type": "line", "start": [15, 15], "end": [5, 15]},
|
||||
{"type": "line", "start": [5, 15], "end": [5, 5]},
|
||||
]
|
||||
added = await self.backend.hatch_add_boundary(hatch.handle, island)
|
||||
measured = await self.backend.entity_measure(hatch.handle)
|
||||
ignored = await self.backend.hatch_edit(hatch.handle, style="ignore")
|
||||
over_island = await self.backend.entity_measure(hatch.handle)
|
||||
|
||||
passed = (
|
||||
added["path_count"] == 2
|
||||
and abs(measured["area"] - 300.0) < 1e-6
|
||||
and measured["hatch_style"] == "outer" # what entity_create_hatch writes
|
||||
and ignored["changed"] == ["style"]
|
||||
and abs(over_island["area"] - 400.0) < 1e-6
|
||||
)
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"path_count": added["path_count"],
|
||||
"filled_area": measured["area"],
|
||||
"area_ignoring_islands": over_island["area"],
|
||||
"expected_filled": 300.0,
|
||||
"hatch_style": measured["hatch_style"],
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
async def _task_selection_filter(self):
|
||||
"""Window is not crossing, and a polygon is not its bounding box.
|
||||
|
||||
Three circles placed so each distinction has exactly one right answer:
|
||||
one wholly inside, one straddling the edge, one out in the corner that
|
||||
the bounding box catches and the triangle does not.
|
||||
"""
|
||||
await ensure_engineering_layers(self.backend)
|
||||
await self.backend.entity_create_circle(5, 5, 1, layer="GEOMETRY")
|
||||
await self.backend.entity_create_circle(10, 5, 3, layer="GEOMETRY")
|
||||
await self.backend.entity_create_circle(18, 18, 0.5, layer="GEOMETRY")
|
||||
await self.backend.entity_create_circle(40, 40, 1, layer="TEXT")
|
||||
|
||||
window = await self.backend.selection_window(0, 0, 10, 10, mode="window")
|
||||
crossing = await self.backend.selection_window(0, 0, 10, 10, mode="crossing")
|
||||
bbox = await self.backend.selection_window(0, 0, 20, 20, mode="window")
|
||||
triangle = await self.backend.selection_polygon([[0, 0], [20, 0], [0, 20]], mode="window")
|
||||
by_layer = await self.backend.selection_filter(entity_type="CIRCLE", layer="TEXT")
|
||||
|
||||
passed = (
|
||||
window["count"] == 1
|
||||
and crossing["count"] == 2
|
||||
and bbox["count"] == 3
|
||||
and triangle["count"] == 1
|
||||
and by_layer["count"] == 1
|
||||
and by_layer["filtered_by"] == ["entity_type", "layer"]
|
||||
)
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"window": window["count"],
|
||||
"crossing": crossing["count"],
|
||||
"bounding_box": bbox["count"],
|
||||
"polygon": triangle["count"],
|
||||
"by_layer": by_layer["count"],
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
async def _task_measure_from_handle(self):
|
||||
"""The 28.2% that reading coordinates back loses.
|
||||
|
||||
A 10x10 square whose top edge is a semicircle encloses 139.2699. Chain
|
||||
the loose edges into a boundary, then measure that boundary by handle;
|
||||
shoelacing the four corners the model would have remembered gives 100.
|
||||
"""
|
||||
await ensure_engineering_layers(self.backend)
|
||||
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
||||
await self.backend.entity_create_line(10, 0, 10, 10, layer="GEOMETRY")
|
||||
await self.backend.entity_create_line(0, 10, 0, 0, layer="GEOMETRY")
|
||||
await self.backend.entity_create_arc(5, 10, 5, 0, 180, layer="GEOMETRY")
|
||||
|
||||
loop = await self.backend.boundary_trace(5.0, 5.0)
|
||||
measured = await self.backend.entity_measure(loop["handle"])
|
||||
naive = await self.backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]])
|
||||
|
||||
true_area = 100.0 + math.pi * 25.0 / 2.0
|
||||
lost = (true_area - naive) / true_area
|
||||
passed = (
|
||||
loop["ok"] is True
|
||||
and abs(measured["area"] - true_area) < 1e-5
|
||||
and abs(loop["area"] - measured["area"]) < 1e-9
|
||||
and measured["method"] == "analytic_bulge"
|
||||
and measured["exact"] is True
|
||||
and 0.28 < lost < 0.29
|
||||
)
|
||||
return (
|
||||
passed,
|
||||
{
|
||||
"measured_area": measured["area"],
|
||||
"true_area": round(true_area, 6),
|
||||
"vertex_shoelace_area": naive,
|
||||
"fraction_lost_by_reading_points_back": round(lost, 4),
|
||||
"boundary_agrees": abs(loop["area"] - measured["area"]) < 1e-9,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
async def _task_auditable_delivery(self):
|
||||
await ensure_engineering_layers(self.backend)
|
||||
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
||||
|
||||
@@ -223,6 +223,52 @@ async def center_linetype_applied():
|
||||
return info.linetype.upper() == "CENTER"
|
||||
|
||||
|
||||
# ── Measurement (v1.5.0) ───────────────────────────────────────────────────────
|
||||
#
|
||||
# These three are NEW capability, not repaired regressions: v1.4.0 has no
|
||||
# `entity_measure` and no boundary tracing, so it misses them by not having the
|
||||
# method rather than by getting it wrong. They are here because all three broke
|
||||
# during v1.5.0 development and nothing outside their own unit tests would have
|
||||
# noticed.
|
||||
|
||||
|
||||
async def hatch_area_subtracts_its_island():
|
||||
"""A section view is mostly holes; counting them is a 33% error here."""
|
||||
b = await _b()
|
||||
h = await b.entity_create_hatch("ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]])
|
||||
await b.hatch_add_boundary(
|
||||
h.handle,
|
||||
[
|
||||
{"type": "line", "start": [5, 5], "end": [15, 5]},
|
||||
{"type": "line", "start": [15, 5], "end": [15, 15]},
|
||||
{"type": "line", "start": [15, 15], "end": [5, 15]},
|
||||
{"type": "line", "start": [5, 15], "end": [5, 5]},
|
||||
],
|
||||
)
|
||||
return abs((await b.entity_measure(h.handle))["area"] - 300.0) < 1e-6
|
||||
|
||||
|
||||
async def boundary_area_agrees_with_measure():
|
||||
"""Two tools, one shape, one number."""
|
||||
b = await _b()
|
||||
await b.entity_create_line(0, 0, 10, 0)
|
||||
await b.entity_create_line(10, 0, 10, 10)
|
||||
await b.entity_create_line(0, 10, 0, 0)
|
||||
await b.entity_create_arc(5, 10, 5, 0, 180)
|
||||
loop = await b.boundary_trace(5.0, 5.0)
|
||||
measured = await b.entity_measure(loop["handle"])
|
||||
true_area = 100.0 + math.pi * 25.0 / 2.0
|
||||
return abs(loop["area"] - measured["area"]) < 1e-9 and abs(measured["area"] - true_area) < 1e-5
|
||||
|
||||
|
||||
async def two_vertex_circle_has_area():
|
||||
"""DXF's most compact circle. The shoelace over its two points is 0."""
|
||||
from engineering.measure import polygon_area_perimeter
|
||||
|
||||
area, _ = polygon_area_perimeter([(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)], closed=True)
|
||||
return abs(area - math.pi * 100.0) < 1e-9
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"core_line_length": (core_line_length, "Core"),
|
||||
"core_circle_radius": (core_circle_radius, "Core"),
|
||||
@@ -245,6 +291,9 @@ CHECKS = {
|
||||
"construction_left_iso_caught": (construction_left_iso_caught, "Quality gate"),
|
||||
"gear_no_self_overlap": (gear_no_self_overlap, "Engineering"),
|
||||
"center_linetype_applied": (center_linetype_applied, "Entities"),
|
||||
"hatch_area_subtracts_its_island": (hatch_area_subtracts_its_island, "Measurement"),
|
||||
"boundary_area_agrees_with_measure": (boundary_area_agrees_with_measure, "Measurement"),
|
||||
"two_vertex_circle_has_area": (two_vertex_circle_has_area, "Measurement"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from benchmarks.tasks_v2 import TASKS_V2
|
||||
from benchmarks.tasks_v3 import TASKS_V3
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
|
||||
@@ -66,8 +66,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
|
||||
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1"
|
||||
|
||||
task_ids = [task.task_id for task in TASKS_V2]
|
||||
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V2]
|
||||
task_ids = [task.task_id for task in TASKS_V3]
|
||||
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V3]
|
||||
servers = [report["adapter"] for report in reports]
|
||||
status_by_server = {
|
||||
report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]}
|
||||
@@ -132,12 +132,17 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
Patch(facecolor=STATUS_COLORS["partial"], label="partial"),
|
||||
Patch(facecolor=STATUS_COLORS["fail"], label="fail"),
|
||||
Patch(facecolor=STATUS_COLORS["unsupported"], label="unsupported (no equivalent)"),
|
||||
# Real rows now, not a theoretical state: the competitor reports were
|
||||
# pinned and run against the v2 matrix, so the five v3 tasks were
|
||||
# never put to them. An invented zero would look identical to a
|
||||
# measured one, so they stay blank and say why.
|
||||
Patch(facecolor=STATUS_COLORS["not_run"], edgecolor="#30363d", label="not run"),
|
||||
]
|
||||
ax.legend(
|
||||
handles=legend_handles,
|
||||
loc="upper left",
|
||||
bbox_to_anchor=(0, -0.02),
|
||||
ncol=4,
|
||||
ncol=5,
|
||||
frameon=False,
|
||||
fontsize=9,
|
||||
labelcolor="#e6edf3",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.4.0",
|
||||
"total": 24,
|
||||
"old_pass": 21,
|
||||
"new_pass": 24,
|
||||
"old_pct": 87.5,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 3,
|
||||
"regressed": 0
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"check": "core_line_length",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_circle_radius",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_layer_create",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_polyline_closed",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_linear_dim",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_save_dxf_roundtrip",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_aligned_no_error",
|
||||
"category": "Dimensions",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_angular_no_error",
|
||||
"category": "Dimensions",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "array_polar_360_distinct",
|
||||
"category": "Modify",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "point_intersection_line_line",
|
||||
"category": "Geometry",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "point_tangent_external",
|
||||
"category": "Geometry",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "arc_has_length",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "arc_select_by_length",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "ezdxf_bounding_box",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "mtext_rotation_roundtrip",
|
||||
"category": "Entities",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "screenshot_png",
|
||||
"category": "Render",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_overlap_critique_fires",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "iso13567_dim_layer",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "construction_left_iso_caught",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "gear_no_self_overlap",
|
||||
"category": "Engineering",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "center_linetype_applied",
|
||||
"category": "Entities",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "hatch_area_subtracts_its_island",
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "boundary_area_agrees_with_measure",
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "two_vertex_circle_has_area",
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,21 @@
|
||||
"capabilities": {
|
||||
"backend": "ezdxf",
|
||||
"features": {
|
||||
"audit_detail": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"boundary_trace": {
|
||||
"mode": "computed",
|
||||
"reason": "straight_edges_split_at_intersections;curved_edges_not_split",
|
||||
"supported": true
|
||||
},
|
||||
"chspace": {
|
||||
"mode": "computed",
|
||||
"reason": "top_view_untwisted_viewports_only;dimensions_refused_unless_frozen;acis_proxy_table_refused;viewport_clipping_reported_not_applied",
|
||||
"supported": true
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "shared",
|
||||
"reason": null,
|
||||
@@ -15,26 +30,61 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"dwg": {
|
||||
"mode": null,
|
||||
"reason": "no_dwg_writer_requires_live_autocad",
|
||||
"supported": false
|
||||
},
|
||||
"dxf": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"handle_overlay": {
|
||||
"mode": "rendered",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"hatch_edge_paths": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"lisp": {
|
||||
"mode": null,
|
||||
"reason": "live_com_only",
|
||||
"supported": false
|
||||
},
|
||||
"measure_area_acis": {
|
||||
"mode": null,
|
||||
"reason": "acis_evaluation_requires_live_autocad",
|
||||
"supported": false
|
||||
},
|
||||
"mleader": {
|
||||
"mode": "composite",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"paper_space": {
|
||||
"mtext_background_color": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"ocs_normalized": {
|
||||
"mode": "wcs",
|
||||
"reason": "reported_xy_is_wcs;z_component_not_reported;text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported",
|
||||
"supported": true
|
||||
},
|
||||
"ocs_tilted_plane": {
|
||||
"mode": null,
|
||||
"reason": "planned_1.4",
|
||||
"reason": "2d_xy_cannot_address_a_tilted_plane",
|
||||
"supported": false
|
||||
},
|
||||
"paper_space": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"pdf": {
|
||||
"mode": "rendered",
|
||||
"reason": null,
|
||||
@@ -55,9 +105,14 @@
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"revcloud": {
|
||||
"mode": "native",
|
||||
"reason": "ezdxf_revcloud_helper",
|
||||
"supported": true
|
||||
},
|
||||
"solid_3d": {
|
||||
"mode": null,
|
||||
"reason": "planned_1.5",
|
||||
"reason": "acis_generation_requires_live_autocad",
|
||||
"supported": false
|
||||
},
|
||||
"table": {
|
||||
@@ -69,6 +124,21 @@
|
||||
"mode": "snapshot",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
},
|
||||
"undo_history": {
|
||||
"mode": null,
|
||||
"reason": "set_EZDXF_UNDO_DEPTH_to_enable",
|
||||
"supported": false
|
||||
},
|
||||
"viewport_render": {
|
||||
"mode": "rendered",
|
||||
"reason": "viewport_borders_not_drawn",
|
||||
"supported": true
|
||||
},
|
||||
"wipeout": {
|
||||
"mode": "native",
|
||||
"reason": null,
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,15 +146,16 @@
|
||||
"environment": {
|
||||
"implementation": "CPython",
|
||||
"machine": "AMD64",
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"python": "3.14.0"
|
||||
"platform": "Windows-10-10.0.26200-SP0",
|
||||
"python": "3.11.15"
|
||||
},
|
||||
"generated_at": "2026-07-23T11:39:04.277175+00:00",
|
||||
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
|
||||
"generated_at": "2026-08-06T06:27:59.080169+00:00",
|
||||
"git_sha": "d5a0ff5109ddbd37d88371c10a1ed087201de4b5",
|
||||
"matrix": "v3",
|
||||
"results": [
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.14,
|
||||
"duration_ms": 2.49,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"line_length": 5.0
|
||||
@@ -97,7 +168,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 3.39,
|
||||
"duration_ms": 1.41,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
@@ -108,7 +179,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.11,
|
||||
"duration_ms": 3.31,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
@@ -119,7 +190,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 348.74,
|
||||
"duration_ms": 17.55,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_type": "DIMENSION"
|
||||
@@ -132,7 +203,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 15.74,
|
||||
"duration_ms": 3.7,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"representations": [
|
||||
@@ -148,7 +219,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 28.95,
|
||||
"duration_ms": 29.09,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_count": 0
|
||||
@@ -161,7 +232,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 2.5,
|
||||
"duration_ms": 1.2,
|
||||
"message": "",
|
||||
"metrics": {},
|
||||
"score": 100.0,
|
||||
@@ -172,7 +243,7 @@
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 29.19,
|
||||
"duration_ms": 8.42,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"final_score": 100.0,
|
||||
@@ -215,11 +286,11 @@
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "roundtrip.dxf",
|
||||
"sha256": "6413204a5baec19a5fbf551418eb56e30b1488ca5bb755e6a8709ac8e8ab0b22",
|
||||
"size_bytes": 18442
|
||||
"sha256": "d11dfce5eddaf849075c62854a8526efaaa3bbd779d374e4c287de77f361caf7",
|
||||
"size_bytes": 18440
|
||||
}
|
||||
],
|
||||
"duration_ms": 16.66,
|
||||
"duration_ms": 32.29,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"entity_count": 1
|
||||
@@ -234,11 +305,11 @@
|
||||
"artifacts": [
|
||||
{
|
||||
"filename": "manifest.json",
|
||||
"sha256": "38c2ee819b49b9c66b2311b952e856eff9e3a7565194cce2edea60a3211d233b",
|
||||
"size_bytes": 3581
|
||||
"sha256": "a74b2e96dbe7ea373717f26a8d26e58fe079a61370eb5d646f04a09f1b7cca2f",
|
||||
"size_bytes": 5662
|
||||
}
|
||||
],
|
||||
"duration_ms": 4380.8,
|
||||
"duration_ms": 305.81,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"score": {
|
||||
@@ -255,16 +326,107 @@
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "auditable_delivery"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 1149.77,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"ranks": {
|
||||
"BPOLY": 1,
|
||||
"CHSPACE": 1,
|
||||
"FILLET": 1,
|
||||
"OVERKILL": 1,
|
||||
"QSELECT": 1,
|
||||
"WBLOCK": 1
|
||||
},
|
||||
"top_n": 3
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "tool_discovery"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 53.97,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"advertised_tools": 2,
|
||||
"catalog_tokens": 40305,
|
||||
"catalog_tools": 149,
|
||||
"ceiling": 2000,
|
||||
"discovery_tokens": 356,
|
||||
"reduction_x": 113.2,
|
||||
"tokenizer": "ratio/v1 (estimate)"
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "token_budget"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 10.6,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"area_ignoring_islands": 400.0,
|
||||
"expected_filled": 300.0,
|
||||
"filled_area": 300.0,
|
||||
"hatch_style": "outer",
|
||||
"path_count": 2
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "hatch_islands"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 8.0,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"bounding_box": 3,
|
||||
"by_layer": 1,
|
||||
"crossing": 2,
|
||||
"polygon": 1,
|
||||
"window": 1
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "selection_filter"
|
||||
},
|
||||
{
|
||||
"artifacts": [],
|
||||
"duration_ms": 5.36,
|
||||
"message": "",
|
||||
"metrics": {
|
||||
"boundary_agrees": true,
|
||||
"fraction_lost_by_reading_points_back": 0.282,
|
||||
"measured_area": 139.269908,
|
||||
"true_area": 139.269908,
|
||||
"vertex_shoelace_area": 100.0
|
||||
},
|
||||
"score": 100.0,
|
||||
"status": "pass",
|
||||
"stderr_summary": "",
|
||||
"stdout_summary": "",
|
||||
"task_id": "measure_from_handle"
|
||||
}
|
||||
],
|
||||
"schema_version": "2.0",
|
||||
"summary": {
|
||||
"attempted": 10,
|
||||
"attempted": 15,
|
||||
"coverage_percent": 100.0,
|
||||
"passed": 10,
|
||||
"passed": 15,
|
||||
"score": 100.0,
|
||||
"supported": 10,
|
||||
"supported": 15,
|
||||
"unsupported": 0
|
||||
},
|
||||
"timeout_seconds": 30.0
|
||||
"timeout_seconds": 180.0
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
"environment": {
|
||||
"implementation": "CPython",
|
||||
"machine": "AMD64",
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"python": "3.14.0"
|
||||
"platform": "Windows-10-10.0.26200-SP0",
|
||||
"python": "3.11.15"
|
||||
},
|
||||
"schema_version": "perf/1.0",
|
||||
"workloads": [
|
||||
@@ -14,22 +14,22 @@
|
||||
"entity_count": 2000
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 3148.0,
|
||||
"wall_ms": 635.3,
|
||||
"metric_value": 6343.1,
|
||||
"wall_ms": 315.3,
|
||||
"workload_id": "create_lines_2k"
|
||||
},
|
||||
{
|
||||
"description": "Create 10,000 lines, export DXF, reopen and verify",
|
||||
"detail": {
|
||||
"build_ms": 10884.9,
|
||||
"build_ms": 1574.1,
|
||||
"entity_count": 10000,
|
||||
"export_ms": 1439.2,
|
||||
"file_bytes": 1369662,
|
||||
"reopen_ms": 1607.5
|
||||
"export_ms": 261.8,
|
||||
"file_bytes": 1369663,
|
||||
"reopen_ms": 320.2
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 717.8,
|
||||
"wall_ms": 13931.6,
|
||||
"metric_value": 4637.9,
|
||||
"wall_ms": 2156.1,
|
||||
"workload_id": "roundtrip_10k"
|
||||
},
|
||||
{
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"metric": "entities_matched",
|
||||
"metric_value": 2500.0,
|
||||
"wall_ms": 3434.2,
|
||||
"wall_ms": 890.8,
|
||||
"workload_id": "region_query_10k"
|
||||
},
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"metric": "critique_issues",
|
||||
"metric_value": 0.0,
|
||||
"wall_ms": 315.9,
|
||||
"wall_ms": 176.5,
|
||||
"workload_id": "premium_pass"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,8 +14,9 @@ import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backends.capability import UnsupportedCapabilityError
|
||||
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
|
||||
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec, task_by_id
|
||||
from benchmarks.tasks_v3 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
|
||||
|
||||
|
||||
def _load_registry() -> dict[str, dict]:
|
||||
@@ -78,6 +79,7 @@ async def run_tasks(
|
||||
artifact_dir: str | Path,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
matrix: str = DEFAULT_MATRIX,
|
||||
) -> dict:
|
||||
destination = Path(artifact_dir).resolve()
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
@@ -90,6 +92,12 @@ async def run_tasks(
|
||||
result = await asyncio.wait_for(adapter.run_task(task), timeout=timeout)
|
||||
except TimeoutError:
|
||||
result = TaskResult(task.task_id, "timeout", 0.0, f"Exceeded {timeout}s")
|
||||
except UnsupportedCapabilityError as exc:
|
||||
# "Your server got this wrong" and "your engine cannot reach
|
||||
# this" are different findings about a competitor. The enum has
|
||||
# carried `unsupported` since v2 for exactly this, but a
|
||||
# refusal is still an exception and used to land as `fail`.
|
||||
result = TaskResult(task.task_id, "unsupported", 0.0, f"[{exc.capability}] {exc}")
|
||||
except Exception as exc:
|
||||
result = TaskResult(task.task_id, "fail", 0.0, str(exc), stderr_summary=str(exc))
|
||||
result.duration_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
@@ -108,7 +116,11 @@ async def run_tasks(
|
||||
weight_by_id[item.task_id] * item.score for item in results if item.status != "unsupported"
|
||||
)
|
||||
return {
|
||||
# The *report* schema is unchanged — same keys, same enum — so the
|
||||
# chart renderers still read v1.4's published files. `matrix` says
|
||||
# which task set ran, which is the thing that actually moved.
|
||||
"schema_version": "2.0",
|
||||
"matrix": matrix,
|
||||
"adapter": adapter.name,
|
||||
"adapter_metadata": adapter.metadata(),
|
||||
"git_sha": _git_sha(),
|
||||
@@ -137,6 +149,12 @@ async def run_tasks(
|
||||
def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--list", action="store_true", help="List the fixed benchmark tasks")
|
||||
parser.add_argument(
|
||||
"--matrix",
|
||||
default=DEFAULT_MATRIX,
|
||||
choices=sorted(MATRICES),
|
||||
help="Task set to run. v2 stays addressable so a v1.4 report can be reproduced.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
default="autocad-mcp-pro",
|
||||
@@ -148,14 +166,35 @@ def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser:
|
||||
parser.add_argument("--artifact-dir", default="benchmarks/results/latest")
|
||||
parser.add_argument("--timeout", type=float, default=30.0)
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
||||
parser.add_argument(
|
||||
"--publish",
|
||||
type=Path,
|
||||
help=(
|
||||
"Also write the report here with artifact paths reduced to filenames. "
|
||||
"How results/published/*.json are produced — it was a manual edit before."
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _sanitize_for_publication(report: dict) -> dict:
|
||||
"""Drop absolute paths from artifacts, keep the hash that identifies them.
|
||||
|
||||
A published report should not carry the run machine's directory layout;
|
||||
the sha256 is what makes the artifact checkable, and that stays.
|
||||
"""
|
||||
published = json.loads(json.dumps(report))
|
||||
for result in published["results"]:
|
||||
for artifact in result["artifacts"]:
|
||||
artifact.pop("path", None)
|
||||
return published
|
||||
|
||||
|
||||
def main() -> None:
|
||||
registry = _load_registry()
|
||||
args = _parser(registry).parse_args()
|
||||
if args.list:
|
||||
for task in TASKS_V2:
|
||||
for task in MATRICES[args.matrix]:
|
||||
print(f"{task.task_id}\t{task.category}\t{task.description}")
|
||||
return
|
||||
|
||||
@@ -165,10 +204,27 @@ def main() -> None:
|
||||
raise SystemExit(
|
||||
f"{args.server} supports backends {supported_backends}, not {args.backend!r}"
|
||||
)
|
||||
tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2)
|
||||
tasks = (
|
||||
[task_by_id(task_id, args.matrix) for task_id in args.task]
|
||||
if args.task
|
||||
else list(MATRICES[args.matrix])
|
||||
)
|
||||
adapter = _make_adapter(entry, args.backend)
|
||||
report = asyncio.run(run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout))
|
||||
report = asyncio.run(
|
||||
run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout, matrix=args.matrix)
|
||||
)
|
||||
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
if args.publish:
|
||||
args.publish.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.publish.write_text(
|
||||
json.dumps(
|
||||
_sanitize_for_publication(report), ensure_ascii=False, indent=2, sort_keys=True
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
print(f"Published: {args.publish}", file=sys.stderr)
|
||||
print(rendered)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""The v3 task matrix: the v2 ten, plus five the v2 ten could not see.
|
||||
|
||||
v2 scored this server 10/10, and that number was structurally uninformative:
|
||||
every task in it exercised something the server was already built around. A
|
||||
matrix that cannot fail is a matrix that measures nothing, so the five added
|
||||
here were picked to cover capability the v2 set never touches — discovery,
|
||||
idle token cost, hatch islands, selection semantics, and measuring an entity
|
||||
that is already in the drawing.
|
||||
|
||||
Two of the five are renamed from the release plan, and the renames are the
|
||||
point rather than bookkeeping:
|
||||
|
||||
``tool_discovery_bilingual`` -> ``tool_discovery``
|
||||
The plan assumed a Turkish query normalizer. One was written and then
|
||||
removed: this repository is English throughout, and the Turkish was an
|
||||
inference from the author's chat language, not a product requirement. A
|
||||
task named "bilingual" would advertise a capability that does not exist,
|
||||
which is the class of claim this release spent four milestones deleting.
|
||||
|
||||
``measurement_massprops`` -> ``measure_from_handle``
|
||||
No mass properties ship. The plan's eight-tool measurement family (F2) was
|
||||
cut to one after measurement showed the repo already carried eight
|
||||
``analysis_*`` tools; centroids and moments of inertia were not among the
|
||||
survivors. What did ship is measuring a *handle* instead of a caller's
|
||||
remembered coordinates, so that is what the task is called.
|
||||
|
||||
The v2 ids and weights are untouched. The published competitor reports were
|
||||
run against the v2 matrix at v1.4 and are not re-scored here — those servers
|
||||
have never been asked these five questions, and inventing a result for them
|
||||
would be worse than the gap. The task-matrix chart renders them ``not_run``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec
|
||||
|
||||
#: Added in v1.5.0. Every one of these can fail on this server.
|
||||
NEW_TASKS_V3: tuple[TaskSpec, ...] = (
|
||||
TaskSpec(
|
||||
"tool_discovery",
|
||||
"discovery",
|
||||
"Resolve AutoCAD command names to tools through the search layer",
|
||||
1.0,
|
||||
),
|
||||
TaskSpec(
|
||||
"token_budget",
|
||||
"efficiency",
|
||||
"Keep the idle catalog under a fixed token ceiling",
|
||||
1.0,
|
||||
),
|
||||
TaskSpec(
|
||||
"hatch_islands",
|
||||
"hatch",
|
||||
"Hatch an area with an island and measure what it fills",
|
||||
1.0,
|
||||
),
|
||||
TaskSpec(
|
||||
"selection_filter",
|
||||
"selection",
|
||||
"Separate window from crossing, and a polygon from its bounding box",
|
||||
1.0,
|
||||
),
|
||||
TaskSpec(
|
||||
"measure_from_handle",
|
||||
"measurement",
|
||||
"Measure a curved boundary from its handle, bulges included",
|
||||
1.0,
|
||||
),
|
||||
)
|
||||
|
||||
TASKS_V3: tuple[TaskSpec, ...] = TASKS_V2 + NEW_TASKS_V3
|
||||
|
||||
#: Runner-selectable task sets. v2 stays addressable so a v2 report can be
|
||||
#: reproduced exactly rather than only described.
|
||||
MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3}
|
||||
|
||||
DEFAULT_MATRIX = "v3"
|
||||
|
||||
|
||||
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
|
||||
for task in MATRICES[matrix]:
|
||||
if task.task_id == task_id:
|
||||
return task
|
||||
raise KeyError(f"Unknown benchmark task: {task_id}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,48 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
#: ProgID the live backend attaches to when ``CAD_PROGID`` is unset. The only
|
||||
#: CAD this project is developed and tested against.
|
||||
DEFAULT_CAD_PROGID = "AutoCAD.Application"
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Server configuration loaded from environment variables."""
|
||||
|
||||
@property
|
||||
def backend(self) -> str:
|
||||
"""Which engine to run: ``ezdxf``, ``com`` or ``auto``.
|
||||
|
||||
Read live rather than captured in ``__init__``, and deliberately
|
||||
read-only. ``_make_backend`` used to read ``os.environ`` itself while
|
||||
this attribute had no production reader at all, so
|
||||
``monkeypatch.setattr(settings, "backend", "ezdxf")`` looked like a
|
||||
guard and was a no-op — on a machine with AutoCAD open that meant a test
|
||||
attaching to the operator's live drawing. Late binding is what keeps
|
||||
``monkeypatch.setenv`` working for the call sites that pin the headless
|
||||
backend correctly; the missing setter is what makes the broken spelling
|
||||
fail loudly instead of silently.
|
||||
"""
|
||||
return os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
|
||||
|
||||
@property
|
||||
def cad_progid(self) -> str:
|
||||
"""COM ProgID the live backend attaches to.
|
||||
|
||||
Read live and deliberately read-only for the same reasons as
|
||||
``backend``: late binding keeps ``monkeypatch.setenv`` working, and the
|
||||
missing setter makes a wrong spelling fail loudly rather than look like
|
||||
a guard that does nothing.
|
||||
|
||||
Stripped but **not** lowercased — the value is echoed back in error
|
||||
messages and ``system_status``, where ``gstarcad.application`` reads
|
||||
like a typo we introduced. Never validated against what is actually
|
||||
registered on the machine: an unrecognised ProgID is not "invalid", it
|
||||
is unverifiable by us, and only AutoCAD is tested.
|
||||
"""
|
||||
return os.environ.get("CAD_PROGID", "").strip() or DEFAULT_CAD_PROGID
|
||||
|
||||
def __init__(self):
|
||||
self.backend: str = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
|
||||
self.log_level: str = os.environ.get("LOG_LEVEL", "INFO").upper().strip()
|
||||
|
||||
raw_paths = os.environ.get("ALLOWED_PATHS", "").strip()
|
||||
@@ -37,18 +73,46 @@ class Settings:
|
||||
# otherwise block the single-thread STA executor forever.
|
||||
self.com_call_timeout: float = float(os.environ.get("COM_CALL_TIMEOUT", "60"))
|
||||
|
||||
# Per-call timeout for the headless ezdxf backend. Every ezdxf method
|
||||
# runs under one document lock, so a single hung call (pathological DXF,
|
||||
# a render that never returns) would otherwise hold that lock forever
|
||||
# and deadlock every later tool call. Higher than com_call_timeout
|
||||
# because matplotlib PDF/PNG rendering of a large drawing is legitimately
|
||||
# slow. Set to 0 to disable the check.
|
||||
self.ezdxf_call_timeout: float = float(os.environ.get("EZDXF_CALL_TIMEOUT", "120"))
|
||||
|
||||
# How many undo steps the headless backend keeps. 0 = off, which is the
|
||||
# default because it is not free: ezdxf has no journal, so a step is a
|
||||
# full DXF snapshot. Measured — 10 entities 3.1 ms / 19 KB, 1000
|
||||
# entities 28 ms / 146 KB, 5000 entities 130 ms / 658 KB — and the cost
|
||||
# is paid on every mutating call, so switching it on for a large drawing
|
||||
# is a real trade the user should make deliberately rather than inherit.
|
||||
self.ezdxf_undo_depth: int = max(0, int(os.environ.get("EZDXF_UNDO_DEPTH", "0")))
|
||||
|
||||
# HTTP transport: refuse non-loopback bind unless explicitly opted in.
|
||||
self.allow_remote_http: bool = (
|
||||
os.environ.get("ALLOW_REMOTE_HTTP", "false").lower().strip() == "true"
|
||||
)
|
||||
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
|
||||
|
||||
# Exposed tool profile: "full" (default, everything), "core" (hides the
|
||||
# raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool
|
||||
# drafting/inspection core for clients with tight tool caps). Invalid
|
||||
# values fall back to "full" with a warning at startup.
|
||||
# Exposed tool profile: "full" (default, everything) or "lean" (a
|
||||
# curated ~47-tool drafting/inspection core for clients with tight tool
|
||||
# caps). Invalid values fall back to "full" with a warning at startup;
|
||||
# so does the "core" profile removed in v1.5.0 (see
|
||||
# server.REMOVED_TOOL_PROFILES).
|
||||
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip()
|
||||
|
||||
# Tool discovery: "off" (default) advertises the full catalog; "search"
|
||||
# replaces it with a search tool plus a call_tool proxy (see
|
||||
# discovery/transform.py and server._apply_discovery_mode).
|
||||
#
|
||||
# Default "off" deliberately. "search" is the intended v1.x default,
|
||||
# but flipping it changes what every connected client sees on
|
||||
# list_tools — a breaking wire-level change that needs its own
|
||||
# migration note and README pass, neither of which ships in v1.5.0.
|
||||
# Invalid values fall back to "off" with a warning at startup.
|
||||
self.discovery_mode: str = os.environ.get("DISCOVERY_MODE", "off").lower().strip()
|
||||
|
||||
# Opt-in 3D solids (COM backend only). Disabled by default because the
|
||||
# headless backend cannot produce ACIS solids and live 3D modifies real
|
||||
# documents; enable deliberately.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Tool-discovery layer: searchable tool metadata and the search transform.
|
||||
|
||||
Kept deliberately free of ``fastmcp`` and ``ezdxf`` imports at module scope so
|
||||
the discovery data stays cheap to import and testable in isolation.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
"""Compact one-line cards for tool-search results.
|
||||
|
||||
Passed to a search transform as ``search_result_serializer``. FastMCP's default
|
||||
(``serialize_tools_for_output_json``) re-emits each hit's full ``list_tools``
|
||||
entry, JSON Schema and all — for this server that is roughly 1.5 kB per hit,
|
||||
and a five-hit answer costs more than the crowded catalog the search was meant
|
||||
to replace.
|
||||
|
||||
One line per hit::
|
||||
|
||||
entity_fillet (write) | Round a corner between two entities. | req: handle_a, handle_b, radius | acad: FILLET, F
|
||||
|
||||
``req:`` lists *required* parameters only. Optional parameters and types are
|
||||
deliberately absent — the honest cost of this format is that a caller must
|
||||
either already know the optional arguments or read the error from a failed
|
||||
call. That trade is worth it at five hits per search; it would not be at one.
|
||||
|
||||
Scope note: the compact payload is a real saving but it is **not** what makes
|
||||
this discovery layer better than stock. Stock BM25 with this same serializer
|
||||
gets the same size win. The differentiators are the alias corpus (which lets a
|
||||
query find anything at all) and the risk filter — not bytes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from fastmcp.tools.base import Tool
|
||||
|
||||
from discovery.aliases import aliases_for
|
||||
|
||||
__all__ = ["MAX_SUMMARY_CHARS", "serialize_search_results"]
|
||||
|
||||
# Long enough for every first docstring line in the live catalog (measured max
|
||||
# 96 characters), short enough that a rambling one cannot dominate the payload.
|
||||
MAX_SUMMARY_CHARS = 110
|
||||
|
||||
# Beyond three, AutoCAD command names stop disambiguating and start padding.
|
||||
MAX_ACAD_COMMANDS = 3
|
||||
|
||||
EMPTY_RESULT = "No tools matched the query."
|
||||
|
||||
|
||||
def _risk(tool: Tool) -> str:
|
||||
"""Same derivation as ``discovery.transform.tool_risk``, inlined.
|
||||
|
||||
Imported the other way round would make ``discovery.serialize`` depend on
|
||||
the transform, and the transform already depends on this module for its
|
||||
default serializer.
|
||||
"""
|
||||
annotations = tool.annotations
|
||||
if annotations is not None:
|
||||
if annotations.destructiveHint:
|
||||
return "destructive"
|
||||
if annotations.readOnlyHint:
|
||||
return "read"
|
||||
return "write"
|
||||
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
if len(text) <= MAX_SUMMARY_CHARS:
|
||||
return text
|
||||
clipped = text[:MAX_SUMMARY_CHARS].rsplit(" ", 1)[0].rstrip(" ,;:.")
|
||||
return f"{clipped}..."
|
||||
|
||||
|
||||
def _summary(tool: Tool) -> str:
|
||||
"""One line describing the tool.
|
||||
|
||||
Prefers the hand-written ``@cad_tool`` summary in the MCP ``meta`` channel;
|
||||
falls back to the first line of the docstring, which for this server is
|
||||
already written as a standalone sentence.
|
||||
"""
|
||||
cad = (tool.meta or {}).get("cad")
|
||||
if isinstance(cad, dict) and cad.get("summary"):
|
||||
return _truncate(str(cad["summary"]).strip())
|
||||
for line in (tool.description or "").strip().splitlines():
|
||||
if line.strip():
|
||||
return _truncate(line.strip())
|
||||
return "(no description)"
|
||||
|
||||
|
||||
def _required(tool: Tool) -> str:
|
||||
schema = tool.parameters or {}
|
||||
required = schema.get("required")
|
||||
if not isinstance(required, list) or not required:
|
||||
return "(none)"
|
||||
return ", ".join(str(name) for name in required)
|
||||
|
||||
|
||||
def _acad(tool: Tool) -> str:
|
||||
record = aliases_for(tool.name)
|
||||
if record is None or not record.acad:
|
||||
return ""
|
||||
return ", ".join(record.acad[:MAX_ACAD_COMMANDS])
|
||||
|
||||
|
||||
def _card(tool: Tool) -> str:
|
||||
parts = [
|
||||
f"{tool.name} ({_risk(tool)})",
|
||||
_summary(tool),
|
||||
f"req: {_required(tool)}",
|
||||
]
|
||||
acad = _acad(tool)
|
||||
if acad:
|
||||
parts.append(f"acad: {acad}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def serialize_search_results(tools: Sequence[Tool]) -> str:
|
||||
"""Render search hits as one compact card per line."""
|
||||
if not tools:
|
||||
return EMPTY_RESULT
|
||||
return "\n".join(_card(tool) for tool in tools)
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Tool-search transform that speaks AutoCAD.
|
||||
|
||||
Subclasses FastMCP's :class:`BM25SearchTransform` rather than replacing it, so
|
||||
the ``call_tool`` proxy, the auth-filtered catalog accessor
|
||||
(``_get_visible_tools``) and the re-entrancy bypass all come for free. Two
|
||||
things are added on top, and only two:
|
||||
|
||||
**1. Vocabulary.** Stock BM25 indexes tool name + description + parameter names
|
||||
+ parameter descriptions. Measured against this server's live catalog, that
|
||||
leaves ``BPOLY``, ``PEDIT``, ``WBLOCK``, ``OVERKILL``, ``LAYTRANS``,
|
||||
``MATCHPROP`` and ``QSELECT`` at df=0 — a search for any of them returns
|
||||
nothing at all. :func:`discovery.aliases.alias_text` supplies the missing
|
||||
AutoCAD command names and shop-floor synonyms; this module folds them into the
|
||||
indexed document. (``FILLET`` is *not* in that list: it is already indexed,
|
||||
because the tool is literally named ``entity_fillet``.)
|
||||
|
||||
**2. Two additive boosts**, so an exact alias match outranks an incidental
|
||||
description word. See :data:`ACAD_MATCH_BOOST` / :data:`SYNONYM_MATCH_BOOST`.
|
||||
|
||||
BM25 itself is not reimplemented: the index object the base class already
|
||||
built is reused, only with different documents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.transforms.search import BM25SearchTransform
|
||||
from fastmcp.server.transforms.search.base import SearchResultSerializer
|
||||
from fastmcp.server.transforms.search.base import (
|
||||
_extract_searchable_text as _stock_searchable_text,
|
||||
)
|
||||
from fastmcp.tools.base import Tool
|
||||
from pydantic import Field
|
||||
|
||||
from discovery.aliases import alias_text, aliases_for
|
||||
from discovery.serialize import serialize_search_results
|
||||
|
||||
__all__ = [
|
||||
"ACAD_MATCH_BOOST",
|
||||
"MAX_SEARCH_LIMIT",
|
||||
"RISK_CEILINGS",
|
||||
"SYNONYM_MATCH_BOOST",
|
||||
"CadSearchTransform",
|
||||
"SearchOutcome",
|
||||
"tool_risk",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scoring
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ``_BM25Index.query()`` hands back a *ranking*, not scores, so the two signals
|
||||
# are fused on a rank scale instead of a raw-score scale: BM25's nth hit is
|
||||
# worth ``1 / (1 + n)``. That makes the boosts below trivially explainable in
|
||||
# the only units that matter here — positions.
|
||||
#
|
||||
# ACAD_MATCH_BOOST the query names an AutoCAD command this tool owns,
|
||||
# *written as a command* — see _names_a_command below.
|
||||
# Worth exactly as much as being BM25's top hit, so a
|
||||
# command match pulls a tool past any number of tools
|
||||
# that merely share a description word.
|
||||
# SYNONYM_MATCH_BOOST the query contains one of the tool's synonym phrases
|
||||
# verbatim ("bill of materials", "round the corner").
|
||||
# Worth BM25's second position: strong, but it loses to
|
||||
# an outright command match.
|
||||
#
|
||||
# That is the whole scorer. Both boosts are flat and apply at most once, so a
|
||||
# tool with a long synonym list cannot out-shout a tool with a short one.
|
||||
ACAD_MATCH_BOOST = 1.0
|
||||
SYNONYM_MATCH_BOOST = 0.5
|
||||
|
||||
_WORD_RE = re.compile(r"[a-z0-9]+")
|
||||
_RAW_WORD_RE = re.compile(r"[A-Za-z0-9]+")
|
||||
|
||||
# Dropped from both sides before phrase matching, so the synonym "delete a
|
||||
# layer" still matches "delete the layer called GEOMETRY". Articles only —
|
||||
# a general stop-word list would start matching phrases that mean other things.
|
||||
_ARTICLES = frozenset({"a", "an", "the"})
|
||||
|
||||
|
||||
def _words(text: str) -> list[str]:
|
||||
"""Lowercase word list. Unlike BM25's tokenizer, single characters survive.
|
||||
|
||||
``_tokenize`` drops ``len <= 1`` tokens, which is right for an inverted
|
||||
index and wrong for phrase matching: dropping the "a" out of "round the
|
||||
corner" would silently change which phrases can match.
|
||||
"""
|
||||
return _WORD_RE.findall(text.lower())
|
||||
|
||||
|
||||
def _phrase_words(text: str) -> list[str]:
|
||||
return [word for word in _words(text) if word not in _ARTICLES]
|
||||
|
||||
|
||||
def _names_a_command(query: str, command: str) -> bool:
|
||||
"""True when ``query`` uses ``command`` as a command, not as English.
|
||||
|
||||
Several AutoCAD command names are also ordinary nouns — LAYER, TABLE, LINE,
|
||||
POINT, TEXT, BOX, ARRAY. Boosting on a bare lowercase "layer" put
|
||||
``layer_create`` / ``layer_modify`` above ``layer_delete`` for "delete the
|
||||
layer called GEOMETRY", which is exactly backwards. A drafter reaching for
|
||||
the command types it in capitals, or types nothing else:
|
||||
|
||||
"BPOLY", "trace it with BPOLY" -> command
|
||||
"bpoly" -> command (nothing else in the query)
|
||||
"delete the layer ..." -> English; BM25 handles it
|
||||
"""
|
||||
words = _words(query)
|
||||
if len(words) == 1 and words[0] == command.lower():
|
||||
return True
|
||||
return command in _RAW_WORD_RE.findall(query)
|
||||
|
||||
|
||||
def _contains_phrase(haystack: Sequence[str], needle: Sequence[str]) -> bool:
|
||||
"""True when ``needle`` appears as a contiguous run of words in ``haystack``.
|
||||
|
||||
Word-level rather than substring, so the synonym "line" does not match
|
||||
"polyline" or "linear".
|
||||
"""
|
||||
n = len(needle)
|
||||
if not n or n > len(haystack):
|
||||
return False
|
||||
first = needle[0]
|
||||
for i, word in enumerate(haystack):
|
||||
if word == first and list(haystack[i : i + n]) == list(needle):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Derived from the MCP annotations each tool already carries, so there is no
|
||||
# second risk table to drift out of step with the registrations. The ceiling a
|
||||
# caller may ask for, most permissive first:
|
||||
#
|
||||
# any everything (default — a search must not quietly narrow itself)
|
||||
# write hide tools that delete or overwrite (entity_delete_many, layer_delete,
|
||||
# transaction_rollback, drawing_finalize, ...)
|
||||
# read only read-only query tools — the right ceiling for "how many ...?"
|
||||
RISK_CEILINGS = ("any", "write", "read")
|
||||
|
||||
MAX_SEARCH_LIMIT = 25
|
||||
|
||||
|
||||
def tool_risk(tool: Tool) -> str:
|
||||
"""``"read"`` | ``"write"`` | ``"destructive"`` for one tool.
|
||||
|
||||
``destructiveHint`` wins over ``readOnlyHint``: no tool in this catalog
|
||||
sets both, and if one ever did, the safe reading is the destructive one.
|
||||
Tools that annotate neither are treated as ``"write"`` — the honest default
|
||||
for an unannotated tool on a server whose job is mutating drawings.
|
||||
"""
|
||||
annotations = tool.annotations
|
||||
if annotations is not None:
|
||||
if annotations.destructiveHint:
|
||||
return "destructive"
|
||||
if annotations.readOnlyHint:
|
||||
return "read"
|
||||
return "write"
|
||||
|
||||
|
||||
def _risk_allowed(ceiling: str, level: str) -> bool:
|
||||
if ceiling == "any":
|
||||
return True
|
||||
if ceiling == "write":
|
||||
return level != "destructive"
|
||||
return level == "read"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchOutcome:
|
||||
"""One search: the tools shown, plus what the risk ceiling took away.
|
||||
|
||||
``hidden`` holds only the tools the caller would otherwise have *seen* —
|
||||
blocked hits inside the unfiltered top-``limit`` — not every blocked tool
|
||||
in the catalog. That keeps the notice a fact about this answer.
|
||||
"""
|
||||
|
||||
hits: tuple[Tool, ...] = ()
|
||||
hidden: tuple[Tool, ...] = ()
|
||||
risk: str = "any"
|
||||
|
||||
def notice(self) -> str:
|
||||
"""One line naming what the risk ceiling removed, or ``""``.
|
||||
|
||||
A filter that silently halves a mixed intent ("list the layers then
|
||||
delete GEOMETRY") is worse than no filter: the caller cannot tell the
|
||||
difference between "no such tool" and "not shown to you".
|
||||
"""
|
||||
if not self.hidden:
|
||||
return ""
|
||||
names = ", ".join(tool.name for tool in self.hidden)
|
||||
return (
|
||||
f"Risk filter: {len(self.hidden)} higher-risk match(es) hidden by "
|
||||
f'risk="{self.risk}" ({names}). Re-run with risk="any" to include them.'
|
||||
)
|
||||
|
||||
|
||||
class CadSearchTransform(BM25SearchTransform):
|
||||
"""BM25 tool search with the AutoCAD alias corpus folded into the index.
|
||||
|
||||
Args:
|
||||
max_results: Default number of tools returned per search.
|
||||
bm25_b: BM25 length-normalisation, left at the 0.75 textbook default —
|
||||
the value every measured number in this module was obtained with.
|
||||
Lowering it is defensible in principle (these "documents" are
|
||||
metadata records of deliberately unequal verbosity, and a tool with
|
||||
six synonyms is not a broader tool than one with two), but it was
|
||||
not what shipped, so re-measure the golden set before changing it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_results: int = 5,
|
||||
bm25_b: float = 0.75,
|
||||
always_visible: list[str] | None = None,
|
||||
search_tool_name: str = "search_tools",
|
||||
call_tool_name: str = "call_tool",
|
||||
search_result_serializer: SearchResultSerializer | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
max_results=max_results,
|
||||
always_visible=always_visible,
|
||||
search_tool_name=search_tool_name,
|
||||
call_tool_name=call_tool_name,
|
||||
search_result_serializer=search_result_serializer or serialize_search_results,
|
||||
)
|
||||
self._bm25_k1 = self._index.k1
|
||||
self._bm25_b = bm25_b
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Indexed document
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_searchable_text(self, tool: Tool) -> str:
|
||||
"""Stock searchable text plus this tool's alias vocabulary.
|
||||
|
||||
The narrowest available hook: upstream's ``_extract_searchable_text``
|
||||
is a module-level function, not a method, so it cannot be overridden in
|
||||
place — it is called and extended instead. Tools with no alias record
|
||||
(anything registered outside this server) keep the stock text
|
||||
unchanged.
|
||||
"""
|
||||
extra = alias_text(tool.name)
|
||||
stock = _stock_searchable_text(tool)
|
||||
return f"{stock} {extra}" if extra else stock
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Index lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_index(self, tools: Sequence[Tool]) -> None:
|
||||
"""Rebuild the index when the catalog's indexed text changes.
|
||||
|
||||
The hash is order-sensitive on purpose: results are mapped back to
|
||||
tools positionally, so a reordered catalog with identical text is still
|
||||
a different index.
|
||||
|
||||
No lock: this and its only caller (:meth:`rank`) are synchronous and
|
||||
await nothing, so two concurrent searches on one event loop cannot
|
||||
interleave a rebuild with a query.
|
||||
"""
|
||||
documents = [self._extract_searchable_text(t) for t in tools]
|
||||
digest = hashlib.sha256("\x00".join(documents).encode()).hexdigest()
|
||||
if digest == self._last_hash:
|
||||
return
|
||||
# Built via type() rather than importing the private _BM25Index name:
|
||||
# the base class already owns an instance, and reusing its class keeps
|
||||
# this from depending on a private module path.
|
||||
index = type(self._index)(self._bm25_k1, self._bm25_b)
|
||||
index.build(documents)
|
||||
self._index = index
|
||||
self._indexed_tools = tuple(tools)
|
||||
self._last_hash = digest
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ranking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _alias_boost(self, tool: Tool, query: str, phrase_words: Sequence[str]) -> float:
|
||||
record = aliases_for(tool.name)
|
||||
if record is None:
|
||||
return 0.0
|
||||
boost = 0.0
|
||||
if any(_names_a_command(query, command) for command in record.acad):
|
||||
boost += ACAD_MATCH_BOOST
|
||||
if any(_contains_phrase(phrase_words, _phrase_words(phrase)) for phrase in record.synonyms):
|
||||
boost += SYNONYM_MATCH_BOOST
|
||||
return boost
|
||||
|
||||
def rank(
|
||||
self,
|
||||
tools: Sequence[Tool],
|
||||
query: str,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
risk: str = "any",
|
||||
) -> SearchOutcome:
|
||||
"""Rank ``tools`` against ``query``. Pure — no Context, no I/O."""
|
||||
if risk not in RISK_CEILINGS:
|
||||
raise ValueError(f"risk must be one of {RISK_CEILINGS}, got {risk!r}")
|
||||
cutoff = self._max_results if limit is None else max(1, min(limit, MAX_SEARCH_LIMIT))
|
||||
tools = list(tools)
|
||||
if not tools:
|
||||
return SearchOutcome(risk=risk)
|
||||
self._ensure_index(tools)
|
||||
|
||||
ranked = self._index.query(query, len(tools))
|
||||
bm25_points = {position: 1.0 / (1.0 + rank) for rank, position in enumerate(ranked)}
|
||||
phrase_words = _phrase_words(query)
|
||||
|
||||
scored: list[tuple[float, str, Tool]] = []
|
||||
for position, tool in enumerate(tools):
|
||||
score = bm25_points.get(position, 0.0) + self._alias_boost(tool, query, phrase_words)
|
||||
if score > 0.0:
|
||||
scored.append((score, tool.name, tool))
|
||||
scored.sort(key=lambda row: (-row[0], row[1]))
|
||||
matches = [tool for _, _, tool in scored]
|
||||
|
||||
# Filter before truncating, so a blocked hit frees its slot for the
|
||||
# next safe tool instead of just shortening the answer.
|
||||
allowed = [tool for tool in matches if _risk_allowed(risk, tool_risk(tool))]
|
||||
hidden = tuple(
|
||||
tool for tool in matches[:cutoff] if not _risk_allowed(risk, tool_risk(tool))
|
||||
)
|
||||
return SearchOutcome(hits=tuple(allowed[:cutoff]), hidden=hidden, risk=risk)
|
||||
|
||||
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
|
||||
return list(self.rank(tools, query).hits)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The synthetic search tool
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def render(self, outcome: SearchOutcome) -> Any:
|
||||
"""Serialize an outcome, keeping the risk notice on its own line.
|
||||
|
||||
With the default (string) serializer the notice is appended as a
|
||||
trailing line. A serializer that returns structured data instead gets
|
||||
the notice as a sibling key, because there is no line to append to.
|
||||
"""
|
||||
rendered = await self._render_results(outcome.hits)
|
||||
notice = outcome.notice()
|
||||
if not notice:
|
||||
return rendered
|
||||
if isinstance(rendered, str):
|
||||
return f"{rendered}\n\n{notice}"
|
||||
return {"results": rendered, "notice": notice}
|
||||
|
||||
def _make_search_tool(self) -> Tool:
|
||||
transform = self
|
||||
default_limit = self._max_results
|
||||
|
||||
async def search_tools(
|
||||
query: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"What you want to do, in plain English or as an AutoCAD "
|
||||
"command name (FILLET, BPOLY, QSELECT, DLI)."
|
||||
)
|
||||
),
|
||||
],
|
||||
limit: Annotated[
|
||||
int,
|
||||
Field(description=(f"How many tools to return. Clamped to 1..{MAX_SEARCH_LIMIT}.")),
|
||||
] = default_limit,
|
||||
risk: Annotated[
|
||||
Literal["any", "write", "read"],
|
||||
Field(
|
||||
description=(
|
||||
"Highest risk level to return. 'any' (default) is everything; "
|
||||
"'write' hides tools that delete or overwrite; 'read' returns "
|
||||
"only read-only query tools — use it for questions like 'how "
|
||||
"many ...?'. Anything filtered out is reported in the result."
|
||||
)
|
||||
),
|
||||
] = "any",
|
||||
ctx: Context = None, # type: ignore[assignment]
|
||||
) -> Any:
|
||||
"""Find the AutoCAD tools that do a job.
|
||||
|
||||
Accepts AutoCAD command names as well as English. Returns one
|
||||
compact line per tool: name, risk level, what it does, its required
|
||||
arguments and the AutoCAD command it corresponds to. Call a result
|
||||
with call_tool.
|
||||
"""
|
||||
visible = await transform._get_visible_tools(ctx)
|
||||
outcome = transform.rank(visible, query, limit=limit, risk=risk)
|
||||
return await transform.render(outcome)
|
||||
|
||||
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
+1052
-1133
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 84 KiB |
+1319
-769
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 90 KiB |
@@ -448,10 +448,7 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
|
||||
labels = [item["repository"] for item in projects]
|
||||
scores = [item["score"] for item in projects]
|
||||
grades = [item["evidence_grade"] for item in projects]
|
||||
colors = [
|
||||
"#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781"
|
||||
for label in labels
|
||||
]
|
||||
colors = ["#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781" for label in labels]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 6.4), constrained_layout=True)
|
||||
fig.patch.set_facecolor("#f6f8fa")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+230
-12
@@ -10,14 +10,25 @@ Scope (deliberate, documented):
|
||||
|
||||
- Nominal sizes over 1 mm up to and including 500 mm.
|
||||
- IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors).
|
||||
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p.
|
||||
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS).
|
||||
- Transition/interference HOLE letters (K, M, N, P) require the ISO 286
|
||||
delta-rule and sub-stepped r/s tables; they are intentionally excluded and
|
||||
raise a ValueError naming the supported set.
|
||||
- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p, and the interference
|
||||
shafts r, s, t, u (t from 24 mm, where ISO 286 begins defining it).
|
||||
- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS), and
|
||||
K, M, N, P via the ISO 286 delta rule ``ES = -ei + Δ``, ``Δ = IT(n) −
|
||||
IT(n−1)``. That is a rule over tables already here, not new table data, which
|
||||
is why it can be pinned against published values rather than trusted.
|
||||
|
||||
r/s/t/u *are* new table data, sub-stepped more finely than the main size steps,
|
||||
so they are cross-checked in the tests against ISO 286-1's own derivation
|
||||
formulae — an independent source. r reproduces as the geometric mean of p and s
|
||||
on 24/24 steps (with p pinned before r existed); s, t and u track their
|
||||
IT7-based formulae within 1 µm on every step from 18 mm up, and the five places
|
||||
where the standard's rounding puts a published value 2-3 µm off its own formula
|
||||
are listed by name in the tests rather than hidden.
|
||||
|
||||
This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6,
|
||||
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...).
|
||||
H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...), the hole-basis
|
||||
transition and interference pairs (K7/h6, M7/h6, N7/h6, P7/h6), and the heavy
|
||||
press/drive fits (H7/r6, H7/s6, H7/t6, H7/u6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -78,10 +89,206 @@ _SHAFT_EI: dict[str, tuple[int, ...]] = {
|
||||
"p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68),
|
||||
}
|
||||
|
||||
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p")
|
||||
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS")
|
||||
# Interference shafts r/s/t/u are sub-stepped more finely than the main steps:
|
||||
# ISO 286 splits >18-30 into 18-24/24-30, >30-50 into 30-40/40-50, and every step
|
||||
# above 50 mm in two or three. Flattening them onto the main steps is a silent
|
||||
# error of tens of microns at the sizes these fits are actually used at.
|
||||
_FINE_SIZE_STEPS: tuple[tuple[float, float], ...] = (
|
||||
(1, 3),
|
||||
(3, 6),
|
||||
(6, 10),
|
||||
(10, 18),
|
||||
(18, 24),
|
||||
(24, 30),
|
||||
(30, 40),
|
||||
(40, 50),
|
||||
(50, 65),
|
||||
(65, 80),
|
||||
(80, 100),
|
||||
(100, 120),
|
||||
(120, 140),
|
||||
(140, 160),
|
||||
(160, 180),
|
||||
(180, 200),
|
||||
(200, 225),
|
||||
(225, 250),
|
||||
(250, 280),
|
||||
(280, 315),
|
||||
(315, 355),
|
||||
(355, 400),
|
||||
(400, 450),
|
||||
(450, 500),
|
||||
)
|
||||
|
||||
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$")
|
||||
# Fundamental deviations ei (µm) for the interference shafts, one per fine step.
|
||||
#
|
||||
# PROVENANCE — this is table data, not a rule, so it is cross-checked in
|
||||
# tests/test_fits.py against ISO 286-1's own derivation formulae rather than
|
||||
# trusted on its own:
|
||||
# r = geometric mean of p and s -> 24/24 steps agree within 1 µm, and p
|
||||
# was already pinned before r existed
|
||||
# s (>50 mm) = IT7 + 0.40*D -> agrees within 1 µm
|
||||
# s (<=50 mm) = IT8 + 0..4 -> monotone inside the standard's band
|
||||
# t = IT7 + 0.63*D -> 17/19 within 1 µm
|
||||
# u = IT7 + 1.00*D -> 17/20 within 1 µm
|
||||
# D is the geometric mean of the step bounds. The handful of steps where the
|
||||
# standard's rounding puts the published value 2-3 µm off its own formula are
|
||||
# listed by name in KNOWN_FORMULA_DIVERGENCES, so a reader holding ISO 286 has
|
||||
# five numbers to check rather than ninety-five.
|
||||
#
|
||||
# t is undefined below 24 mm in the standard and is None there — refused, not
|
||||
# extrapolated.
|
||||
_SHAFT_EI_FINE: dict[str, tuple[int | None, ...]] = {
|
||||
"r": (
|
||||
10,
|
||||
15,
|
||||
19,
|
||||
23,
|
||||
28,
|
||||
28,
|
||||
34,
|
||||
34,
|
||||
41,
|
||||
43,
|
||||
51,
|
||||
54,
|
||||
63,
|
||||
65,
|
||||
68,
|
||||
77,
|
||||
80,
|
||||
84,
|
||||
94,
|
||||
98,
|
||||
108,
|
||||
114,
|
||||
126,
|
||||
132,
|
||||
),
|
||||
"s": (
|
||||
14,
|
||||
19,
|
||||
23,
|
||||
28,
|
||||
35,
|
||||
35,
|
||||
43,
|
||||
43,
|
||||
53,
|
||||
59,
|
||||
71,
|
||||
79,
|
||||
92,
|
||||
100,
|
||||
108,
|
||||
122,
|
||||
130,
|
||||
140,
|
||||
158,
|
||||
170,
|
||||
190,
|
||||
208,
|
||||
232,
|
||||
252,
|
||||
),
|
||||
"t": (
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
41,
|
||||
48,
|
||||
54,
|
||||
66,
|
||||
75,
|
||||
91,
|
||||
104,
|
||||
122,
|
||||
134,
|
||||
146,
|
||||
166,
|
||||
180,
|
||||
196,
|
||||
218,
|
||||
240,
|
||||
268,
|
||||
294,
|
||||
330,
|
||||
360,
|
||||
),
|
||||
"u": (
|
||||
18,
|
||||
23,
|
||||
28,
|
||||
33,
|
||||
41,
|
||||
48,
|
||||
60,
|
||||
70,
|
||||
87,
|
||||
102,
|
||||
124,
|
||||
144,
|
||||
170,
|
||||
190,
|
||||
210,
|
||||
236,
|
||||
258,
|
||||
284,
|
||||
315,
|
||||
350,
|
||||
390,
|
||||
435,
|
||||
490,
|
||||
540,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _fine_size_index(nominal_mm: float) -> int:
|
||||
for index, (over, up_to) in enumerate(_FINE_SIZE_STEPS):
|
||||
if over < nominal_mm <= up_to:
|
||||
return index
|
||||
raise ValueError( # pragma: no cover - _size_index rejects out-of-range first
|
||||
f"No ISO 286 sub-step covers {nominal_mm} mm"
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p", "r", "s", "t", "u")
|
||||
SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS", "K", "M", "N", "P")
|
||||
|
||||
_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[K-Pk-p]|[r-ur-u])(\d{1,2})\s*$")
|
||||
|
||||
#: Holes derived from the same-letter shaft by the ISO 286 delta rule. No new
|
||||
#: table data: ES = -ei + Δ, with Δ = IT(n) − IT(n−1) taken from _IT_TABLE.
|
||||
_DELTA_RULE_HOLES = ("K", "M", "N", "P")
|
||||
|
||||
#: The highest IT grade the delta still applies at. Above it ISO 286 sets Δ = 0,
|
||||
#: so the hole is the exact mirror of the shaft.
|
||||
_DELTA_MAX_GRADE = {"K": 8, "M": 8, "N": 8, "P": 7}
|
||||
|
||||
|
||||
def _delta_rule_es(letter: str, grade: int, size_index: int, it_um: int) -> float:
|
||||
"""Upper deviation of a transition/interference hole (ISO 286 delta rule).
|
||||
|
||||
``ES = -ei + Δ`` where ``ei`` is the same-letter shaft's fundamental
|
||||
deviation and ``Δ = IT(n) − IT(n−1)``. Both inputs are already authored in
|
||||
this module, so this adds a *rule* rather than a table — and the rule is
|
||||
pinned against published values in the tests, which is what makes it
|
||||
checkable at all.
|
||||
"""
|
||||
if grade <= _IT_GRADES[0]:
|
||||
raise ValueError(
|
||||
f"Fit {letter}{grade}: the ISO 286 delta rule needs IT{grade - 1}, which is "
|
||||
f"below the authored IT{_IT_GRADES[0]}-IT{_IT_GRADES[-1]} range. Use IT5 or "
|
||||
"coarser for a transition/interference hole."
|
||||
)
|
||||
ei = float(_SHAFT_EI[letter.lower()][size_index])
|
||||
if grade > _DELTA_MAX_GRADE[letter]:
|
||||
return -ei # ISO 286: above this grade the delta is zero
|
||||
previous_it = _IT_TABLE[size_index][_IT_GRADES.index(grade) - 1]
|
||||
return -ei + float(it_um - previous_it)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -163,12 +370,23 @@ def fit_lookup(code: str, nominal_mm: float) -> FitDeviation:
|
||||
else:
|
||||
ei = float(_SHAFT_EI[letter][index])
|
||||
upper_um, lower_um = ei + it_um, ei
|
||||
elif letter in _DELTA_RULE_HOLES: # K, M, N, P — transition/interference holes
|
||||
es = _delta_rule_es(letter, grade, index, it_um)
|
||||
upper_um, lower_um = es, es - it_um
|
||||
elif letter in _SHAFT_EI_FINE: # r, s, t, u — interference shafts
|
||||
ei_um = _SHAFT_EI_FINE[letter][_fine_size_index(float(nominal_mm))]
|
||||
if ei_um is None:
|
||||
raise ValueError(
|
||||
f"Fit {letter}{grade}: ISO 286 does not define shaft {letter!r} below "
|
||||
f"24 mm, and {nominal_mm} mm is under it. Use s (defined from 1 mm) "
|
||||
"for a lighter interference fit, or explicit tol_upper/tol_lower."
|
||||
)
|
||||
ei = float(ei_um)
|
||||
upper_um, lower_um = ei + it_um, ei
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Fit letter {letter!r} is outside the authored scope. Supported: "
|
||||
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. "
|
||||
"Transition/interference hole letters (K, M, N, P) need the ISO 286 "
|
||||
"delta-rule and are not authored yet."
|
||||
f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}."
|
||||
)
|
||||
|
||||
return FitDeviation(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Exact area and perimeter for DXF boundary geometry.
|
||||
|
||||
Pure arithmetic: no ezdxf, no COM, no I/O. Both backends call the same functions,
|
||||
so the headless and live engines cannot drift apart on the numbers, and the tests
|
||||
exercise the real implementation rather than a stand-in.
|
||||
|
||||
The thing that makes this exact rather than approximate is the **bulge**. A DXF
|
||||
polyline edge carries a bulge factor, and a non-zero bulge means that edge is a
|
||||
circular arc — always circular, never a spline — so the enclosed area is the
|
||||
shoelace of the vertices plus each arc's signed circular segment, and the walked
|
||||
length is arc length rather than chord length. Both are closed-form.
|
||||
|
||||
Getting this wrong is not a rounding matter. Measured on a 100x100 square with
|
||||
one edge bulged into an outward semicircle: ignoring bulge reports 10000 against
|
||||
a true 13926.9908, i.e. **28.2% low**, and a perimeter 12.5% low.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
#: Max chord deviation (drawing units) when geometry has to be flattened because
|
||||
#: it is not closed-form — splines, partial ellipses.
|
||||
DEFAULT_FLATTEN_TOLERANCE = 0.001
|
||||
|
||||
#: Above this vertex count the O(n^2) self-intersection test is skipped and the
|
||||
#: answer is reported as unknown. A slow honest "I did not check" beats both a
|
||||
#: hang and a confident False.
|
||||
MAX_SELF_INTERSECT_VERTS = 2000
|
||||
|
||||
_EPS = 1e-12
|
||||
|
||||
|
||||
def polygon_area_perimeter(
|
||||
vertices: Sequence[tuple[float, float, float]],
|
||||
closed: bool,
|
||||
) -> tuple[float, float]:
|
||||
"""Exact area and perimeter of a bulge-carrying polygon.
|
||||
|
||||
``vertices`` are ``(x, y, bulge)``; the bulge belongs to the edge *leaving*
|
||||
that vertex, which is the DXF convention.
|
||||
|
||||
``closed=False`` still returns the area of the implicitly closed region —
|
||||
that is what AutoCAD's AREA command does — but the perimeter is the length
|
||||
actually walked, without the closing segment. Callers must report the
|
||||
assumption rather than let it pass silently.
|
||||
|
||||
Two vertices are not degenerate when they carry bulges: that is how DXF
|
||||
stores a circle built from two semicircular arcs, and the shoelace over its
|
||||
two points is genuinely 0 while the two circular segments are the entire
|
||||
shape. Only zero or one vertex bounds nothing.
|
||||
"""
|
||||
count = len(vertices)
|
||||
if count < 2:
|
||||
return 0.0, _open_length(vertices)
|
||||
|
||||
shoelace = 0.0
|
||||
for i in range(count):
|
||||
x1, y1, _ = vertices[i]
|
||||
x2, y2, _ = vertices[(i + 1) % count]
|
||||
shoelace += x1 * y2 - x2 * y1
|
||||
shoelace *= 0.5
|
||||
|
||||
segments = 0.0
|
||||
perimeter = 0.0
|
||||
limit = count if closed else count - 1
|
||||
for i in range(count):
|
||||
x1, y1, bulge = vertices[i]
|
||||
x2, y2, _ = vertices[(i + 1) % count]
|
||||
chord = math.hypot(x2 - x1, y2 - y1)
|
||||
if abs(bulge) < _EPS or chord < _EPS:
|
||||
if i < limit:
|
||||
perimeter += chord
|
||||
continue
|
||||
theta = 4.0 * math.atan(bulge) # signed sweep, radians
|
||||
radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
|
||||
# The circular segment cut off by the chord. Signed: a positive bulge
|
||||
# bows the edge outward and adds material, a negative one removes it.
|
||||
segments += math.copysign(
|
||||
radius * radius / 2.0 * (abs(theta) - math.sin(abs(theta))), theta
|
||||
)
|
||||
if i < limit:
|
||||
perimeter += radius * abs(theta)
|
||||
|
||||
return abs(shoelace + segments), perimeter
|
||||
|
||||
|
||||
def _open_length(vertices: Sequence[tuple[float, float, float]]) -> float:
|
||||
return sum(
|
||||
math.dist(vertices[i][:2], vertices[i + 1][:2]) for i in range(max(0, len(vertices) - 1))
|
||||
)
|
||||
|
||||
|
||||
def is_self_intersecting(vertices: Sequence[tuple[float, float]]) -> bool | None:
|
||||
"""True when non-adjacent edges of the closed loop cross; None if not checked.
|
||||
|
||||
Worth reporting because the shoelace *cancels* crossed lobes rather than
|
||||
failing: a bowtie made of two 2500-unit triangles measures 0.0, which is a
|
||||
confident wrong number of exactly the kind this release removes.
|
||||
"""
|
||||
count = len(vertices)
|
||||
if count < 4:
|
||||
return False
|
||||
if count > MAX_SELF_INTERSECT_VERTS:
|
||||
return None # unknown beats a guess
|
||||
|
||||
for i in range(count):
|
||||
a1, a2 = vertices[i], vertices[(i + 1) % count]
|
||||
for j in range(i + 1, count):
|
||||
if j == i or (j + 1) % count == i or j == (i + 1) % count:
|
||||
continue # adjacent edges share an endpoint by construction
|
||||
b1, b2 = vertices[j], vertices[(j + 1) % count]
|
||||
if _segments_cross(a1, a2, b1, b2):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _orientation(p, q, r) -> float:
|
||||
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
||||
|
||||
|
||||
def _segments_cross(p1, p2, q1, q2) -> bool:
|
||||
"""Proper crossing only — touching at an endpoint is not an intersection."""
|
||||
d1 = _orientation(q1, q2, p1)
|
||||
d2 = _orientation(q1, q2, p2)
|
||||
d3 = _orientation(p1, p2, q1)
|
||||
d4 = _orientation(p1, p2, q2)
|
||||
return ((d1 > _EPS) != (d2 > _EPS)) and ((d3 > _EPS) != (d4 > _EPS))
|
||||
|
||||
|
||||
def ellipse_area(major: float, ratio: float) -> float:
|
||||
"""pi*a*b — closed form, exact."""
|
||||
return math.pi * abs(float(major)) * abs(float(major) * float(ratio))
|
||||
|
||||
|
||||
def ellipse_perimeter_ramanujan(major: float, ratio: float) -> float:
|
||||
"""Ramanujan's second approximation.
|
||||
|
||||
Relative error is around 1e-10 for ratio >= 0.1, but it is an approximation
|
||||
and not closed form, so anything reporting it must set ``perimeter_exact``
|
||||
to False. Exact for a circle (ratio == 1), which is the useful sanity check.
|
||||
"""
|
||||
a = abs(float(major))
|
||||
b = abs(float(major) * float(ratio))
|
||||
if a < _EPS or b < _EPS:
|
||||
return 4.0 * max(a, b)
|
||||
h = ((a - b) ** 2) / ((a + b) ** 2)
|
||||
return math.pi * (a + b) * (1.0 + (3.0 * h) / (10.0 + math.sqrt(4.0 - 3.0 * h)))
|
||||
@@ -108,13 +108,18 @@ def preflight_drawing(
|
||||
|
||||
questions: list[PreflightQuestion] = []
|
||||
required = (
|
||||
("units", "MISSING_UNITS", "Çizim birimi nedir?", ["mm", "inch"]),
|
||||
("part_type", "MISSING_PART_TYPE", "Üretilecek parça/çizim türü nedir?", []),
|
||||
("dimensions", "MISSING_DIMENSIONS", "Ana üretim ölçüleri nelerdir?", []),
|
||||
("units", "MISSING_UNITS", "What drawing units should be used?", ["mm", "inch"]),
|
||||
(
|
||||
"part_type",
|
||||
"MISSING_PART_TYPE",
|
||||
"What kind of part or drawing is being produced?",
|
||||
[],
|
||||
),
|
||||
("dimensions", "MISSING_DIMENSIONS", "What are the main production dimensions?", []),
|
||||
(
|
||||
"tolerance_policy",
|
||||
"MISSING_TOLERANCE_POLICY",
|
||||
"Hangi tolerans politikası kullanılmalı?",
|
||||
"Which tolerance policy should apply?",
|
||||
["ISO 2768-m", "explicit", "none"],
|
||||
),
|
||||
)
|
||||
@@ -138,7 +143,7 @@ def preflight_drawing(
|
||||
"CONFLICTING_REQUIREMENT",
|
||||
field_name,
|
||||
[seen_constraints[field_name], value],
|
||||
f"{field_name} için birbiriyle çelişen değerler verildi.",
|
||||
f"Conflicting values were supplied for {field_name}.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -59,11 +59,50 @@ async def apply_iso_a3_titleblock(
|
||||
*,
|
||||
metadata: TitleBlockMetadata,
|
||||
origin: tuple[float, float] = (0.0, 0.0),
|
||||
layout: str | None = None,
|
||||
) -> dict:
|
||||
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet)."""
|
||||
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet).
|
||||
|
||||
With `layout`, the sheet is drawn on that paper-space layout — which is
|
||||
where a title block belongs, since the border frames the printed sheet
|
||||
rather than the model. The caller's current space is restored afterwards, so
|
||||
asking for a sheet border does not silently move them onto the sheet.
|
||||
Without `layout` it draws in whatever space is current, as it always did.
|
||||
"""
|
||||
|
||||
ox, oy = float(origin[0]), float(origin[1])
|
||||
|
||||
previous_space = "Model"
|
||||
if layout is not None:
|
||||
listing = await backend.layout_list()
|
||||
names = {str(entry) for entry in (listing.get("layouts") or [])}
|
||||
if layout not in names:
|
||||
raise RuntimeError(
|
||||
f"apply_iso_a3_titleblock: layout {layout!r} does not exist "
|
||||
f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with "
|
||||
"layout_create first."
|
||||
)
|
||||
previous_space = getattr(backend, "_current_space", "Model")
|
||||
switched = await backend.layout_set_current(layout)
|
||||
if not switched.get("ok", True):
|
||||
raise RuntimeError(f"apply_iso_a3_titleblock: {switched.get('error')}")
|
||||
|
||||
try:
|
||||
return await _draw_titleblock(backend, metadata=metadata, ox=ox, oy=oy, layout=layout)
|
||||
finally:
|
||||
if layout is not None:
|
||||
await backend.layout_set_current(previous_space)
|
||||
|
||||
|
||||
async def _draw_titleblock(
|
||||
backend: AutoCADBackend,
|
||||
*,
|
||||
metadata: TitleBlockMetadata,
|
||||
ox: float,
|
||||
oy: float,
|
||||
layout: str | None,
|
||||
) -> dict:
|
||||
|
||||
# ---- outer + inner borders -------------------------------------------------
|
||||
outer_pts = [
|
||||
[ox, oy],
|
||||
@@ -263,6 +302,8 @@ async def apply_iso_a3_titleblock(
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"layout": layout or "Model",
|
||||
"outer_border": outer.handle,
|
||||
"inner_border": inner.handle,
|
||||
"titleblock_lines": titleblock_lines,
|
||||
|
||||
+59
-8
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -32,9 +32,31 @@ classifiers = [
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"fastmcp>=3.0",
|
||||
"ezdxf>=1.3",
|
||||
"pydantic>=2.0",
|
||||
# These ranges are what an *installer* gets from PyPI, so they stay ranges:
|
||||
# a library that pins its dependencies exactly is unco-installable. They are
|
||||
# deliberately narrow because a range is all a `pip install` has to protect
|
||||
# it. The floor of each range is the version this release is tested against.
|
||||
#
|
||||
# What CI and contributors get is different and exact: uv.lock pins the
|
||||
# whole transitive graph (including the dev tools) to the set the suite was
|
||||
# last run against, and every locked lane installs with `--locked` so an
|
||||
# untested upstream cannot appear silently. Ranges guard users; the lock
|
||||
# guards us. Regenerate the lock with `uv lock` whenever these change.
|
||||
#
|
||||
# Every ceiling is the next major. A tighter cap (e.g. `<3.5`) would block a
|
||||
# user whose environment already holds a newer minor from installing us at
|
||||
# all, and it buys nothing here: the lock is what protects our own runs, and
|
||||
# `deps-floating.yml` runs the suite against unpinned latest on a schedule,
|
||||
# so a breaking upstream minor surfaces as a failing scheduled job rather
|
||||
# than as a silent CI-green-but-users-broken state.
|
||||
#
|
||||
# fastmcp 3.x moves fast and `fastmcp` is now a thin meta-package
|
||||
# (Requires-Dist: fastmcp-slim[client,server]), so a new minor can reshape
|
||||
# the API we build on. If deps-floating goes red, add a targeted `!=` for
|
||||
# the bad version rather than lowering this ceiling.
|
||||
"fastmcp>=3.4.5,<4",
|
||||
"ezdxf>=1.4,<2",
|
||||
"pydantic>=2.0,<3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -44,11 +66,20 @@ Issues = "https://github.com/U-C4N/Autocad-MCP/issues"
|
||||
Changelog = "https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md"
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Rendering needs an extra — the core install cannot draw pixels.
|
||||
# ezdxf.addons.drawing.frontend does an unconditional top-level `import
|
||||
# PIL.Image`, so Pillow is required by *every* ezdxf render path (PNG
|
||||
# screenshot and PDF export alike), not only by the COM window-capture path
|
||||
# it is listed under below. `pip install autocad-mcp-pro` on its own therefore
|
||||
# renders nothing. Install `[pdf]` (or `[full]`) for rendering on any platform:
|
||||
# Pillow comes in transitively as a matplotlib dependency.
|
||||
com = [
|
||||
"pywin32>=306",
|
||||
# Win32 window capture for the live-AutoCAD screenshot path.
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
pdf = [
|
||||
# Also satisfies ezdxf's hard Pillow requirement, transitively.
|
||||
"matplotlib>=3.8",
|
||||
]
|
||||
full = [
|
||||
@@ -63,12 +94,24 @@ autocad-mcp = "server:main"
|
||||
# without a --from indirection (MCP registry / uvx convention).
|
||||
autocad-mcp-pro = "server:main"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
# PEP 735 dependency group (was [tool.uv] dev-dependencies, which uv now warns
|
||||
# is deprecated). The standard spelling is understood by uv *and* by pip >=25.1
|
||||
# (`pip install --group dev`), so the dev toolchain has one definition whether
|
||||
# or not you use uv.
|
||||
#
|
||||
# These are the exact gates CI runs, and uv.lock freezes them: `uv sync
|
||||
# --locked` reproduces this list bit-for-bit, so a lint or test job can no
|
||||
# longer be broken by a tool release nobody chose.
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.8",
|
||||
# Exact pin *and* locked: the formatter's output changes between minors, so
|
||||
# a floating ruff makes `ruff format --check .` fail on unchanged code. This
|
||||
# is now the only place the version is written — the workflows install ruff
|
||||
# from the lock rather than restating it.
|
||||
"ruff==0.16.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -78,7 +121,15 @@ build-backend = "hatchling.build"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
# Flat layout: no directory matches the project name, so hatchling needs an
|
||||
# explicit file selection (GH issue #2). server.py imports config + security.
|
||||
only-include = ["server.py", "config.py", "security.py", "version.py", "backends", "engineering"]
|
||||
only-include = [
|
||||
"server.py",
|
||||
"config.py",
|
||||
"security.py",
|
||||
"version.py",
|
||||
"backends",
|
||||
"engineering",
|
||||
"discovery",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Generate ``docs/tool-inventory.json`` — a machine-readable snapshot of the surface.
|
||||
|
||||
Everything here is derived from the **live component registry**, never by
|
||||
parsing server.py's text. That is the whole point: the release gates used to
|
||||
count the literal string ``@mcp.tool(`` in server.py, which made that string
|
||||
load-bearing anywhere in the file (writing it in a docstring inflated the
|
||||
count, so the one docstring that needed the example had to spell it without its
|
||||
parentheses). A snapshot taken from the registry cannot be fooled by prose, and
|
||||
it carries far more than a count — every tool's group, cost and summary, plus
|
||||
the resource and prompt inventories.
|
||||
|
||||
The snapshot is read back by ``tests/test_tool_inventory.py`` (drift gate) and
|
||||
by ``tests/test_release_consistency.py`` (README release-snapshot counts).
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/generate_tool_inventory.py # write the file
|
||||
python scripts/generate_tool_inventory.py --check # exit 1 if it drifted
|
||||
|
||||
``--check`` is the same comparison the test makes, for use outside pytest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Make the project root importable when this script is run from anywhere.
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
#: Where the generated snapshot lives (docs, not a shipped package file — it is
|
||||
#: deliberately absent from the wheel's ``only-include`` and from the Docker
|
||||
#: image, so the Dockerfile/wheel parity gate is unaffected).
|
||||
INVENTORY_PATH = _PROJECT_ROOT / "docs" / "tool-inventory.json"
|
||||
|
||||
#: Path as written in messages, so a failure reads the same on every platform.
|
||||
INVENTORY_REL_PATH = "docs/tool-inventory.json"
|
||||
|
||||
#: The one string every failure message must contain.
|
||||
REGENERATE_COMMAND = "python scripts/generate_tool_inventory.py"
|
||||
|
||||
#: Bump when the document's shape changes (not when its contents change).
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
_FILE_COMMENT = (
|
||||
"GENERATED FILE - do not edit by hand. Derived from the live FastMCP "
|
||||
f"component registry; regenerate with `{REGENERATE_COMMAND}`."
|
||||
)
|
||||
|
||||
# Ordered so a diff of the annotations block is stable.
|
||||
_ANNOTATION_FIELDS = (
|
||||
"title",
|
||||
"readOnlyHint",
|
||||
"destructiveHint",
|
||||
"idempotentHint",
|
||||
"openWorldHint",
|
||||
)
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
"""The version every other release gate keys off (pyproject is the source)."""
|
||||
with (_PROJECT_ROOT / "pyproject.toml").open("rb") as stream:
|
||||
return str(tomllib.load(stream)["project"]["version"])
|
||||
|
||||
|
||||
def _components() -> dict[str, Any]:
|
||||
"""The untransformed local component registry — tools, resources, prompts.
|
||||
|
||||
``server._registered_tools()`` reads the same mapping for the tool half; this
|
||||
is the resource/prompt half of it. Reading the registry (rather than
|
||||
``list_resources`` / ``list_prompts``) keeps the snapshot below every
|
||||
transform layer, so what gets committed is what is *registered*, not what
|
||||
some discovery mode happens to advertise.
|
||||
"""
|
||||
import server
|
||||
|
||||
return server.mcp._local_provider._components
|
||||
|
||||
|
||||
def _annotations(tool: Any) -> dict[str, Any]:
|
||||
"""The tool's own MCP annotations, minus the fields it left unset."""
|
||||
annotations = getattr(tool, "annotations", None)
|
||||
if annotations is None:
|
||||
return {}
|
||||
return {
|
||||
field: getattr(annotations, field)
|
||||
for field in _ANNOTATION_FIELDS
|
||||
if getattr(annotations, field, None) is not None
|
||||
}
|
||||
|
||||
|
||||
def _tool_rows(tools: list[Any], groups: dict[str, list[str]]) -> list[dict[str, Any]]:
|
||||
"""One row per registered tool, sorted by name.
|
||||
|
||||
``cost`` and ``summary`` come from the ``meta["cad"]`` card that ``@cad_tool``
|
||||
writes; ``group`` from ``server._tool_groups()``, i.e. from the tool's own
|
||||
tags. Aliases are deliberately *not* copied here — ``discovery/aliases.py``
|
||||
is their single source of truth and already has its own coverage gate.
|
||||
"""
|
||||
group_of = {name: label for label, names in groups.items() for name in names}
|
||||
rows = []
|
||||
for tool in tools:
|
||||
cad = (getattr(tool, "meta", None) or {}).get("cad") or {}
|
||||
rows.append(
|
||||
{
|
||||
"name": tool.name,
|
||||
"group": group_of.get(tool.name),
|
||||
"cost": cad.get("cost"),
|
||||
"summary": cad.get("summary"),
|
||||
"tags": sorted(getattr(tool, "tags", None) or ()),
|
||||
"annotations": _annotations(tool),
|
||||
}
|
||||
)
|
||||
return sorted(rows, key=lambda row: row["name"])
|
||||
|
||||
|
||||
def _resource_rows(components: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Plain resources and resource templates in one uniformly-shaped list.
|
||||
|
||||
FastMCP files them under two registry prefixes (``resource:`` and
|
||||
``template:``); the README counts them as one number, so they are merged and
|
||||
told apart by ``templated``.
|
||||
"""
|
||||
rows = []
|
||||
for key, component in components.items():
|
||||
if key.startswith("resource:"):
|
||||
uri, templated, parameters = str(component.uri), False, []
|
||||
elif key.startswith("template:"):
|
||||
uri, templated = str(component.uri_template), True
|
||||
parameters = sorted(
|
||||
(getattr(component, "parameters", None) or {}).get("properties", {})
|
||||
)
|
||||
else:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"uri": uri,
|
||||
"name": component.name,
|
||||
"description": component.description or "",
|
||||
"mime_type": component.mime_type,
|
||||
"tags": sorted(getattr(component, "tags", None) or ()),
|
||||
"templated": templated,
|
||||
"parameters": parameters,
|
||||
}
|
||||
)
|
||||
return sorted(rows, key=lambda row: row["uri"])
|
||||
|
||||
|
||||
def _prompt_rows(components: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for key, component in components.items():
|
||||
if not key.startswith("prompt:"):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": component.name,
|
||||
"description": component.description or "",
|
||||
"tags": sorted(getattr(component, "tags", None) or ()),
|
||||
"arguments": [
|
||||
{"name": argument.name, "required": bool(argument.required)}
|
||||
for argument in (component.arguments or [])
|
||||
],
|
||||
}
|
||||
)
|
||||
return sorted(rows, key=lambda row: row["name"])
|
||||
|
||||
|
||||
async def build_inventory() -> dict[str, Any]:
|
||||
"""Build the snapshot document from the live registry.
|
||||
|
||||
Raises rather than emitting an empty section: committing an inventory that
|
||||
silently lost every tool would be worse than a broken generator, because the
|
||||
drift gate would then happily lock the emptiness in.
|
||||
"""
|
||||
import server
|
||||
|
||||
components = _components()
|
||||
tools = await server._registered_tools()
|
||||
groups = await server._tool_groups()
|
||||
tool_rows = _tool_rows(tools, groups)
|
||||
resource_rows = _resource_rows(components)
|
||||
prompt_rows = _prompt_rows(components)
|
||||
for label, rows in (
|
||||
("tools", tool_rows),
|
||||
("resources", resource_rows),
|
||||
("prompts", prompt_rows),
|
||||
):
|
||||
if not rows:
|
||||
raise RuntimeError(
|
||||
f"refusing to write an inventory with zero {label}: the component "
|
||||
"registry looks unreadable (FastMCP layout change?)"
|
||||
)
|
||||
return {
|
||||
"_comment": _FILE_COMMENT,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"package_version": _pyproject_version(),
|
||||
"totals": {
|
||||
"tools": len(tool_rows),
|
||||
"resources": len(resource_rows),
|
||||
"prompts": len(prompt_rows),
|
||||
"groups": len(groups),
|
||||
},
|
||||
"groups": {label: len(names) for label, names in sorted(groups.items())},
|
||||
"tools": tool_rows,
|
||||
"resources": resource_rows,
|
||||
"prompts": prompt_rows,
|
||||
}
|
||||
|
||||
|
||||
def render(inventory: dict[str, Any]) -> str:
|
||||
"""Serialize deterministically: same registry in, byte-identical text out."""
|
||||
return json.dumps(inventory, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate docs/tool-inventory.json from the live MCP registry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="do not write; exit 1 if the committed file has drifted from the live surface",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
text = render(asyncio.run(build_inventory()))
|
||||
|
||||
if args.check:
|
||||
current = INVENTORY_PATH.read_text(encoding="utf-8") if INVENTORY_PATH.exists() else None
|
||||
if current == text:
|
||||
print(f"ok: {INVENTORY_REL_PATH} matches the live surface")
|
||||
return 0
|
||||
reason = "is missing" if current is None else "has drifted from the live surface"
|
||||
print(f"DRIFT: {INVENTORY_REL_PATH} {reason}")
|
||||
print(f" regenerate it with: {REGENERATE_COMMAND}")
|
||||
return 1
|
||||
|
||||
INVENTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
INVENTORY_PATH.write_text(text, encoding="utf-8", newline="\n")
|
||||
inventory = json.loads(text)
|
||||
totals = inventory["totals"]
|
||||
print(
|
||||
f"wrote {INVENTORY_REL_PATH}: {totals['tools']} tools, "
|
||||
f"{totals['resources']} resources, {totals['prompts']} prompts, "
|
||||
f"{totals['groups']} groups"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+142
-38
@@ -6,6 +6,7 @@ Path traversal protection and command injection prevention.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +30,38 @@ _BLOCKED_PATH_PATTERNS = re.compile(
|
||||
)
|
||||
|
||||
|
||||
_DRIVE_ANCHOR = re.compile(r"^[A-Za-z]:$")
|
||||
|
||||
|
||||
def _reject_non_local_anchor(original: str, resolved: Path) -> None:
|
||||
r"""Every guard below compares against plain ``X:\`` paths.
|
||||
|
||||
Windows accepts three other anchors that name the same files but compare
|
||||
unequal: the extended-length prefix (``\\?\C:\...``), UNC (``\\host\share``)
|
||||
and the admin shares (``\\localhost\C$``). The backslash spellings are
|
||||
already rejected by ``_BLOCKED_PATH_PATTERNS``, but ``pathlib`` normalises
|
||||
the *forward-slash* spellings into them only after ``.resolve()`` — so they
|
||||
slipped past the pattern check and then failed to match ``C:/Windows`` in
|
||||
the system-directory loop. Proven: ``//?/C:/Windows/win.ini`` handed back a
|
||||
working handle to ``C:\Windows\win.ini``.
|
||||
|
||||
A hard input rejection, so a plain ``ToolError`` like its siblings — not a
|
||||
capability refusal, which means "this backend cannot" and would wrongly
|
||||
suggest another engine would accept it.
|
||||
"""
|
||||
drive = resolved.drive
|
||||
if _DRIVE_ANCHOR.match(drive):
|
||||
return
|
||||
log.warning("Blocked non-local path anchor: %s -> %s", original, resolved)
|
||||
raise ToolError(
|
||||
f"Path rejected: '{original}' resolves to '{resolved}', which is not a "
|
||||
f"plain local drive path (anchor '{drive}'). Extended-length (\\\\?\\), "
|
||||
"device and UNC (\\\\host\\share, \\\\localhost\\C$) paths bypass the "
|
||||
"system-directory and allowed-root checks and are not accepted. Use a "
|
||||
"normal 'X:\\folder\\file.dxf' path."
|
||||
)
|
||||
|
||||
|
||||
def validate_path(path: str, *, allow_write: bool = False) -> Path:
|
||||
"""Resolve and validate a file path against security rules.
|
||||
|
||||
@@ -49,6 +82,12 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
|
||||
|
||||
resolved = Path(path).resolve()
|
||||
|
||||
# After .resolve() (drive is only meaningful once resolved) and before the
|
||||
# system-directory loop, whose comparisons this is protecting. POSIX has no
|
||||
# drive component at all, so the check is Windows-only by construction.
|
||||
if os.name == "nt":
|
||||
_reject_non_local_anchor(path, resolved)
|
||||
|
||||
_SYSTEM_DIRS = [
|
||||
Path("C:/Windows").resolve(),
|
||||
Path("C:/Program Files").resolve(),
|
||||
@@ -103,56 +142,107 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command / LISP sanitization
|
||||
#
|
||||
# Why it is NOT incoherent to block INSERT/SAVEAS/NEW/OPEN/PURGE/ERASE here
|
||||
# while `block_insert` / `drawing_save_as` / `drawing_new` / `drawing_open` /
|
||||
# `drawing_purge` / `entity_delete` are tools — this gets re-raised, so it is
|
||||
# recorded once:
|
||||
#
|
||||
# `sanitize_command` and `sanitize_lisp` each have exactly ONE caller, both of
|
||||
# them the COM-only free-text escape hatch into SendCommand. The policy is
|
||||
# scoped to the CHANNEL, not to the operation, which is capability narrowing
|
||||
# rather than a contradiction:
|
||||
#
|
||||
# * `block_insert(name, x, y, ...)` takes a block ALREADY in the drawing plus
|
||||
# typed geometry. The INSERT *command* takes a file path and pulls in an
|
||||
# external DWG, which can carry an attached VBA/LISP payload. Same name,
|
||||
# different capability.
|
||||
# * `entity_delete(handle)` deletes one named entity. `ERASE ALL` does not.
|
||||
# * `drawing_save_as` / `_open` / `_new` route their path through
|
||||
# validate_path. A free-text `SAVEAS C:\...` routes through nothing.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DANGEROUS_COMMANDS = [
|
||||
# Matched against the extracted VERB, so the "_XXX" duplicates the old list
|
||||
# carried are gone — command_verbs() strips every decorator AutoCAD accepts.
|
||||
#
|
||||
# Single-letter aliases are deliberately absent. `E` is the ERASE alias, but it
|
||||
# is also the Extents *option* of ZOOM, so blocking it refuses `_ZOOM\nE\n` —
|
||||
# the canonical zoom-extents macro, and this repo's own safe-command fixture.
|
||||
# A guardrail that blocks the most common harmless macro gets switched off, and
|
||||
# then it guards nothing.
|
||||
_DANGEROUS_COMMANDS = frozenset(
|
||||
{
|
||||
# destructive to the drawing
|
||||
"ERASE",
|
||||
"_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",
|
||||
# ends or overwrites the session / file
|
||||
"QUIT",
|
||||
"EXIT",
|
||||
"CLOSE",
|
||||
"CLOSEALL",
|
||||
"QSAVE",
|
||||
"SAVE",
|
||||
"SAVEAS",
|
||||
"NEW",
|
||||
"OPEN",
|
||||
"RECOVER",
|
||||
"RECOVERALL",
|
||||
# loads or runs code — every channel found while probing
|
||||
"SHELL",
|
||||
"_SHELL",
|
||||
"SCRIPT",
|
||||
"_SCRIPT",
|
||||
"SCR",
|
||||
"APPLOAD",
|
||||
"_APPLOAD",
|
||||
"NETLOAD",
|
||||
"_NETLOAD",
|
||||
"ARX",
|
||||
"VBARUN",
|
||||
"_VBARUN",
|
||||
"VBALOAD",
|
||||
"_VBALOAD",
|
||||
"VBASTMT",
|
||||
"VBAIDE",
|
||||
"VBAMAN",
|
||||
"CUILOAD",
|
||||
"MENULOAD",
|
||||
"MENU",
|
||||
"CUI",
|
||||
# writes files outside the tool layer's path validation
|
||||
"EXPORT",
|
||||
"ETRANSMIT",
|
||||
"PUBLISH",
|
||||
"SECURITYOPTIONS",
|
||||
"_SECURITYOPTIONS",
|
||||
]
|
||||
|
||||
_DANGEROUS_COMMAND_PATTERNS = re.compile(
|
||||
r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b",
|
||||
re.IGNORECASE,
|
||||
}
|
||||
)
|
||||
|
||||
# AutoCAD accepts a command with any mix of these leading decorators:
|
||||
# _ international (locale-independent) form
|
||||
# . suppress a redefined command
|
||||
# - the command-line (no dialog) variant
|
||||
# ' transparent
|
||||
_COMMAND_DECORATORS = "_.-*'"
|
||||
|
||||
|
||||
def command_verbs(command: str) -> list[str]:
|
||||
"""Every command verb in a SendCommand string.
|
||||
|
||||
SendCommand takes a whole macro, not one command: segments are separated by
|
||||
a newline or ``;``, and only the FIRST token of a segment is a verb — the
|
||||
rest is argument data. Scanning the raw string with a word-boundary regex is
|
||||
what refused ``MTEXT 0,0 10,10 Do not erase this part``, which is annotation
|
||||
text, while still missing spellings AutoCAD accepts.
|
||||
"""
|
||||
verbs: list[str] = []
|
||||
for segment in re.split(r"[\n;]+", command):
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
token = segment.split()[0].split(",")[0].lstrip(_COMMAND_DECORATORS)
|
||||
if token:
|
||||
verbs.append(token.upper())
|
||||
return verbs
|
||||
|
||||
|
||||
# Dangerous LISP symbols. We match these as **bare tokens anywhere** in the
|
||||
# expression — not only when they appear right after `(` — because aliasing
|
||||
# tricks like `(setq f command)(f "ERASE")` reference the symbol as a value.
|
||||
@@ -191,6 +281,12 @@ _DANGEROUS_LISP_PATTERNS = [
|
||||
r"\*push-error-using-stack\*",
|
||||
]
|
||||
|
||||
# A double-quoted literal is DATA — drawing text, a layer name, a note. The
|
||||
# symbol denylist must not read it. Without this, an engineering CAD server
|
||||
# refused its own vocabulary: '(setq desc "Max load 500 kg")' matched
|
||||
# r"(?:auto)?load" and '(setq layer "C:CENTER")' matched r"c:[\w-]+".
|
||||
_LISP_STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
|
||||
_DANGEROUS_LISP_PATTERN = re.compile(
|
||||
r"(?:(?<![\w-])|^)(?:" + "|".join(_DANGEROUS_LISP_PATTERNS) + r")(?![\w-])",
|
||||
re.IGNORECASE,
|
||||
@@ -210,12 +306,17 @@ def sanitize_command(command: str) -> str:
|
||||
log.info("Dangerous commands enabled — bypassing sanitization for: %s", command[:80])
|
||||
return command
|
||||
|
||||
if _DANGEROUS_COMMAND_PATTERNS.search(command):
|
||||
blocked = {verb for verb in command_verbs(command) if verb in _DANGEROUS_COMMANDS}
|
||||
if blocked:
|
||||
log.warning("Blocked dangerous command: %s", command[:120])
|
||||
raise ToolError(
|
||||
f"Command rejected: '{command[:80]}' contains a restricted command. "
|
||||
"Restricted commands include ERASE, DELETE, PURGE, QUIT, SHELL, SCRIPT, etc. "
|
||||
"Set DANGEROUS_COMMANDS_ENABLED=true to allow all commands."
|
||||
f"Command rejected: {', '.join(sorted(blocked))} "
|
||||
f"{'is a restricted command' if len(blocked) == 1 else 'are restricted commands'}. "
|
||||
"This denylist is a guardrail against accidental destruction, not a "
|
||||
"security boundary — "
|
||||
"prefer the typed tools (entity_delete, drawing_save_as, block_insert, "
|
||||
"drawing_purge), which validate their arguments. Set "
|
||||
"DANGEROUS_COMMANDS_ENABLED=true to allow all commands."
|
||||
)
|
||||
|
||||
return command
|
||||
@@ -236,7 +337,10 @@ def sanitize_lisp(expression: str) -> str:
|
||||
)
|
||||
return expression
|
||||
|
||||
if _DANGEROUS_LISP_PATTERN.search(expression):
|
||||
# Blank the literals, then scan. The ORIGINAL expression is returned:
|
||||
# this decides whether to run it, it does not rewrite it.
|
||||
code_only = _LISP_STRING_LITERAL.sub('""', expression)
|
||||
if _DANGEROUS_LISP_PATTERN.search(code_only):
|
||||
log.warning("Blocked dangerous LISP expression: %s", expression[:120])
|
||||
raise ToolError(
|
||||
"LISP expression rejected: contains a restricted function. "
|
||||
|
||||
+10
-3
@@ -7,12 +7,12 @@
|
||||
"url": "https://github.com/U-C4N/Autocad-MCP",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "autocad-mcp-pro",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
@@ -33,7 +33,14 @@
|
||||
},
|
||||
{
|
||||
"name": "TOOL_PROFILE",
|
||||
"description": "Exposed tool profile: full (default), core or lean.",
|
||||
"description": "Exposed tool profile: full (default) or lean.",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
},
|
||||
{
|
||||
"name": "DISCOVERY_MODE",
|
||||
"description": "Tool discovery: off (default) advertises the full catalog; search replaces it with search_tools plus a call_tool proxy.",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
"""Shared fixtures for AutoCAD MCP tests."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def pin_headless_backend(request, monkeypatch):
|
||||
"""Every test talks to the headless engine unless it says otherwise.
|
||||
|
||||
This suite runs on developer machines that have AutoCAD installed, and a
|
||||
test that opens ``Client(server.mcp)`` without pinning would run the real
|
||||
lifespan: ``AUTOCAD_MCP_BACKEND`` unset means ``auto``, which on Windows
|
||||
means attaching to the operator's open, unsaved drawing and creating
|
||||
entities in it. That has actually happened here. Defaulting the whole suite
|
||||
to ``ezdxf`` is the only guard that covers tests nobody has written yet.
|
||||
|
||||
Opt out with ``@pytest.mark.live_backend`` for a test that deliberately
|
||||
exercises backend selection.
|
||||
"""
|
||||
if request.node.get_closest_marker("live_backend"):
|
||||
return
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"live_backend: test drives backend selection itself; do not pin AUTOCAD_MCP_BACKEND",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def backend():
|
||||
"""Create an ezdxf backend with a fresh empty document."""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Static fixtures for the test suite."""
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Golden query set for tool discovery: English in, one right tool out.
|
||||
|
||||
Sixty-six cases (51 tuning + 15 holdout) split into a **tuning** set and a
|
||||
**holdout** set. The split is
|
||||
the point of the file. Ranking work is measured against
|
||||
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
||||
before any scoring parameter was chosen, and were not looked at until the
|
||||
scorer was frozen. A retrieval number quoted against cases the ranker was fitted
|
||||
to is not evidence of anything.
|
||||
|
||||
Rules the cases follow:
|
||||
|
||||
* Every ``expect`` is a tool that genuinely, unambiguously does the job. Where
|
||||
two tools would both be defensible answers (``view_screenshot`` vs
|
||||
``view_zoom_and_screenshot``, ``entity_delete`` vs ``entity_delete_many``)
|
||||
the query is worded to pick one, or the case is left out. A golden set that
|
||||
scores ambiguity is measuring the author, not the ranker.
|
||||
* ``kind`` records what the case exercises, so accuracy can be reported per
|
||||
category instead of as one flattering average.
|
||||
* ``risk`` is the ceiling a caller would sensibly pass. It is ``"read"`` for
|
||||
the counting questions — the ones where stock BM25 answers "how many entities
|
||||
are on the GEOMETRY layer" with ``entity_delete_many``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ["ALL_CASES", "HOLDOUT_CASES", "TUNING_CASES", "GoldenCase"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GoldenCase:
|
||||
"""One query and the tool it must find."""
|
||||
|
||||
query: str
|
||||
expect: str
|
||||
kind: str # "command" | "synonym" | "paraphrase" | "counting"
|
||||
risk: str = "any"
|
||||
|
||||
def __str__(self) -> str: # readable pytest ids
|
||||
return f"{self.kind}:{self.query}"
|
||||
|
||||
|
||||
# ── tuning set ──────────────────────────────────────────────────────────────
|
||||
# Free to be measured against, argued with and re-measured.
|
||||
|
||||
TUNING_CASES: tuple[GoldenCase, ...] = (
|
||||
# AutoCAD command names a drafter types verbatim.
|
||||
# Re-pointed in v1.5.0 (M8): BPOLY now has a real destination. It was
|
||||
# parked on entity_create_polyline only because nothing traced a boundary,
|
||||
# and this is a correction of the expectation, not a tuning of the ranker.
|
||||
GoldenCase("BPOLY", "boundary_trace", "command"),
|
||||
GoldenCase("PEDIT", "entity_edit_geometry", "command"),
|
||||
GoldenCase("WBLOCK", "block_create_from_entities", "command"),
|
||||
GoldenCase("OVERKILL", "drawing_refine", "command"),
|
||||
GoldenCase("LAYTRANS", "template_apply_layers", "command"),
|
||||
GoldenCase("MATCHPROP", "entity_set_properties", "command"),
|
||||
GoldenCase("QSELECT", "entity_select_smart", "command"),
|
||||
GoldenCase("DIMLINEAR", "dimension_linear", "command"),
|
||||
GoldenCase("ARRAYPOLAR", "entity_array_polar", "command"),
|
||||
GoldenCase("MLEADER", "leader_create_mleader", "command"),
|
||||
GoldenCase("BHATCH", "entity_create_hatch", "command"),
|
||||
GoldenCase("LAYISO", "layer_isolate", "command"),
|
||||
GoldenCase("XLINE", "construction_xline", "command"),
|
||||
GoldenCase("EXPLODE", "block_explode", "command"),
|
||||
GoldenCase("DXFOUT", "drawing_export_dxf", "command"),
|
||||
GoldenCase("QDIM", "dimension_auto", "command"),
|
||||
GoldenCase("CHA", "entity_chamfer", "command"),
|
||||
GoldenCase("DLI", "dimension_linear", "command"),
|
||||
GoldenCase("LAYDEL", "layer_delete", "command"),
|
||||
GoldenCase("PURGE", "drawing_purge", "command"),
|
||||
# Shop-floor vocabulary.
|
||||
GoldenCase("bill of materials", "entity_create_table", "synonym"),
|
||||
GoldenCase("parts list", "entity_create_table", "synonym"),
|
||||
GoldenCase("round the corner", "entity_fillet", "synonym"),
|
||||
GoldenCase("bolt circle", "entity_array_polar", "synonym"),
|
||||
GoldenCase("title block", "titleblock_apply_iso_a3", "synonym"),
|
||||
GoldenCase("keyed bore", "keyway_draw_keyed_bore", "synonym"),
|
||||
GoldenCase("feature control frame", "gd_frame", "synonym"),
|
||||
GoldenCase("object snap", "point_from_snap", "synonym"),
|
||||
GoldenCase("zoom to fit", "view_zoom_extents", "synonym"),
|
||||
GoldenCase("smooth curve", "entity_create_spline", "synonym"),
|
||||
GoldenCase("true position", "gd_frame", "synonym"),
|
||||
GoldenCase("datum triangle", "datum_feature", "synonym"),
|
||||
# Paraphrases — no shared vocabulary with the tool name.
|
||||
GoldenCase("I need the corner rounded off with a 3 mm radius", "entity_fillet", "paraphrase"),
|
||||
GoldenCase(
|
||||
"put an arrow with a note pointing at the hole", "leader_create_mleader", "paraphrase"
|
||||
),
|
||||
GoldenCase("turn a layer off so it does not print", "layer_hide", "paraphrase"),
|
||||
GoldenCase("make the whole shape smaller by half", "entity_scale", "paraphrase"),
|
||||
GoldenCase(
|
||||
"repeat this shape around a centre point six times", "entity_array_polar", "paraphrase"
|
||||
),
|
||||
GoldenCase(
|
||||
"the two lines overshoot, cut them back to where they cross", "entity_trim", "paraphrase"
|
||||
),
|
||||
GoldenCase("write a paragraph of general notes", "entity_create_mtext", "paraphrase"),
|
||||
GoldenCase("take a picture of the current drawing", "view_screenshot", "paraphrase"),
|
||||
GoldenCase("start a checkpoint I can roll back to", "transaction_begin", "paraphrase"),
|
||||
GoldenCase(
|
||||
"what is wrong with this drawing before I finish it", "drawing_critique", "paraphrase"
|
||||
),
|
||||
GoldenCase("fill this closed area with section hatching", "entity_create_hatch", "paraphrase"),
|
||||
GoldenCase("bevel a corner at 45 degrees", "entity_chamfer", "paraphrase"),
|
||||
GoldenCase("load the dashed hidden linetype", "linetype_load", "paraphrase"),
|
||||
# Counting / inspection. Under stock BM25 the first of these answers with
|
||||
# entity_delete_many.
|
||||
GoldenCase(
|
||||
"how many entities are on the GEOMETRY layer",
|
||||
"analysis_entity_stats",
|
||||
"counting",
|
||||
risk="read",
|
||||
),
|
||||
GoldenCase("what layers exist in this drawing", "layer_list", "counting", risk="read"),
|
||||
GoldenCase("how big is the drawing overall", "analysis_bounding_box", "counting", risk="read"),
|
||||
# Re-pointed when analysis_measure_entity was added, NOT to match the
|
||||
# ranker: "this closed polyline" names an entity, and until that tool existed
|
||||
# the only destination was the one tool that cannot look at the drawing. The
|
||||
# expectation was wrong the whole time; the case is now answerable.
|
||||
GoldenCase(
|
||||
"what is the area of this closed polyline",
|
||||
"analysis_measure_entity",
|
||||
"counting",
|
||||
risk="read",
|
||||
),
|
||||
# Withdrawn: "list every circle in the drawing" -> analysis_select_by_type.
|
||||
# ``entity_list(type_filter="CIRCLE")`` answers it just as correctly (and
|
||||
# with a layer filter and paging on top), so the case scored the author's
|
||||
# preference, not the ranker. Replaced rather than re-labelled: relabelling
|
||||
# a case to whatever the ranker returned is how a golden set stops being
|
||||
# evidence.
|
||||
GoldenCase(
|
||||
"what is inside this rectangular window",
|
||||
"analysis_find_in_region",
|
||||
"counting",
|
||||
risk="read",
|
||||
),
|
||||
GoldenCase(
|
||||
"where is this block used in the drawing",
|
||||
"block_find_references",
|
||||
"counting",
|
||||
risk="read",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── holdout set ─────────────────────────────────────────────────────────────
|
||||
# Written alongside the tuning cases, before any scoring constant was chosen,
|
||||
# and deliberately not measured until the scorer was frozen. Do not tune
|
||||
# against these. If a holdout case fails, the honest fix is a new tuning case
|
||||
# that captures the same class of failure — not an edit to this tuple.
|
||||
|
||||
HOLDOUT_CASES: tuple[GoldenCase, ...] = (
|
||||
GoldenCase("DDEDIT", "entity_edit_text", "command"),
|
||||
GoldenCase("MVIEW", "viewport_create", "command"),
|
||||
GoldenCase("SPL", "entity_create_spline", "command"),
|
||||
GoldenCase("TOLERANCE", "gd_frame", "command"),
|
||||
GoldenCase("crosshatch", "entity_create_hatch", "synonym"),
|
||||
GoldenCase("wall thickness", "entity_offset", "synonym"),
|
||||
GoldenCase("undo the undo", "drawing_redo", "synonym"),
|
||||
GoldenCase("pitch circle", "entity_create_circle", "synonym"),
|
||||
GoldenCase("I want a parallel copy 5 mm inside the outline", "entity_offset", "paraphrase"),
|
||||
GoldenCase("get rid of the unused blocks and layers", "drawing_purge", "paraphrase"),
|
||||
GoldenCase("change the wording of this label", "entity_edit_text", "paraphrase"),
|
||||
GoldenCase("make a scaled window of the model on the sheet", "viewport_create", "paraphrase"),
|
||||
GoldenCase(
|
||||
"how far apart are these two points",
|
||||
"analysis_measure_distance",
|
||||
"counting",
|
||||
risk="read",
|
||||
),
|
||||
GoldenCase("which blocks are defined", "block_list", "counting", risk="read"),
|
||||
GoldenCase("what are the properties of this entity", "entity_get", "counting", risk="read"),
|
||||
)
|
||||
|
||||
|
||||
ALL_CASES: tuple[GoldenCase, ...] = TUNING_CASES + HOLDOUT_CASES
|
||||
@@ -0,0 +1,220 @@
|
||||
"""M8/F15 — annotation objects: wipeout, revision cloud, MTEXT mask, find/replace.
|
||||
|
||||
Four tools that fill real gaps rather than restating the 141 already here. Each
|
||||
carries a measured caveat:
|
||||
|
||||
* **WIPEOUT** is a real DXF entity (`add_wipeout`) that round-trips.
|
||||
* **REVCLOUD** is genuinely available: `ezdxf.revcloud.add_entity()` emits the
|
||||
bulged LWPOLYLINE that AutoCAD's REVCLOUD *is*, stamps the `RevcloudProps`
|
||||
xdata and picks the bulge sign from the winding direction — so the result is
|
||||
recognised by `is_revcloud()` rather than merely resembling a cloud. The trap
|
||||
is the segment length: pass one larger than the shortest edge and you get a
|
||||
"cloud" with no arcs at all, which looks like a plain rectangle and reports
|
||||
success.
|
||||
* **MTEXT background mask** round-trips (`bg_fill`, `box_fill_scale`) — proven
|
||||
through saveas/readfile, because a mask that vanishes on save is worse than
|
||||
no mask.
|
||||
* **find/replace** is the one with a real scope problem. TEXT, MTEXT, ATTRIB
|
||||
and ATTDEF are all readable *and* writable, so all four are in scope. A
|
||||
DIMENSION's `dxf.text` is `'<>'` — the override placeholder, not the
|
||||
measurement — so replacing text inside dimensions would either do nothing or
|
||||
destroy the association with the measured value. The tool reports which types
|
||||
it searched instead of silently skipping some, because "0 replacements" and
|
||||
"I did not look there" are different answers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
SQUARE = [[0.0, 0.0], [100.0, 0.0], [100.0, 60.0], [0.0, 60.0]]
|
||||
|
||||
|
||||
# ── wipeout ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_wipeout_is_created_and_reports_its_handle(backend):
|
||||
result = await backend.entity_create_wipeout(SQUARE)
|
||||
|
||||
assert result["ok"] is True
|
||||
entity = backend._doc.entitydb.get(result["handle"])
|
||||
assert entity.dxftype() == "WIPEOUT"
|
||||
|
||||
|
||||
async def test_a_wipeout_survives_save_and_reload(backend, tmp_path):
|
||||
import ezdxf
|
||||
|
||||
created = await backend.entity_create_wipeout(SQUARE)
|
||||
path = tmp_path / "wipeout.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"])
|
||||
assert reloaded is not None and reloaded.dxftype() == "WIPEOUT"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [[], [[0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]]])
|
||||
async def test_a_wipeout_needs_at_least_three_points(backend, bad):
|
||||
"""Two points enclose no area; a zero-area mask masks nothing."""
|
||||
result = await backend.entity_create_wipeout(bad)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── revision cloud ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_revision_cloud_is_a_bulged_polyline(backend):
|
||||
"""The arcs are the cloud. A revcloud without bulges is a rectangle."""
|
||||
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
entity = backend._doc.entitydb.get(result["handle"])
|
||||
assert entity.dxftype() == "LWPOLYLINE"
|
||||
bulges = [point[4] for point in entity.get_points()]
|
||||
assert all(abs(b) > 0.1 for b in bulges), "every segment must carry an arc"
|
||||
assert result["segments"] == len(bulges)
|
||||
|
||||
|
||||
async def test_the_cloud_is_recognisable_as_one(backend):
|
||||
from ezdxf.revcloud import is_revcloud
|
||||
|
||||
result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0)
|
||||
assert is_revcloud(backend._doc.entitydb.get(result["handle"]))
|
||||
|
||||
|
||||
async def test_a_segment_longer_than_the_path_is_refused(backend):
|
||||
"""Otherwise the result is a plain rectangle reported as a cloud."""
|
||||
result = await backend.entity_create_revcloud(SQUARE, segment_length=10_000.0)
|
||||
assert result["ok"] is False
|
||||
assert "segment_length" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0.0, -5.0])
|
||||
async def test_a_non_positive_segment_length_is_refused(backend, bad):
|
||||
result = await backend.entity_create_revcloud(SQUARE, segment_length=bad)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── MTEXT background mask ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_background_mask_round_trips(backend, tmp_path):
|
||||
"""A mask that vanishes on save is worse than no mask."""
|
||||
import ezdxf
|
||||
|
||||
mtext = await backend.entity_create_mtext("SECTION A-A", 10, 10)
|
||||
|
||||
result = await backend.text_set_background(mtext.handle, color=2, scale=1.5)
|
||||
assert result["ok"] is True
|
||||
|
||||
path = tmp_path / "masked.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(mtext.handle)
|
||||
assert reloaded.dxf.bg_fill == 1
|
||||
assert reloaded.dxf.box_fill_scale == pytest.approx(1.5)
|
||||
|
||||
|
||||
async def test_a_background_mask_can_be_turned_off_again(backend):
|
||||
mtext = await backend.entity_create_mtext("X", 10, 10)
|
||||
await backend.text_set_background(mtext.handle, color=2)
|
||||
|
||||
result = await backend.text_set_background(mtext.handle, enabled=False)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["enabled"] is False
|
||||
assert backend._doc.entitydb.get(mtext.handle).dxf.get("bg_fill", 0) == 0
|
||||
|
||||
|
||||
async def test_a_background_mask_on_plain_text_is_refused(backend):
|
||||
"""Only MTEXT carries a background fill; TEXT silently would not."""
|
||||
text = await backend.entity_create_text("PLAIN", 0, 0)
|
||||
result = await backend.text_set_background(text.handle, color=2)
|
||||
assert result["ok"] is False
|
||||
assert "MTEXT" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0.5, 0.0, -1.0])
|
||||
async def test_a_mask_scale_below_one_is_refused(backend, bad):
|
||||
"""A box smaller than the text is not a mask, it is a stripe."""
|
||||
mtext = await backend.entity_create_mtext("X", 0, 0)
|
||||
result = await backend.text_set_background(mtext.handle, color=2, scale=bad)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── find / replace ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _tagged_block(backend):
|
||||
doc = backend._doc
|
||||
block = doc.blocks.new("TAGGED")
|
||||
block.add_attdef("PART", (0, 0), text="OLD")
|
||||
ref = doc.modelspace().add_blockref("TAGGED", (0, 0))
|
||||
ref.add_auto_attribs({"PART": "OLD"})
|
||||
return ref
|
||||
|
||||
|
||||
async def test_find_replace_covers_text_mtext_and_block_attributes(backend):
|
||||
"""Silently skipping attributes inside blocks is this tool's honesty trap.
|
||||
|
||||
Four hits, not three: the ATTDEF in the block *definition* carries the
|
||||
default value the next insert will use, so leaving it behind would fix the
|
||||
drawing and reintroduce the old text on the next block reference.
|
||||
"""
|
||||
text = await backend.entity_create_text("OLD value", 0, 0)
|
||||
mtext = await backend.entity_create_mtext("OLD value", 0, 10)
|
||||
await _tagged_block(backend)
|
||||
|
||||
result = await backend.text_find_replace("OLD", "NEW")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["replaced"] == 4
|
||||
changed = {row["type"] for row in result["entities"]}
|
||||
assert changed == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
|
||||
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW value"
|
||||
assert backend._doc.entitydb.get(mtext.handle).text == "NEW value"
|
||||
|
||||
|
||||
async def test_it_reports_which_types_it_searched(backend):
|
||||
""" "0 replacements" and "I never looked there" are different answers."""
|
||||
result = await backend.text_find_replace("NOTHING", "X")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["replaced"] == 0
|
||||
assert set(result["searched_types"]) == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"}
|
||||
assert "DIMENSION" in result["note"]
|
||||
|
||||
|
||||
async def test_a_dry_run_changes_nothing(backend):
|
||||
text = await backend.entity_create_text("OLD", 0, 0)
|
||||
|
||||
result = await backend.text_find_replace("OLD", "NEW", dry_run=True)
|
||||
|
||||
assert result["replaced"] == 1
|
||||
assert result["dry_run"] is True
|
||||
assert backend._doc.entitydb.get(text.handle).dxf.text == "OLD"
|
||||
|
||||
|
||||
async def test_it_can_be_scoped_to_a_layer(backend):
|
||||
await backend.entity_create_text("OLD", 0, 0, layer="TEXT")
|
||||
other = await backend.entity_create_text("OLD", 0, 10, layer="DIM")
|
||||
|
||||
result = await backend.text_find_replace("OLD", "NEW", layer="TEXT")
|
||||
|
||||
assert result["replaced"] == 1
|
||||
assert backend._doc.entitydb.get(other.handle).dxf.text == "OLD"
|
||||
|
||||
|
||||
async def test_an_empty_search_string_is_refused(backend):
|
||||
"""Replacing "" matches between every character and destroys the text."""
|
||||
result = await backend.text_find_replace("", "X")
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
async def test_case_sensitivity_is_the_callers_choice(backend):
|
||||
text = await backend.entity_create_text("Old Value", 0, 0)
|
||||
|
||||
result = await backend.text_find_replace("old", "NEW", match_case=False)
|
||||
|
||||
assert result["replaced"] == 1
|
||||
assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW Value"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""T0.8-B — which engine a test talks to must not be a matter of luck.
|
||||
|
||||
``_make_backend`` read ``os.environ["AUTOCAD_MCP_BACKEND"]`` directly while
|
||||
``config.settings.backend`` — the setting that *looks* like the control — had no
|
||||
production reader at all. So ``monkeypatch.setattr(config.settings, "backend",
|
||||
"ezdxf")`` was a no-op that read like a safety guard, and on a developer machine
|
||||
with AutoCAD open an unguarded test would attach to the operator's live unsaved
|
||||
drawing and start creating entities in it. That is not hypothetical: it happened
|
||||
during this release's development.
|
||||
|
||||
The fix makes ``Settings.backend`` a property that reads the environment live,
|
||||
and points ``_make_backend`` at it. One source of truth, and late binding is
|
||||
preserved so ``monkeypatch.setenv`` — which four call sites and
|
||||
``benchmarks/token_suite.py`` already use correctly — keeps working. Assigning to
|
||||
``config.settings.backend`` now raises instead of quietly protecting nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
import server
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _ComBackendSentinel:
|
||||
"""Stands in for the real COM backend and screams if anyone builds it."""
|
||||
|
||||
constructed: list[str] = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
_ComBackendSentinel.constructed.append("ComBackend() CONSTRUCTED")
|
||||
raise AssertionError("a test tried to construct the live COM backend")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_sentinel(monkeypatch):
|
||||
"""Replace backends.com_backend with a module that cannot connect anywhere."""
|
||||
_ComBackendSentinel.constructed.clear()
|
||||
fake = types.ModuleType("backends.com_backend")
|
||||
fake.ComBackend = _ComBackendSentinel
|
||||
monkeypatch.setitem(sys.modules, "backends.com_backend", fake)
|
||||
return _ComBackendSentinel
|
||||
|
||||
|
||||
async def test_the_env_var_is_what_actually_selects_the_backend(monkeypatch, com_sentinel):
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
backend = await server._make_backend()
|
||||
|
||||
assert isinstance(backend, EzdxfBackend)
|
||||
assert com_sentinel.constructed == [], "the COM backend must never have been touched"
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
async def test_settings_backend_reports_what_make_backend_will_use(monkeypatch):
|
||||
"""The setting must not be able to disagree with the selector."""
|
||||
for value in ("ezdxf", "com", "auto"):
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", value)
|
||||
assert config.settings.backend == value
|
||||
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", " EzDxF ")
|
||||
assert config.settings.backend == "ezdxf", "must normalise like the selector does"
|
||||
|
||||
monkeypatch.delenv("AUTOCAD_MCP_BACKEND", raising=False)
|
||||
assert config.settings.backend == "auto"
|
||||
|
||||
|
||||
async def test_assigning_to_settings_backend_fails_loudly(monkeypatch):
|
||||
"""The dead monkeypatch that made two tests *look* guarded must now raise.
|
||||
|
||||
Silently accepting the assignment is the whole bug: the test passes, reads
|
||||
as safe, and talks to live AutoCAD anyway.
|
||||
"""
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
with pytest.raises(AttributeError):
|
||||
config.settings.backend = "com"
|
||||
assert config.settings.backend == "ezdxf"
|
||||
|
||||
|
||||
async def test_make_backend_reads_the_setting_not_the_environment_directly(monkeypatch):
|
||||
"""One source of truth: patching the property must steer the selector.
|
||||
|
||||
If ``_make_backend`` goes back to reading ``os.environ`` itself, this fails —
|
||||
which is the regression guard, because that direct read is what let the
|
||||
setting and the behaviour drift apart in the first place.
|
||||
"""
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "com")
|
||||
monkeypatch.setattr(type(config.settings), "backend", property(lambda self: "ezdxf"))
|
||||
|
||||
backend = await server._make_backend()
|
||||
assert isinstance(backend, EzdxfBackend)
|
||||
await backend.disconnect()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""M9 — the v3 task matrix.
|
||||
|
||||
v2 returned 10/10 for this server. That was not a good sign: every task in it
|
||||
tested something the server was designed around, so the score could only ever
|
||||
have been full marks. The five tasks added here were chosen because they can
|
||||
fail, and three of them did fail while being written — a hatch could not be
|
||||
measured at all, and the boundary tools disagreed with the measurement tool
|
||||
about the same shape.
|
||||
|
||||
Two assertions in this file exist purely to keep the matrix honest rather than
|
||||
to test behaviour:
|
||||
|
||||
* the plan's ``tool_discovery_bilingual`` and ``measurement_massprops`` must
|
||||
**not** appear, because neither capability shipped; and
|
||||
* the published competitor reports must not carry the new task ids, because
|
||||
those servers were never run against them and a zero we invented would read
|
||||
exactly like a zero we measured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||||
from benchmarks.adapters.base import BenchmarkAdapter
|
||||
from benchmarks.run_competitors import run_tasks
|
||||
from benchmarks.tasks_v2 import TASKS_V2
|
||||
from benchmarks.tasks_v3 import MATRICES, NEW_TASKS_V3, TASKS_V3, task_by_id
|
||||
|
||||
PUBLISHED = Path(__file__).resolve().parents[1] / "benchmarks" / "results" / "published"
|
||||
|
||||
|
||||
# ── shape of the matrix ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_v3_extends_v2_without_disturbing_it():
|
||||
"""A renumbered or reweighted v2 task would silently invalidate the
|
||||
published v1.4 reports, which are keyed by task id."""
|
||||
assert TASKS_V3[: len(TASKS_V2)] == TASKS_V2
|
||||
assert len(NEW_TASKS_V3) == 5
|
||||
assert len(TASKS_V3) == 15
|
||||
|
||||
|
||||
def test_every_task_id_is_unique():
|
||||
ids = [task.task_id for task in TASKS_V3]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
def test_the_two_capabilities_that_did_not_ship_are_not_advertised_as_tasks():
|
||||
ids = {task.task_id for task in TASKS_V3}
|
||||
|
||||
assert "tool_discovery_bilingual" not in ids, (
|
||||
"the Turkish normalizer was built and removed; this repo is English "
|
||||
"throughout, so a bilingual task would be a benchmark for nothing"
|
||||
)
|
||||
assert "measurement_massprops" not in ids, (
|
||||
"no centroid or moment-of-inertia tool ships — F2 was cut to entity_measure"
|
||||
)
|
||||
assert "tool_discovery" in ids
|
||||
assert "measure_from_handle" in ids
|
||||
|
||||
|
||||
def test_task_by_id_can_still_address_the_v2_matrix():
|
||||
assert task_by_id("core_geometry", "v2").category == "creation"
|
||||
with pytest.raises(KeyError):
|
||||
task_by_id("hatch_islands", "v2")
|
||||
|
||||
|
||||
def test_matrices_registry_exposes_both_sets():
|
||||
assert MATRICES["v2"] == TASKS_V2
|
||||
assert MATRICES["v3"] == TASKS_V3
|
||||
|
||||
|
||||
# ── the reference adapter actually answers them ─────────────────────────────
|
||||
|
||||
|
||||
def test_the_reference_adapter_implements_every_v3_task():
|
||||
"""An unimplemented task scores `unsupported` and is easy to not notice."""
|
||||
missing = [
|
||||
task.task_id
|
||||
for task in TASKS_V3
|
||||
if not hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}")
|
||||
]
|
||||
assert missing == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("task", NEW_TASKS_V3, ids=lambda t: t.task_id)
|
||||
async def test_each_new_task_passes_on_the_headless_backend(task, tmp_path):
|
||||
report = await run_tasks(AutoCADMCPProAdapter(), [task], tmp_path, timeout=120.0)
|
||||
|
||||
result = report["results"][0]
|
||||
assert result["status"] == "pass", result["message"]
|
||||
assert result["metrics"], "a task that proves nothing reports no evidence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_full_v3_matrix_runs_green(tmp_path):
|
||||
report = await run_tasks(AutoCADMCPProAdapter(), TASKS_V3, tmp_path, timeout=180.0)
|
||||
|
||||
assert report["summary"]["attempted"] == 15
|
||||
assert report["summary"]["passed"] == 15, [
|
||||
(item["task_id"], item["status"], item["message"]) for item in report["results"]
|
||||
]
|
||||
assert report["matrix"] == "v3"
|
||||
|
||||
|
||||
# ── a capability refusal is not a failure ───────────────────────────────────
|
||||
|
||||
|
||||
class RefusingAdapter(BenchmarkAdapter):
|
||||
name = "refusing"
|
||||
|
||||
async def setup(self, artifact_dir):
|
||||
return None
|
||||
|
||||
async def run_task(self, task):
|
||||
raise UnsupportedCapabilityError("chspace", "no change-space member on this engine")
|
||||
|
||||
async def cleanup(self):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_capability_refusal_is_reported_unsupported_not_failed(tmp_path):
|
||||
"""`fail` and `unsupported` are different claims about a competitor.
|
||||
|
||||
"Your server got this wrong" and "your engine cannot reach this" are not
|
||||
the same finding, and the runner used to collapse the second into the
|
||||
first because a refusal is still an exception.
|
||||
"""
|
||||
report = await run_tasks(RefusingAdapter(), [NEW_TASKS_V3[0]], tmp_path)
|
||||
|
||||
result = report["results"][0]
|
||||
assert result["status"] == "unsupported"
|
||||
assert "chspace" in result["message"]
|
||||
assert report["summary"]["unsupported"] == 1
|
||||
assert report["summary"]["score"] == 0.0
|
||||
|
||||
|
||||
# ── nothing was invented for the competitors ────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename", ["beiming183-autocad-mcp.json", "puran-water-autocad-mcp.json"]
|
||||
)
|
||||
def test_competitor_reports_carry_no_result_for_the_new_tasks(filename):
|
||||
report = json.loads((PUBLISHED / filename).read_text(encoding="utf-8"))
|
||||
scored = {item["task_id"] for item in report["results"]}
|
||||
new_ids = {task.task_id for task in NEW_TASKS_V3}
|
||||
|
||||
assert scored & new_ids == set(), (
|
||||
"these servers were pinned and run against the v2 matrix at v1.4; a "
|
||||
"result here would be fabricated"
|
||||
)
|
||||
assert report.get("matrix", "v2") == "v2"
|
||||
|
||||
|
||||
def test_the_published_reference_report_covers_the_whole_v3_matrix():
|
||||
report = json.loads((PUBLISHED / "autocad-mcp-pro.json").read_text(encoding="utf-8"))
|
||||
scored = {item["task_id"] for item in report["results"]}
|
||||
|
||||
assert scored == {task.task_id for task in TASKS_V3}
|
||||
assert report["matrix"] == "v3"
|
||||
assert report["summary"]["score"] == 100.0
|
||||
@@ -0,0 +1,168 @@
|
||||
"""M5 — `block_create_from_entities` on both engines.
|
||||
|
||||
The live backend refused this outright ("not supported in COM backend. Use
|
||||
system_run_command with _BLOCK instead"), which pushed users at the free-text
|
||||
escape hatch for something ActiveX does directly: `Blocks.Add` makes the
|
||||
definition and `Document.CopyObjects` moves entities into it. Meanwhile the
|
||||
tool's own docstring said the opposite — "works by using AutoCAD's BLOCK command
|
||||
(COM backend only)" — so the one backend that could not do it was named as the
|
||||
one that could.
|
||||
|
||||
Both engines also used to swallow handles they could not resolve: pass five
|
||||
handles with two typos and you got `entity_count: 3` and no mention of the two.
|
||||
The count is now accompanied by what was skipped.
|
||||
|
||||
The COM path is unit-tested against a fake ActiveX object. This machine has a
|
||||
live AutoCAD and connecting to it during development is forbidden, so that path
|
||||
ships verified at this level only — the CHANGELOG says so rather than implying
|
||||
hardware coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── headless ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_ezdxf_builds_the_definition_from_real_entities(backend):
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
circle = await backend.entity_create_circle(5, 5, 2)
|
||||
|
||||
result = await backend.block_create_from_entities("BRACKET", [line.handle, circle.handle])
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["entity_count"] == 2
|
||||
assert result["skipped"] == []
|
||||
block = backend._doc.blocks.get("BRACKET")
|
||||
assert sorted(e.dxftype() for e in block) == ["CIRCLE", "LINE"]
|
||||
|
||||
|
||||
async def test_ezdxf_reports_handles_it_could_not_resolve(backend):
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
|
||||
result = await backend.block_create_from_entities("PARTIAL", [line.handle, "NOSUCH", "ALSONO"])
|
||||
|
||||
assert result["entity_count"] == 1
|
||||
assert result["skipped"] == ["NOSUCH", "ALSONO"], (
|
||||
"a count with no mention of what was dropped is the same shape of lie "
|
||||
"as the audit reporting no fixes"
|
||||
)
|
||||
|
||||
|
||||
async def test_ezdxf_refuses_a_block_with_nothing_in_it(backend):
|
||||
with pytest.raises(RuntimeError, match="NOSUCH"):
|
||||
await backend.block_create_from_entities("EMPTY", ["NOSUCH"])
|
||||
assert "EMPTY" not in backend._doc.blocks, "a failed call must leave no stub behind"
|
||||
|
||||
|
||||
async def test_ezdxf_honours_the_base_point(backend):
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.block_create_from_entities("BASED", [line.handle], base_x=3.0, base_y=4.0)
|
||||
|
||||
block = backend._doc.blocks.get("BASED")
|
||||
assert tuple(block.block.dxf.base_point)[:2] == (3.0, 4.0)
|
||||
|
||||
|
||||
# ── live backend, against a fake ActiveX surface ────────────────────────────
|
||||
|
||||
|
||||
class _FakeBlock:
|
||||
def __init__(self, name):
|
||||
self.Name = name
|
||||
|
||||
|
||||
class _FakeDocument:
|
||||
"""The three ActiveX members the implementation is allowed to touch."""
|
||||
|
||||
def __init__(self, handles):
|
||||
self._handles = handles
|
||||
self.Blocks = types.SimpleNamespace(Add=self._add_block)
|
||||
self.copied = None
|
||||
self.added = None
|
||||
|
||||
def _add_block(self, base_point, name):
|
||||
self.added = (tuple(base_point.value), name)
|
||||
return _FakeBlock(name)
|
||||
|
||||
def HandleToObject(self, handle):
|
||||
if handle not in self._handles:
|
||||
raise RuntimeError(f"no object with handle {handle}")
|
||||
return types.SimpleNamespace(Handle=handle, ObjectName="AcDbLine")
|
||||
|
||||
def CopyObjects(self, objects, owner, *rest):
|
||||
self.copied = ([o.Handle for o in objects.value], owner.Name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend(monkeypatch):
|
||||
"""A ComBackend whose document is a fake — no COM connection is made."""
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from backends import com_backend as module
|
||||
|
||||
document = _FakeDocument({"2F", "30"})
|
||||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||||
monkeypatch.setattr(module, "_regen", lambda: None)
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, document
|
||||
|
||||
|
||||
async def test_com_creates_the_definition_and_copies_into_it(com_backend):
|
||||
backend, document = com_backend
|
||||
|
||||
result = await backend.block_create_from_entities("BRACKET", ["2F", "30"], 3.0, 4.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["entity_count"] == 2
|
||||
assert result["skipped"] == []
|
||||
assert document.added == ((3.0, 4.0, 0.0), "BRACKET"), "base point must reach Blocks.Add"
|
||||
assert document.copied == (["2F", "30"], "BRACKET")
|
||||
|
||||
|
||||
async def test_com_reports_handles_it_could_not_resolve(com_backend):
|
||||
backend, document = com_backend
|
||||
|
||||
result = await backend.block_create_from_entities("PARTIAL", ["2F", "NOSUCH"])
|
||||
|
||||
assert result["entity_count"] == 1
|
||||
assert result["skipped"] == ["NOSUCH"]
|
||||
assert document.copied == (["2F"], "PARTIAL")
|
||||
|
||||
|
||||
async def test_com_refuses_a_block_with_nothing_in_it(com_backend):
|
||||
backend, document = com_backend
|
||||
|
||||
with pytest.raises(RuntimeError, match="NOSUCH"):
|
||||
await backend.block_create_from_entities("EMPTY", ["NOSUCH"])
|
||||
|
||||
assert document.copied is None, "nothing may be copied when nothing resolved"
|
||||
|
||||
|
||||
async def test_both_backends_return_the_same_key_set(backend, com_backend):
|
||||
com, _ = com_backend
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
|
||||
headless = await backend.block_create_from_entities("A", [line.handle])
|
||||
live = await com.block_create_from_entities("A", ["2F"])
|
||||
|
||||
assert headless.keys() == live.keys()
|
||||
|
||||
|
||||
async def test_ezdxf_still_declares_no_capability_gap_here():
|
||||
"""Both engines can do this now, so it must not look like a boundary."""
|
||||
features = EzdxfBackend().capabilities().features
|
||||
assert "block_definition" not in features, (
|
||||
"a capability key would imply one engine cannot; both can"
|
||||
)
|
||||
@@ -0,0 +1,447 @@
|
||||
"""F3 — boundary tracing: `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities`.
|
||||
|
||||
Two tools that turn loose edges into one closed LWPOLYLINE. Every assertion here
|
||||
exists because a measured ezdxf behaviour makes the *plausible* implementation
|
||||
lie:
|
||||
|
||||
* **A boundary with no boundary is the silent-success case.** A seed dropped in
|
||||
open space, or inside a "region" whose edges leave a gap, has no enclosing
|
||||
loop. Returning an empty or open polyline with ``ok: True`` is exactly the
|
||||
failure mode this repo spent a milestone deleting, so both are refusals — and
|
||||
the gap case must name the dangling endpoint, because "no loop here" and
|
||||
"your top edge is 5 mm short" are different problems with different fixes.
|
||||
|
||||
* **`edgeminer.find_all_loops` is O(n!)** — measured 63.9 s at 40 edges and a
|
||||
TimeoutError at 60. It must never be called; `find_loop_by_edge` is 0.0057 s
|
||||
at 60 edges. Nothing below asserts on runtime, but the nested-region test
|
||||
pins the *behaviour* that makes the cheap call correct: the loop returned is
|
||||
the nearest enclosing one, not the outermost.
|
||||
|
||||
* **`edgesmith.lwpolyline_from_chain` does not close what it chains.** Measured
|
||||
on four LINEs of a square: five points, the fifth a duplicate of the first,
|
||||
and ``closed = False``. That polyline draws like a square and behaves like an
|
||||
open one — HATCH association, area queries and `entity_offset` all see an open
|
||||
path. So the stored entity is checked for the closed *flag* and for the
|
||||
absence of the duplicate vertex, not just for looking right.
|
||||
|
||||
* **`edgesmith.loop_area` ignores bulges.** Measured on two semicircular ARCs
|
||||
chained into a circle: it returns **0.0** where the true area is 314.16. Use
|
||||
it and every arc-bounded boundary — every fillet, every slot, every keyway
|
||||
pocket — reports an area of roughly nothing while claiming success.
|
||||
|
||||
* **The reported area comes from the polyline, not from a copy of it.** The
|
||||
first fix for the bulge problem flattened the chain at a fixed 0.01 sag,
|
||||
which left `boundary_trace` saying 139.2177 and `entity_measure` saying
|
||||
139.2699 about the same figure, with nothing in either payload marking one
|
||||
as the approximation. The stored vertices carry exact bulges, so both tools
|
||||
now read those and round the same way.
|
||||
|
||||
Coordinates in and out are WCS, as everywhere else. ``area`` is unsigned:
|
||||
the same square wound clockwise or counter-clockwise measures 1600.0 either
|
||||
way, and a caller asking "how big is this pocket" never wants a negative
|
||||
answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
# A 40 x 40 square: area 1600.
|
||||
SQUARE = [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)]
|
||||
|
||||
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
|
||||
|
||||
|
||||
def _numbers(text: str) -> list[float]:
|
||||
"""Every number an error message mentions, however it was formatted."""
|
||||
return [float(match) for match in _NUMBER.findall(text)]
|
||||
|
||||
|
||||
def _points(backend, handle) -> list[tuple[float, float]]:
|
||||
entity = backend._doc.entitydb.get(handle)
|
||||
return [(round(float(p[0]), 6), round(float(p[1]), 6)) for p in entity.get_points()]
|
||||
|
||||
|
||||
def _bulges(backend, handle) -> list[float]:
|
||||
entity = backend._doc.entitydb.get(handle)
|
||||
return [float(p[4]) for p in entity.get_points()]
|
||||
|
||||
|
||||
async def _square_of_lines(backend, points=SQUARE, layer=None):
|
||||
"""Four LINEs, corner to corner, returned as handles."""
|
||||
handles = []
|
||||
for index in range(len(points)):
|
||||
x1, y1 = points[index]
|
||||
x2, y2 = points[(index + 1) % len(points)]
|
||||
line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
|
||||
handles.append(line.handle)
|
||||
return handles
|
||||
|
||||
|
||||
# ── boundary_trace: the happy path, measured ────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_seed_inside_a_square_traces_its_four_edges(backend):
|
||||
await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["closed"] is True
|
||||
assert result["vertices"] == 4, "four edges, four corners — not five"
|
||||
assert result["area"] == pytest.approx(1600.0)
|
||||
|
||||
|
||||
async def test_the_traced_boundary_is_a_real_lwpolyline(backend):
|
||||
await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
entity = backend._doc.entitydb.get(result["handle"])
|
||||
assert entity.dxftype() == "LWPOLYLINE"
|
||||
assert set(_points(backend, result["handle"])) == set(SQUARE)
|
||||
|
||||
|
||||
async def test_the_boundary_is_closed_by_flag_not_by_a_duplicate_vertex(backend):
|
||||
"""`lwpolyline_from_chain` measured: 5 points, last == first, closed False.
|
||||
|
||||
That polyline looks shut and is not. Hatching, offsetting and area queries
|
||||
all treat it as an open path.
|
||||
"""
|
||||
await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
entity = backend._doc.entitydb.get(result["handle"])
|
||||
assert entity.closed is True
|
||||
stored = _points(backend, result["handle"])
|
||||
assert len(stored) == 4
|
||||
assert stored[0] != stored[-1]
|
||||
|
||||
|
||||
async def test_the_boundary_survives_save_and_reload(backend, tmp_path):
|
||||
import ezdxf
|
||||
|
||||
await _square_of_lines(backend)
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
path = tmp_path / "boundary.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"])
|
||||
assert reloaded.dxftype() == "LWPOLYLINE"
|
||||
assert reloaded.closed is True
|
||||
assert len(list(reloaded.get_points())) == 4
|
||||
|
||||
|
||||
# ── boundary_trace: the refusals ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_seed_outside_every_closed_region_is_refused(backend):
|
||||
"""A boundary with no boundary. An empty polyline reported as success is
|
||||
the exact shape of lie this repo removes tools for."""
|
||||
await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_trace(500.0, 500.0)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result.get("handle") is None
|
||||
assert result["error"]
|
||||
|
||||
|
||||
async def test_a_seed_in_open_space_with_no_edges_at_all_is_refused(backend):
|
||||
result = await backend.boundary_trace(0.0, 0.0)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"]
|
||||
|
||||
|
||||
async def test_a_region_with_a_gap_is_refused_and_the_error_names_the_gap(backend):
|
||||
"""The top edge stops 5 mm short at (35, 40).
|
||||
|
||||
"No loop found" sends the user hunting the whole drawing; naming the
|
||||
dangling endpoint sends them to the one line that is short.
|
||||
"""
|
||||
await backend.entity_create_line(0, 0, 40, 0)
|
||||
await backend.entity_create_line(40, 0, 40, 40)
|
||||
await backend.entity_create_line(40, 40, 35, 40) # 5 mm short of the corner
|
||||
await backend.entity_create_line(0, 40, 0, 0)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "gap" in result["error"].lower()
|
||||
assert 35.0 in _numbers(result["error"]), (
|
||||
"the error must carry the dangling endpoint, not just the word 'gap'"
|
||||
)
|
||||
|
||||
|
||||
async def test_a_gap_inside_the_tolerance_is_closed(backend):
|
||||
"""Real drawings miss corners by microns; tolerance is what it is for."""
|
||||
await backend.entity_create_line(0, 0, 40, 0)
|
||||
await backend.entity_create_line(40, 0, 40, 40)
|
||||
await backend.entity_create_line(40, 40, 0.001, 40)
|
||||
await backend.entity_create_line(0, 40, 0, 0)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0, tolerance=0.01)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["closed"] is True
|
||||
assert result["area"] == pytest.approx(1600.0, rel=1e-3)
|
||||
|
||||
|
||||
async def test_the_same_gap_outside_the_tolerance_is_still_refused(backend):
|
||||
"""Otherwise `tolerance` would be decoration."""
|
||||
await backend.entity_create_line(0, 0, 40, 0)
|
||||
await backend.entity_create_line(40, 0, 40, 40)
|
||||
await backend.entity_create_line(40, 40, 0.001, 40)
|
||||
await backend.entity_create_line(0, 40, 0, 0)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0, tolerance=1e-9)
|
||||
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── boundary_trace: which loop, and what it is made of ──────────────────────
|
||||
|
||||
|
||||
async def test_nested_regions_return_the_inner_loop(backend):
|
||||
"""AutoCAD's BOUNDARY traces the nearest enclosing loop.
|
||||
|
||||
Returning the outer square here would hatch straight over the island —
|
||||
and it is also the answer a whole-drawing loop search hands you first.
|
||||
"""
|
||||
await _square_of_lines(backend) # 40 x 40, area 1600
|
||||
inner = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]
|
||||
await _square_of_lines(backend, inner) # 20 x 20, area 400
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["area"] == pytest.approx(400.0)
|
||||
assert set(_points(backend, result["handle"])) == set(inner)
|
||||
|
||||
|
||||
async def test_a_seed_between_the_two_loops_returns_the_outer_ring(backend):
|
||||
"""Same drawing, different seed: the loop that encloses *this* point."""
|
||||
await _square_of_lines(backend)
|
||||
await _square_of_lines(backend, [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)])
|
||||
|
||||
result = await backend.boundary_trace(5.0, 5.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["area"] == pytest.approx(1600.0)
|
||||
|
||||
|
||||
async def test_source_handles_report_which_entities_formed_the_boundary(backend):
|
||||
"""A traced boundary the caller cannot attribute is a polyline from nowhere."""
|
||||
handles = await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
assert set(result["source_handles"]) == set(handles)
|
||||
assert result["handle"] not in result["source_handles"]
|
||||
|
||||
|
||||
async def test_the_boundary_is_independent_of_its_sources(backend):
|
||||
"""BOUNDARY makes a new entity, it does not alias the edges. Deleting a
|
||||
source line must not empty the polyline that was traced from it."""
|
||||
handles = await _square_of_lines(backend)
|
||||
result = await backend.boundary_trace(20.0, 20.0)
|
||||
|
||||
await backend.entity_delete(handles[0])
|
||||
|
||||
entity = backend._doc.entitydb.get(result["handle"])
|
||||
assert entity is not None
|
||||
assert entity.closed is True
|
||||
assert set(_points(backend, result["handle"])) == set(SQUARE)
|
||||
|
||||
|
||||
async def test_the_layer_filter_restricts_the_candidate_edges(backend):
|
||||
"""A construction line through the square halves it.
|
||||
|
||||
Unfiltered, the nearest enclosing loop around (10, 20) is the left half
|
||||
(area 800). Scoped to GEOMETRY, the divider is not an edge and the loop is
|
||||
the whole square (1600). If `layer` were ignored both answers would be 800.
|
||||
"""
|
||||
await _square_of_lines(backend, layer="GEOMETRY")
|
||||
await backend.entity_create_line(20, 0, 20, 40, layer="CONSTRUCTION")
|
||||
|
||||
unfiltered = await backend.boundary_trace(10.0, 20.0)
|
||||
filtered = await backend.boundary_trace(10.0, 20.0, layer="GEOMETRY")
|
||||
|
||||
assert unfiltered["area"] == pytest.approx(800.0)
|
||||
assert filtered["area"] == pytest.approx(1600.0)
|
||||
|
||||
|
||||
# ── boundary_from_entities: the happy path ──────────────────────────────────
|
||||
|
||||
|
||||
async def test_named_entities_chain_into_one_closed_polyline(backend):
|
||||
"""The handles arrive in drawing order, not chain order; chaining is the
|
||||
tool's whole job."""
|
||||
handles = await _square_of_lines(backend)
|
||||
shuffled = [handles[2], handles[0], handles[3], handles[1]]
|
||||
|
||||
result = await backend.boundary_from_entities(shuffled)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["closed"] is True
|
||||
assert result["vertices"] == 4
|
||||
assert result["area"] == pytest.approx(1600.0)
|
||||
assert set(_points(backend, result["handle"])) == set(SQUARE)
|
||||
|
||||
|
||||
async def test_the_chained_polyline_is_closed_without_a_duplicate_vertex(backend):
|
||||
handles = await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_from_entities(handles)
|
||||
|
||||
entity = backend._doc.entitydb.get(result["handle"])
|
||||
assert entity.closed is True
|
||||
stored = _points(backend, result["handle"])
|
||||
assert len(stored) == 4 and stored[0] != stored[-1]
|
||||
|
||||
|
||||
async def test_a_chained_polyline_round_trips(backend, tmp_path):
|
||||
import ezdxf
|
||||
|
||||
handles = await _square_of_lines(backend)
|
||||
result = await backend.boundary_from_entities(handles)
|
||||
|
||||
path = tmp_path / "chained.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"])
|
||||
assert reloaded.closed is True
|
||||
assert len(list(reloaded.get_points())) == 4
|
||||
|
||||
|
||||
async def test_two_arcs_close_into_a_circle_whose_area_counts_the_bulges(backend):
|
||||
"""`loop_area` returns 0.0 for this exact chain (measured).
|
||||
|
||||
Two semicircles enclose 314.16 mm^2; their two *vertices* enclose nothing.
|
||||
An area taken from the vertex polygon reports ~0 for every arc-bounded
|
||||
pocket in the drawing while still saying ok.
|
||||
"""
|
||||
upper = await backend.entity_create_arc(0, 0, 10, 0, 180)
|
||||
lower = await backend.entity_create_arc(0, 0, 10, 180, 360)
|
||||
|
||||
result = await backend.boundary_from_entities([upper.handle, lower.handle])
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["closed"] is True
|
||||
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
|
||||
assert all(abs(b) > 0.1 for b in _bulges(backend, result["handle"])), (
|
||||
"a circle stored without bulges is a straight line between two points"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_reported_area_matches_measuring_the_polyline_it_just_made(backend):
|
||||
"""Two tools, one shape, one number.
|
||||
|
||||
`boundary_*` used to report a curve flattened at a fixed 0.01 sag while
|
||||
`entity_measure` read the stored bulges analytically, so the same figure
|
||||
came back as 139.2177 from one tool and 139.2699 from the other with
|
||||
nothing in either payload to say which was the approximation. The polyline
|
||||
carries exact bulges either way — there was never a reason to answer from
|
||||
a coarser copy of it.
|
||||
"""
|
||||
# 10 x 10 square whose top edge is a semicircle: 100 + pi*25/2.
|
||||
await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.entity_create_line(10, 0, 10, 10)
|
||||
await backend.entity_create_line(0, 10, 0, 0)
|
||||
await backend.entity_create_arc(5, 10, 5, 0, 180)
|
||||
|
||||
traced = await backend.boundary_trace(5.0, 5.0)
|
||||
measured = await backend.entity_measure(traced["handle"])
|
||||
|
||||
true_area = 100.0 + math.pi * 25.0 / 2.0
|
||||
# rel=1e-8, not tighter: both tools round to six decimals on the way out.
|
||||
assert measured["area"] == pytest.approx(true_area, rel=1e-8)
|
||||
assert traced["area"] == pytest.approx(measured["area"], rel=1e-9), (
|
||||
"the boundary tool and the measure tool must not disagree about the "
|
||||
"shape the boundary tool just drew"
|
||||
)
|
||||
|
||||
|
||||
async def test_boundary_from_entities_reports_the_same_exact_area(backend):
|
||||
upper = await backend.entity_create_arc(0, 0, 10, 0, 180)
|
||||
lower = await backend.entity_create_arc(0, 0, 10, 180, 360)
|
||||
|
||||
result = await backend.boundary_from_entities([upper.handle, lower.handle])
|
||||
measured = await backend.entity_measure(result["handle"])
|
||||
|
||||
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-8)
|
||||
assert result["area"] == pytest.approx(measured["area"], rel=1e-12), "same six decimals"
|
||||
|
||||
|
||||
# ── boundary_from_entities: the refusals ────────────────────────────────────
|
||||
|
||||
|
||||
async def test_an_open_chain_is_refused_and_names_the_dangling_endpoint(backend):
|
||||
"""Three sides of a rectangle: the ends at (0, 0) and (7, 25) never meet."""
|
||||
first = await backend.entity_create_line(0, 0, 40, 0)
|
||||
second = await backend.entity_create_line(40, 0, 40, 25)
|
||||
third = await backend.entity_create_line(40, 25, 7, 25)
|
||||
|
||||
result = await backend.boundary_from_entities([first.handle, second.handle, third.handle])
|
||||
|
||||
assert result["ok"] is False
|
||||
reported = _numbers(result["error"])
|
||||
assert 7.0 in reported and 25.0 in reported, (
|
||||
"'not closed' without the loose end leaves the caller measuring by hand"
|
||||
)
|
||||
|
||||
|
||||
async def test_a_chain_broken_in_the_middle_is_refused(backend):
|
||||
"""Four segments, all four endpoints paired — except two of them are 6 mm
|
||||
apart. The count is right and the chain is not."""
|
||||
first = await backend.entity_create_line(0, 0, 40, 0)
|
||||
second = await backend.entity_create_line(40, 0, 40, 40)
|
||||
third = await backend.entity_create_line(34, 40, 0, 40) # starts 6 mm inboard
|
||||
fourth = await backend.entity_create_line(0, 40, 0, 0)
|
||||
|
||||
result = await backend.boundary_from_entities(
|
||||
[first.handle, second.handle, third.handle, fourth.handle]
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert 34.0 in _numbers(result["error"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 1])
|
||||
async def test_fewer_than_two_handles_is_refused(backend, count):
|
||||
"""One segment cannot enclose anything; zero is a no-op reported as work."""
|
||||
handles = await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_from_entities(handles[:count])
|
||||
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
async def test_a_non_linear_entity_is_refused_by_type(backend):
|
||||
"""TEXT has no endpoints. Skipping it quietly would chain three of four
|
||||
sides and then blame the geometry for not closing."""
|
||||
handles = await _square_of_lines(backend)
|
||||
text = await backend.entity_create_text("NOTE", 20, 20)
|
||||
|
||||
result = await backend.boundary_from_entities(handles + [text.handle])
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "TEXT" in result["error"]
|
||||
assert text.handle in result["error"]
|
||||
|
||||
|
||||
async def test_an_unresolvable_handle_is_refused_by_name(backend):
|
||||
handles = await _square_of_lines(backend)
|
||||
|
||||
result = await backend.boundary_from_entities(handles + ["NOSUCH"])
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "NOSUCH" in result["error"]
|
||||
@@ -0,0 +1,935 @@
|
||||
"""``cad_batch`` — the ordered multi-tool executor.
|
||||
|
||||
One round trip instead of N, with a later step able to reference an earlier
|
||||
one's handle so the model never has to echo it back.
|
||||
|
||||
Where the win actually comes from, measured on a 17-step mounting-plate task
|
||||
(ISO layers, 4 outline lines, 2 fillets, 2 snapped midpoints, 5 circles, 2
|
||||
dimensions) against the real in-memory server:
|
||||
|
||||
* **Payload alone: 1.28x** — and *0.74x* with ``verbose=True``. The batched
|
||||
request is the LARGER of the two (1,579 vs 1,357 chars: every step carries a
|
||||
``{tool, args, bind}`` wrapper the sequential arm gets free from the protocol
|
||||
envelope). Batching does not win by making payloads smaller.
|
||||
* **Agent loop: 9.07x** — 340,447 -> 37,548 input tokens. This is the real
|
||||
mechanism, and it is *turn elimination*: 18 billed turns become 2, and each
|
||||
turn re-sends the whole conversation plus the 18,306-token tool prefix.
|
||||
|
||||
So the honest framing is that batching multiplies with the discovery lane
|
||||
rather than beating it — search alone is 18.29x on the same task, and the two
|
||||
together are 106.99x. Nothing here should claim batching is the biggest lever.
|
||||
|
||||
What these tests actually guard is the *dangerous* half of the idea, because
|
||||
the happy path is the easy half:
|
||||
|
||||
* **Privilege.** A single ``cad_batch`` grant must not reach the raw
|
||||
command/LISP escape hatches. The deny set is derived from the live
|
||||
``@cad_tool(cost="escape")`` cards rather than hand-listed, so a future
|
||||
escape hatch is denied the day it is registered, and a denied step refuses
|
||||
the *whole* batch before anything executes — otherwise ``on_error="continue"``
|
||||
would turn the denylist into a no-op.
|
||||
* **Error typing.** The re-entrant call path reformats every non-FastMCP
|
||||
exception into ``ToolError(f"Error calling tool {name!r}: {e}")``. The
|
||||
classifier must recover the real cause from the ``__cause__`` chain and
|
||||
never from the message text.
|
||||
* **Atomicity honesty.** ezdxf rolls back by restoring a full DXF snapshot;
|
||||
COM ends an undo mark and fires ``_UNDO B`` at AutoCAD without ever
|
||||
confirming it landed. The payload must say which one it got, and must not
|
||||
claim a rollback it did not verify.
|
||||
* **dry_run.** Validates against each tool's real JSON Schema without
|
||||
executing a single step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import server
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(monkeypatch):
|
||||
"""In-memory client on a fresh headless document.
|
||||
|
||||
The env var is what ``server._make_backend`` reads; setting
|
||||
``config.settings.backend`` would leave a Windows box with AutoCAD open
|
||||
talking to the operator's live drawing.
|
||||
"""
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
async with Client(server.mcp) as connected:
|
||||
status = (await connected.call_tool("system_status", {})).structured_content or {}
|
||||
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
|
||||
await connected.call_tool("drawing_new", {})
|
||||
yield connected
|
||||
|
||||
|
||||
async def _entity_count(client) -> int:
|
||||
info = (await client.call_tool("drawing_info", {})).structured_content or {}
|
||||
return int(info["entity_count"])
|
||||
|
||||
|
||||
async def _batch(client, steps, **kwargs):
|
||||
payload = {"steps": steps, **kwargs}
|
||||
return (await client.call_tool("cad_batch", payload)).structured_content
|
||||
|
||||
|
||||
def dumps(obj) -> str:
|
||||
"""Compact JSON — how the payload is actually charged for."""
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
LINE = "entity_create_line"
|
||||
|
||||
|
||||
def _line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, **extra):
|
||||
return {"tool": LINE, "args": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, **extra}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration and metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cad_batch_is_registered_with_a_discovery_card():
|
||||
tools = {t.name: t for t in await server._registered_tools()}
|
||||
assert "cad_batch" in tools, "cad_batch must be a registered tool"
|
||||
card = (tools["cad_batch"].meta or {})["cad"]
|
||||
assert card["cost"] == "destructive", "it can run any mutating tool, so it costs the worst case"
|
||||
assert card["summary"] and "\n" not in card["summary"]
|
||||
assert card["synonyms"], "the alias corpus must cover it"
|
||||
|
||||
|
||||
async def test_cad_batch_is_filed_under_the_batch_group():
|
||||
groups = await server._tool_groups()
|
||||
assert "cad_batch" in groups["batch"]
|
||||
assert {"entity_batch_create", "entity_batch_modify"} <= set(groups["batch"]), (
|
||||
"the pre-existing batch tools stay: cad_batch supersedes them in scope, not in the registry"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_three_step_batch_runs_in_one_round_trip(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
_line(),
|
||||
_line(y1=10.0, y2=10.0),
|
||||
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert (result["steps"], result["executed"], result["succeeded"], result["failed"]) == (
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
)
|
||||
assert [row["status"] for row in result["results"]] == ["ok", "ok", "ok"]
|
||||
assert await _entity_count(client) == 3
|
||||
|
||||
|
||||
async def test_a_successful_step_reports_only_its_handle_by_default(client):
|
||||
result = await _batch(client, [_line()])
|
||||
payload = result["results"][0]["result"]
|
||||
assert set(payload) == {"handle"}, (
|
||||
"an EntityInfo is fully recoverable via entity_get(handle); re-emitting it "
|
||||
"in every batch row is exactly the cost cad_batch exists to remove"
|
||||
)
|
||||
assert payload["handle"]
|
||||
|
||||
|
||||
async def test_verbose_restores_the_full_step_result(client):
|
||||
result = await _batch(client, [_line()], verbose=True)
|
||||
payload = result["results"][0]["result"]
|
||||
assert payload["handle"] and payload["type"] == "LINE"
|
||||
assert "properties" in payload
|
||||
|
||||
|
||||
async def test_a_result_without_a_handle_is_never_trimmed(client):
|
||||
"""Compaction only drops what ``entity_get(handle)`` can give back."""
|
||||
result = await _batch(client, [{"tool": "drawing_info", "args": {}}])
|
||||
payload = result["results"][0]["result"]
|
||||
assert payload["entity_count"] == 0
|
||||
assert payload["name"]
|
||||
|
||||
|
||||
async def test_steps_run_in_the_order_given(client):
|
||||
"""Reads interleaved with writes see the document as of their own position."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
_line(),
|
||||
{"tool": "drawing_info", "args": {}},
|
||||
{"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}},
|
||||
{"tool": "drawing_info", "args": {}},
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["results"][1]["result"]["entity_count"] == 1
|
||||
assert result["results"][3]["result"]["entity_count"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binding — the half that removes the handle echo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_bound_handle_feeds_a_later_step(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
_line(bind="edge"),
|
||||
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 5.0, "dy": 5.0}},
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["bindings"] == ["edge"]
|
||||
handle = result["results"][0]["result"]["handle"]
|
||||
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
||||
assert got["properties"]["start"][0] == pytest.approx(5.0)
|
||||
|
||||
|
||||
async def test_a_dotted_reference_reaches_into_a_step_result(client):
|
||||
"""``point_from_snap`` returns {x, y}; without ``$p.x`` that is a round trip."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
_line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, bind="edge"),
|
||||
{
|
||||
"tool": "point_from_snap",
|
||||
"args": {"handle": "$edge", "snap": "mid"},
|
||||
"bind": "mid",
|
||||
},
|
||||
{
|
||||
"tool": "entity_create_circle",
|
||||
"args": {"cx": "$mid.x", "cy": "$mid.y", "radius": 1.0},
|
||||
},
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True, result["results"]
|
||||
handle = result["results"][2]["result"]["handle"]
|
||||
circle = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
||||
assert circle["properties"]["center"][0] == pytest.approx(5.0)
|
||||
|
||||
|
||||
async def test_a_list_index_is_a_valid_reference_segment():
|
||||
resolved = server._resolve_batch_ref("$box.handles.1", {"box": {"handles": ["A", "B", "C"]}})
|
||||
assert resolved == "B"
|
||||
|
||||
|
||||
async def test_a_bare_reference_resolves_to_the_handle():
|
||||
resolved = server._resolve_batch_ref("$edge", {"edge": {"handle": "2F1", "type": "LINE"}})
|
||||
assert resolved == "2F1"
|
||||
|
||||
|
||||
async def test_a_bare_reference_to_a_handleless_result_is_a_typed_error():
|
||||
with pytest.raises(server.BatchReferenceError) as excinfo:
|
||||
server._resolve_batch_ref("$info", {"info": {"entity_count": 3}})
|
||||
assert "handle" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_a_doubled_dollar_escapes_a_literal_dollar(client):
|
||||
"""Text really does start with ``$`` sometimes; ``$$`` is the way out."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": "entity_create_text", "args": {"text": "$$4.50", "x": 0, "y": 0, "height": 2.5}}],
|
||||
)
|
||||
handle = result["results"][0]["result"]["handle"]
|
||||
got = (await client.call_tool("entity_get", {"handle": handle})).structured_content
|
||||
assert got["properties"]["text"] == "$4.50"
|
||||
|
||||
|
||||
async def test_only_a_whole_string_is_a_reference():
|
||||
"""Partial interpolation is deliberately unsupported — it is ambiguous."""
|
||||
assert server._substitute_batch_refs("cost is $edge each", {"edge": {"handle": "1"}}) == (
|
||||
"cost is $edge each"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dry_run — the real JSON Schema, nothing executed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dry_run_validates_without_executing(client):
|
||||
result = await _batch(client, [_line(), _line(y1=5.0, y2=5.0)], dry_run=True)
|
||||
assert result["dry_run"] is True
|
||||
assert result["ok"] is True
|
||||
assert result["executed"] == 0
|
||||
assert [row["status"] for row in result["results"]] == ["valid", "valid"]
|
||||
assert await _entity_count(client) == 0, "a dry run must not draw anything"
|
||||
|
||||
|
||||
async def test_dry_run_rejects_a_missing_required_argument(client):
|
||||
result = await _batch(client, [{"tool": LINE, "args": {"x1": 0, "y1": 0}}], dry_run=True)
|
||||
assert result["ok"] is False
|
||||
row = result["results"][0]
|
||||
assert row["status"] == "invalid"
|
||||
assert row["error"]["kind"] == "invalid_args"
|
||||
assert "x2" in row["error"]["message"]
|
||||
|
||||
|
||||
async def test_dry_run_rejects_an_argument_of_the_wrong_type(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": LINE, "args": {"x1": "over there", "y1": 0, "x2": 1, "y2": 1}}],
|
||||
dry_run=True,
|
||||
)
|
||||
assert result["results"][0]["error"]["kind"] == "invalid_args"
|
||||
|
||||
|
||||
async def test_dry_run_rejects_an_unknown_argument(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": LINE, "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 1, "colour": 3}}],
|
||||
dry_run=True,
|
||||
)
|
||||
assert result["results"][0]["error"]["kind"] == "invalid_args"
|
||||
|
||||
|
||||
async def test_dry_run_names_an_unknown_tool(client):
|
||||
result = await _batch(client, [{"tool": "entity_create_dragon", "args": {}}], dry_run=True)
|
||||
row = result["results"][0]
|
||||
assert row["error"]["kind"] == "unknown_tool"
|
||||
assert "entity_create_dragon" in row["error"]["message"]
|
||||
|
||||
|
||||
async def test_dry_run_catches_a_forward_reference(client):
|
||||
"""A step may only reference something an *earlier* step bound."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
{"tool": "entity_move", "args": {"handle": "$edge", "dx": 1.0, "dy": 0.0}},
|
||||
_line(bind="edge"),
|
||||
],
|
||||
dry_run=True,
|
||||
)
|
||||
row = result["results"][0]
|
||||
assert row["error"]["kind"] == "unresolved_ref"
|
||||
assert "edge" in row["error"]["message"]
|
||||
|
||||
|
||||
async def test_dry_run_catches_a_reference_nobody_binds(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": "entity_move", "args": {"handle": "$typo", "dx": 1.0, "dy": 0.0}}],
|
||||
dry_run=True,
|
||||
)
|
||||
assert result["results"][0]["error"]["kind"] == "unresolved_ref"
|
||||
|
||||
|
||||
async def test_a_reference_stands_in_for_any_type_during_validation(client):
|
||||
"""``$mid.x`` is a float at run time and a string at validation time.
|
||||
|
||||
The validator must not reject it for that — but it must still check
|
||||
everything around it, which the following test proves.
|
||||
"""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
_line(bind="edge"),
|
||||
{"tool": "point_from_snap", "args": {"handle": "$edge", "snap": "mid"}, "bind": "m"},
|
||||
{"tool": "entity_create_circle", "args": {"cx": "$m.x", "cy": "$m.y", "radius": 1.0}},
|
||||
],
|
||||
dry_run=True,
|
||||
)
|
||||
assert result["ok"] is True, result["results"]
|
||||
|
||||
|
||||
async def test_a_reference_does_not_excuse_the_rest_of_the_step(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
_line(bind="edge"),
|
||||
{"tool": "entity_move", "args": {"handle": "$edge", "dx": "sideways", "dy": 0.0}},
|
||||
],
|
||||
dry_run=True,
|
||||
)
|
||||
assert result["results"][1]["error"]["kind"] == "invalid_args"
|
||||
|
||||
|
||||
async def test_an_invalid_step_stops_a_real_batch_before_anything_runs(client):
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await _batch(client, [_line(), {"tool": "entity_create_dragon", "args": {}}])
|
||||
assert "entity_create_dragon" in str(excinfo.value)
|
||||
assert await _entity_count(client) == 0, "validation must fail closed, before step 1 draws"
|
||||
|
||||
|
||||
async def test_a_malformed_step_is_reported_not_crashed(client):
|
||||
result = await _batch(client, [{"args": {"x1": 0}}], dry_run=True)
|
||||
assert result["results"][0]["error"]["kind"] == "malformed_step"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privilege — the deny set
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_deny_set_is_derived_from_the_escape_cost_cards():
|
||||
"""Hand-listing would go stale the day a new escape hatch is registered."""
|
||||
escapes = {
|
||||
tool.name
|
||||
for tool in await server._registered_tools()
|
||||
if ((tool.meta or {}).get("cad") or {}).get("cost") == "escape"
|
||||
}
|
||||
assert escapes, "the registry must still carry escape-cost tools for this to mean anything"
|
||||
assert server._batch_denied_tools() == escapes | {"cad_batch"}
|
||||
|
||||
|
||||
async def test_the_raw_command_and_lisp_hatches_are_denied():
|
||||
denied = server._batch_denied_tools()
|
||||
assert {"system_run_command", "system_run_lisp"} <= denied
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", ["system_run_command", "system_run_lisp"])
|
||||
async def test_a_denied_tool_refuses_the_whole_batch(client, tool):
|
||||
args = {"command": "_ZOOM E"} if tool.endswith("command") else {"expression": "(+ 1 1)"}
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await _batch(client, [_line(), {"tool": tool, "args": args}])
|
||||
message = str(excinfo.value)
|
||||
assert tool in message
|
||||
assert "denied" in message.lower() or "not callable" in message.lower()
|
||||
assert await _entity_count(client) == 0, "a denied step must abort before step 1 draws"
|
||||
|
||||
|
||||
async def test_a_denied_step_is_not_survivable_with_on_error_continue(client):
|
||||
"""Otherwise the deny set would just be a step you skip past."""
|
||||
with pytest.raises(ToolError):
|
||||
await _batch(
|
||||
client,
|
||||
[{"tool": "system_run_lisp", "args": {"expression": "(+ 1 1)"}}, _line()],
|
||||
on_error="continue",
|
||||
)
|
||||
assert await _entity_count(client) == 0
|
||||
|
||||
|
||||
async def test_cad_batch_cannot_nest_inside_itself(client):
|
||||
"""No recursion, and no laundering a denied step through a nested executor."""
|
||||
result = await _batch(
|
||||
client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}], dry_run=True
|
||||
)
|
||||
assert result["results"][0]["error"]["kind"] == "denied"
|
||||
with pytest.raises(ToolError, match="cad_batch"):
|
||||
await _batch(client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}])
|
||||
|
||||
|
||||
async def test_a_tool_with_no_discovery_card_is_not_run_blind(client):
|
||||
"""Fail closed on unknown provenance: the deny set is computed from the local
|
||||
registry, but a mounted or transformed provider can supply a tool it never saw.
|
||||
Every tool this server registers carries a card (there is a coverage gate),
|
||||
so this costs the native surface nothing."""
|
||||
|
||||
@server.mcp.tool(name="_batch_uncarded_probe")
|
||||
async def _uncarded(ctx=None) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
try:
|
||||
result = await _batch(client, [{"tool": "_batch_uncarded_probe", "args": {}}], dry_run=True)
|
||||
assert result["results"][0]["error"]["kind"] == "denied"
|
||||
assert "card" in result["results"][0]["error"]["message"]
|
||||
finally:
|
||||
server.mcp.local_provider.remove_tool("_batch_uncarded_probe")
|
||||
|
||||
|
||||
async def test_a_dry_run_reports_a_denied_step_rather_than_hiding_it(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}],
|
||||
dry_run=True,
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert result["results"][0]["error"]["kind"] == "denied"
|
||||
|
||||
|
||||
async def test_the_refusal_points_at_the_direct_tool(client):
|
||||
"""The escape hatch stays reachable — separately, where its own gate applies."""
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await _batch(client, [{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}])
|
||||
assert "directly" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production laundering vector: DISCOVERY_MODE=search
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# In search mode the advertised surface is just ``search_tools`` + ``call_tool``,
|
||||
# and ``call_tool`` invokes any tool by name. That is a general-purpose proxy
|
||||
# sitting inside the same server as a denylist — i.e. exactly the shape of a way
|
||||
# around it. The deny set is derived from ``cost="escape"`` cards, and the proxy
|
||||
# is not a local component, so nothing in the *denylist* stops it; what stops it
|
||||
# is the fail-closed "no @cad_tool card, no execution" rule.
|
||||
#
|
||||
# These pin that, because the coupling is invisible: giving ``call_tool`` a card
|
||||
# — an entirely reasonable thing to want, so it is costed and discoverable —
|
||||
# silently reopens the escalation path with no other test failing.
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def search_mode(monkeypatch):
|
||||
"""The real search surface, restored to ``off`` afterwards."""
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
server._apply_discovery_mode("search")
|
||||
try:
|
||||
async with Client(server.mcp) as connected:
|
||||
yield connected
|
||||
finally:
|
||||
server._apply_discovery_mode("off")
|
||||
|
||||
|
||||
async def test_search_mode_advertises_the_proxy_that_makes_this_a_risk(search_mode):
|
||||
names = {tool.name for tool in await search_mode.list_tools()}
|
||||
assert names == {"search_tools", "call_tool"}, (
|
||||
"if the search surface changed, the laundering test below is aiming at the wrong target"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hatch", ["system_run_command", "system_run_lisp"])
|
||||
async def test_the_search_proxy_cannot_launder_a_denied_hatch(search_mode, hatch):
|
||||
"""cad_batch -> call_tool -> system_run_* must not execute."""
|
||||
result = (
|
||||
await search_mode.call_tool(
|
||||
"call_tool",
|
||||
{
|
||||
"name": "cad_batch",
|
||||
"arguments": {
|
||||
"steps": [{"tool": "call_tool", "args": {"name": hatch, "arguments": {}}}],
|
||||
"dry_run": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
).structured_content or {}
|
||||
assert result["ok"] is False
|
||||
assert result["results"][0]["error"]["kind"] == "denied"
|
||||
|
||||
|
||||
async def test_the_laundered_hatch_is_refused_before_anything_executes(search_mode):
|
||||
"""Not merely reported — the whole batch is refused, so the proxy never runs."""
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await search_mode.call_tool(
|
||||
"call_tool",
|
||||
{
|
||||
"name": "cad_batch",
|
||||
"arguments": {
|
||||
"steps": [
|
||||
{
|
||||
"tool": "call_tool",
|
||||
"args": {
|
||||
"name": "system_run_command",
|
||||
"arguments": {"command": "_ZOOM E"},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
assert "refused all 1 steps - nothing was executed" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_cad_batch_still_works_through_the_search_proxy(search_mode):
|
||||
"""The denial above must be a denial, not search mode breaking batching."""
|
||||
result = (
|
||||
await search_mode.call_tool(
|
||||
"call_tool",
|
||||
{
|
||||
"name": "cad_batch",
|
||||
"arguments": {
|
||||
"steps": [{"tool": "drawing_new", "args": {}}, _line()],
|
||||
},
|
||||
},
|
||||
)
|
||||
).structured_content or {}
|
||||
assert result["ok"] is True
|
||||
assert result["succeeded"] == 2
|
||||
|
||||
|
||||
async def test_a_batch_step_may_still_call_the_older_batch_tools(client):
|
||||
"""The reconciliation: they compose. cad_batch orders, entity_batch_create bulks."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
{
|
||||
"tool": "entity_batch_create",
|
||||
"args": {
|
||||
"entities": [
|
||||
{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1},
|
||||
{"type": "circle", "cx": 0, "cy": 0, "radius": 1},
|
||||
]
|
||||
},
|
||||
},
|
||||
_line(),
|
||||
],
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert await _entity_count(client) == 3
|
||||
|
||||
|
||||
async def test_a_tool_hidden_by_the_profile_is_not_reachable_through_a_batch(client):
|
||||
"""cad_batch must not be a way around TOOL_PROFILE or the ENABLE_3D gate."""
|
||||
result = await _batch(client, [{"tool": "solid_box", "args": {}}], dry_run=True)
|
||||
assert result["results"][0]["error"]["kind"] == "unknown_tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error taxonomy — typed, never string-sniffed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_backend_capability_refusal_is_typed_as_unsupported(client, tmp_path):
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "part.dwg")}}],
|
||||
on_error="continue",
|
||||
)
|
||||
error = result["results"][0]["error"]
|
||||
assert error["kind"] == "unsupported"
|
||||
assert error["capability"] == "dwg", "the capability key must survive the re-entrant wrapper"
|
||||
|
||||
|
||||
async def test_the_classifier_reads_the_cause_chain_not_the_message():
|
||||
"""The proof that no message text is being sniffed: same words, different type."""
|
||||
from backends.ezdxf_backend import UnsupportedCapabilityError
|
||||
|
||||
liar = RuntimeError("the headless ezdxf backend cannot write DWG (capability 'dwg')")
|
||||
assert server._classify_batch_error(liar)["kind"] == "failed"
|
||||
|
||||
honest = ToolError("Error calling tool 'drawing_save_as': nothing useful here")
|
||||
honest.__cause__ = UnsupportedCapabilityError("dwg", "no DWG writer")
|
||||
classified = server._classify_batch_error(honest)
|
||||
assert classified["kind"] == "unsupported"
|
||||
assert classified["capability"] == "dwg"
|
||||
|
||||
|
||||
async def test_a_backend_failure_is_typed_as_failed(client):
|
||||
result = await _batch(
|
||||
client, [{"tool": "entity_get", "args": {"handle": "NOSUCH"}}], on_error="continue"
|
||||
)
|
||||
assert result["results"][0]["error"]["kind"] == "failed"
|
||||
|
||||
|
||||
async def test_a_deliberate_tool_refusal_is_typed_as_refused(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[{"tool": "drawing_open", "args": {"path": "../../etc/passwd"}}],
|
||||
on_error="continue",
|
||||
)
|
||||
assert result["results"][0]["error"]["kind"] == "refused"
|
||||
|
||||
|
||||
async def test_a_bound_value_of_the_wrong_type_is_typed_at_run_time(client):
|
||||
"""The one thing dry_run cannot check, reported precisely when it happens."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
{"tool": "entity_create_text", "args": {"text": "T", "x": 0, "y": 0}, "bind": "t"},
|
||||
{"tool": "entity_move", "args": {"handle": "$t", "dx": "$t.type", "dy": 0.0}},
|
||||
],
|
||||
on_error="continue",
|
||||
)
|
||||
assert result["results"][1]["error"]["kind"] == "invalid_args"
|
||||
|
||||
|
||||
async def test_every_reported_kind_is_a_declared_kind(client, tmp_path):
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
|
||||
{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "p.dwg")}},
|
||||
_line(),
|
||||
],
|
||||
on_error="continue",
|
||||
)
|
||||
kinds = {row["error"]["kind"] for row in result["results"] if row["status"] == "error"}
|
||||
assert kinds <= set(server.BATCH_ERROR_KINDS)
|
||||
assert kinds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stop_is_the_default_because_both_backends_deliver_it(client):
|
||||
result = await _batch(client, [_line()])
|
||||
assert result["on_error"] == "stop"
|
||||
|
||||
|
||||
async def test_stop_leaves_earlier_steps_applied_and_says_so(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
|
||||
on_error="stop",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
statuses = [row["status"] for row in result["results"]]
|
||||
assert statuses == ["ok", "error", "skipped"]
|
||||
assert result["skipped"] == 1
|
||||
assert await _entity_count(client) == 1
|
||||
assert result["atomicity"]["rolled_back"] is False
|
||||
assert result["atomicity"]["guarantee"] == "none"
|
||||
|
||||
|
||||
async def test_continue_runs_every_step(client):
|
||||
result = await _batch(
|
||||
client,
|
||||
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)],
|
||||
on_error="continue",
|
||||
)
|
||||
assert [row["status"] for row in result["results"]] == ["ok", "error", "ok"]
|
||||
assert result["succeeded"] == 2 and result["failed"] == 1
|
||||
assert await _entity_count(client) == 2
|
||||
|
||||
|
||||
async def test_continue_does_not_execute_a_step_whose_binding_never_landed(client):
|
||||
"""A dependent step must not silently run on a stale or missing value."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
{"tool": "entity_get", "args": {"handle": "NOSUCH"}, "bind": "ghost"},
|
||||
{"tool": "entity_move", "args": {"handle": "$ghost", "dx": 1.0, "dy": 0.0}},
|
||||
_line(),
|
||||
],
|
||||
on_error="continue",
|
||||
)
|
||||
assert result["results"][0]["status"] == "error"
|
||||
assert result["results"][1]["status"] == "error"
|
||||
assert result["results"][1]["error"]["kind"] == "unresolved_ref"
|
||||
assert result["results"][2]["status"] == "ok"
|
||||
|
||||
|
||||
async def test_an_unknown_on_error_mode_is_rejected(client):
|
||||
with pytest.raises(ToolError):
|
||||
await _batch(client, [_line()], on_error="pray")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Atomicity — honest per backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Caps:
|
||||
def __init__(self, mode, supported=True):
|
||||
from backends.base import CapabilityMap, FeatureCapability
|
||||
|
||||
self.map = CapabilityMap(
|
||||
backend="stub", features={"transactions": FeatureCapability(supported, mode)}
|
||||
)
|
||||
|
||||
def capabilities(self):
|
||||
return self.map
|
||||
|
||||
|
||||
async def test_a_snapshot_backend_is_reported_as_a_snapshot_guarantee():
|
||||
guarantee, note = server._batch_rollback_guarantee(_Caps("snapshot"))
|
||||
assert guarantee == "snapshot"
|
||||
assert "snapshot" in note.lower()
|
||||
|
||||
|
||||
async def test_an_undo_mark_backend_is_never_called_atomic():
|
||||
"""COM ends the undo mark and fires ``_UNDO B`` without confirming it landed."""
|
||||
guarantee, note = server._batch_rollback_guarantee(_Caps("undo_mark"))
|
||||
assert guarantee == "best_effort_undo"
|
||||
lowered = note.lower()
|
||||
assert "not atomic" in lowered, "it must say so, not merely omit the word"
|
||||
assert "best-effort" in lowered
|
||||
assert "does not confirm" in lowered
|
||||
|
||||
|
||||
async def test_a_backend_without_transactions_gets_no_guarantee():
|
||||
guarantee, _ = server._batch_rollback_guarantee(_Caps(None, supported=False))
|
||||
assert guarantee == "none"
|
||||
|
||||
|
||||
async def test_an_unrecognised_transaction_mode_is_not_assumed_good():
|
||||
guarantee, _ = server._batch_rollback_guarantee(_Caps("time_travel"))
|
||||
assert guarantee == "unverified"
|
||||
|
||||
|
||||
async def test_rollback_restores_the_document_on_ezdxf(client):
|
||||
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
|
||||
result = await _batch(
|
||||
client,
|
||||
[_line(), _line(y1=4.0, y2=4.0), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
|
||||
on_error="rollback",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert await _entity_count(client) == 1, "the two batch lines must be gone"
|
||||
atomicity = result["atomicity"]
|
||||
assert atomicity["guarantee"] == "snapshot"
|
||||
assert atomicity["rolled_back"] is True
|
||||
assert atomicity["verified"] is True
|
||||
assert atomicity["before"] == atomicity["after"]
|
||||
|
||||
|
||||
async def test_a_successful_rollback_batch_commits_and_keeps_its_work(client):
|
||||
result = await _batch(client, [_line(), _line(y1=4.0, y2=4.0)], on_error="rollback")
|
||||
assert result["ok"] is True
|
||||
assert result["atomicity"]["rolled_back"] is False
|
||||
assert result["atomicity"]["committed"] is True
|
||||
assert await _entity_count(client) == 2
|
||||
|
||||
|
||||
async def test_rollback_reports_the_guarantee_it_actually_got(client):
|
||||
result = await _batch(client, [_line()], on_error="rollback")
|
||||
atomicity = result["atomicity"]
|
||||
assert atomicity["mode"] == "rollback"
|
||||
assert atomicity["guarantee"] in server.BATCH_ROLLBACK_GUARANTEES
|
||||
assert atomicity["note"]
|
||||
|
||||
|
||||
async def test_rollback_refuses_when_the_backend_declines_the_checkpoint(client, monkeypatch):
|
||||
"""A checkpoint we did not open is not one we can roll back to.
|
||||
|
||||
This is the live COM path: ``ComBackend.transaction_begin`` *returns*
|
||||
``{"ok": False, "error": "A transaction is already active"}`` rather than
|
||||
raising, so a batch that took the return value on faith would run every
|
||||
step and then report a rollback it never had. Simulated on the headless
|
||||
backend, whose own transaction stack happily nests.
|
||||
"""
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
async def _decline(self):
|
||||
return {"ok": False, "error": "A transaction is already active"}
|
||||
|
||||
monkeypatch.setattr(EzdxfBackend, "transaction_begin", _decline)
|
||||
result = await _batch(client, [_line()], on_error="rollback")
|
||||
assert result["ok"] is False
|
||||
assert result["executed"] == 0
|
||||
assert result["atomicity"]["transaction"] is False
|
||||
assert "already active" in result["atomicity"]["note"]
|
||||
assert await _entity_count(client) == 0, "nothing may run without the checkpoint"
|
||||
|
||||
|
||||
async def test_a_rollback_batch_does_not_disturb_a_transaction_the_caller_opened(client):
|
||||
"""The headless backend's checkpoints are a stack; ours must pop only its own."""
|
||||
await client.call_tool("transaction_begin", {})
|
||||
await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1})
|
||||
result = await _batch(
|
||||
client,
|
||||
[_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}],
|
||||
on_error="rollback",
|
||||
)
|
||||
assert result["atomicity"]["rolled_back"] is True
|
||||
assert await _entity_count(client) == 1, "the caller's circle survives our rollback"
|
||||
await client.call_tool("transaction_rollback", {})
|
||||
assert await _entity_count(client) == 0, "the caller's own checkpoint still works"
|
||||
|
||||
|
||||
async def test_non_rollback_modes_make_no_atomicity_claim(client):
|
||||
"""And say it in four keys, not in two paragraphs about a rollback that was
|
||||
never on the table — that boilerplate measured 61% of a short batch's reply."""
|
||||
for mode in ("stop", "continue"):
|
||||
result = await _batch(client, [_line()], on_error=mode)
|
||||
atomicity = result["atomicity"]
|
||||
assert atomicity["transaction"] is False
|
||||
assert atomicity["rolled_back"] is False
|
||||
assert atomicity["guarantee"] == "none"
|
||||
assert set(atomicity) == {"mode", "guarantee", "transaction", "rolled_back", "note"}
|
||||
assert "atomic" not in atomicity["note"].lower()
|
||||
assert len(dumps(atomicity)) < 200
|
||||
|
||||
|
||||
async def test_a_dry_run_opens_no_transaction(client):
|
||||
result = await _batch(client, [_line()], on_error="rollback", dry_run=True)
|
||||
assert result["atomicity"]["transaction"] is False
|
||||
status = (await client.call_tool("system_status", {})).structured_content or {}
|
||||
assert status.get("transaction_depth", 0) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Limits and progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_an_empty_batch_is_rejected(client):
|
||||
with pytest.raises(ToolError):
|
||||
await _batch(client, [])
|
||||
|
||||
|
||||
async def test_the_step_count_is_capped(client):
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await _batch(client, [_line()] * (server.MAX_BATCH_STEPS + 1))
|
||||
assert str(server.MAX_BATCH_STEPS) in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_progress_is_reported_for_every_step(monkeypatch):
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
seen: list[tuple[float, float | None]] = []
|
||||
|
||||
async def handler(progress, total, message=None):
|
||||
seen.append((progress, total))
|
||||
|
||||
async with Client(server.mcp, progress_handler=handler) as connected:
|
||||
await connected.call_tool("drawing_new", {})
|
||||
await connected.call_tool(
|
||||
"cad_batch", {"steps": [_line(), _line(y1=2.0, y2=2.0), _line(y1=4.0, y2=4.0)]}
|
||||
)
|
||||
assert seen, "a long batch is the canonical case for ctx.report_progress"
|
||||
assert (3.0, 3.0) in [(p, t) for p, t in seen], "the final tick must report completion"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The rollback claim must come from the backend, not from control flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_rollback_is_not_claimed_when_the_backend_declines_it(client):
|
||||
"""A step that commits consumes the checkpoint out from under us.
|
||||
|
||||
``transaction_rollback()`` then returns ``{"ok": False, "error": "No active
|
||||
transaction to rollback"}``. Reporting ``rolled_back: True`` off control
|
||||
flow would be the same class of lie as writing DXF bytes into a .dwg.
|
||||
"""
|
||||
steps = [
|
||||
_line(),
|
||||
{"tool": "transaction_commit", "args": {}},
|
||||
{"tool": "entity_get", "args": {"handle": "NOSUCH"}},
|
||||
]
|
||||
result = await _batch(client, steps, on_error="rollback")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["atomicity"]["rolled_back"] is False, (
|
||||
"the backend refused the rollback; the payload must not claim it happened"
|
||||
)
|
||||
assert await _entity_count(client) == 1, "the line really did survive"
|
||||
|
||||
|
||||
async def test_commit_is_not_claimed_when_the_backend_declines_it(client):
|
||||
"""Same rule on the success path: a step that already committed leaves
|
||||
nothing for the batch's own commit to do."""
|
||||
steps = [_line(), {"tool": "transaction_commit", "args": {}}]
|
||||
result = await _batch(client, steps, on_error="rollback")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["atomicity"]["committed"] is False, (
|
||||
"the checkpoint was already gone; do not report a commit that did not happen"
|
||||
)
|
||||
|
||||
|
||||
async def test_a_read_only_finding_is_not_a_failed_step(client):
|
||||
"""A checker reporting findings must not be mistaken for a step that failed.
|
||||
|
||||
``validation_check`` is ``readOnlyHint=True`` and answers
|
||||
``{"ok": len(issues) == 0}`` — ``ok: False`` there means *it found something*,
|
||||
not *it could not run*. Treating that as a refusal made a read-only check
|
||||
trigger a verified rollback that destroyed the geometry the batch had just
|
||||
drawn. A mutator's ``ok: False`` still means it did not mutate.
|
||||
"""
|
||||
steps = [_line(), {"tool": "validation_check", "args": {}}]
|
||||
result = await _batch(client, steps, on_error="rollback")
|
||||
|
||||
assert result["succeeded"] == 2
|
||||
assert result["failed"] == 0
|
||||
assert result["ok"] is True
|
||||
assert result["atomicity"]["rolled_back"] is False
|
||||
assert await _entity_count(client) == 1, "the read-only check must not undo the line"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""``CAD_PROGID`` — which COM application the live backend attaches to.
|
||||
|
||||
The ProgID was hardcoded twice inside ``_acad_app``, so the only CAD this server
|
||||
could ever drive was AutoCAD itself. GstarCAD, ZWCAD and BricsCAD all expose the
|
||||
same ActiveX object model under a different ProgID, and the connection is the
|
||||
only thing standing between them and the existing 100+ COM methods.
|
||||
|
||||
Two things make this more delicate than a string swap, and both are tested here:
|
||||
|
||||
* **Nothing may connect.** These tests run on a machine with AutoCAD installed.
|
||||
The ``Dispatch`` branch is not a passive probe — it COM-launches the
|
||||
application and sets ``Visible = True``. So the fake below records the ProgID
|
||||
it was *asked* for and returns a stub; every assertion is on the recorded
|
||||
calls, never on a returned object. ``raising=False`` on the ``win32com`` patch
|
||||
is load-bearing: that name only exists in the module when the pywin32 imports
|
||||
succeeded, so without it these tests would pass on a Windows dev box and error
|
||||
on Linux CI.
|
||||
* **The cache would hide the bug.** ``_acad_app`` short-circuits on
|
||||
``_COM_STATE["app"]``, so a warm cache makes it perform zero lookups and the
|
||||
test asserts nothing at all. The fixture rebinds ``_COM_STATE`` to a fresh
|
||||
dict rather than mutating it — that also guarantees a test can never leave a
|
||||
fake application object in the real process-wide cache.
|
||||
|
||||
An unknown ProgID fails loudly rather than falling back to AutoCAD. Unlike
|
||||
``TOOL_PROFILE`` and ``DISCOVERY_MODE``, which fall back with a warning because
|
||||
they have closed enums, this is an open string: an unrecognised value is not
|
||||
"invalid", it is "unverifiable by us", and it has no principled substitute. The
|
||||
blast radius decides it — refusing costs a readable startup error, while falling
|
||||
back launches and attaches to a live AutoCAD the user explicitly said they were
|
||||
not using.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from backends import com_backend as cb
|
||||
|
||||
DEFAULT = "AutoCAD.Application"
|
||||
OTHER = "BricscadApp.AcadApplication"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_calls(monkeypatch):
|
||||
"""Record every ProgID ``_acad_app`` asks for, and connect to nothing."""
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def _get_active_object(progid):
|
||||
calls.append(("GetActiveObject", progid))
|
||||
raise OSError("no running instance (fake)")
|
||||
|
||||
def _dispatch(progid):
|
||||
calls.append(("Dispatch", progid))
|
||||
return types.SimpleNamespace(Visible=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
cb,
|
||||
"win32com",
|
||||
types.SimpleNamespace(
|
||||
client=types.SimpleNamespace(
|
||||
GetActiveObject=_get_active_object,
|
||||
Dispatch=_dispatch,
|
||||
)
|
||||
),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(cb, "_COM_STATE", {})
|
||||
return calls
|
||||
|
||||
|
||||
# -- the setting ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_default_progid_is_autocad(monkeypatch):
|
||||
monkeypatch.delenv("CAD_PROGID", raising=False)
|
||||
assert config.settings.cad_progid == DEFAULT
|
||||
|
||||
|
||||
def test_the_setting_is_read_live_and_stripped(monkeypatch):
|
||||
monkeypatch.setenv("CAD_PROGID", f" {OTHER} ")
|
||||
assert config.settings.cad_progid == OTHER
|
||||
|
||||
|
||||
def test_case_is_preserved_because_the_value_is_shown_to_humans(monkeypatch):
|
||||
"""``gstarcad.application`` in an error message reads like a typo we made."""
|
||||
monkeypatch.setenv("CAD_PROGID", "GstarCAD.Application")
|
||||
assert config.settings.cad_progid == "GstarCAD.Application"
|
||||
|
||||
|
||||
def test_an_empty_value_falls_back_to_the_default(monkeypatch):
|
||||
monkeypatch.setenv("CAD_PROGID", " ")
|
||||
assert config.settings.cad_progid == DEFAULT
|
||||
|
||||
|
||||
def test_assigning_to_the_setting_fails_loudly(monkeypatch):
|
||||
"""Same reasoning as ``backend``: a silent no-op assignment reads as control."""
|
||||
monkeypatch.setenv("CAD_PROGID", OTHER)
|
||||
with pytest.raises(AttributeError):
|
||||
config.settings.cad_progid = DEFAULT
|
||||
assert config.settings.cad_progid == OTHER
|
||||
|
||||
|
||||
# -- what the connection actually asks for ----------------------------------
|
||||
|
||||
|
||||
def test_unset_attaches_to_autocad_on_both_paths(monkeypatch, com_calls):
|
||||
monkeypatch.delenv("CAD_PROGID", raising=False)
|
||||
cb._acad_app()
|
||||
assert com_calls == [("GetActiveObject", DEFAULT), ("Dispatch", DEFAULT)]
|
||||
|
||||
|
||||
def test_the_configured_progid_reaches_both_call_sites(monkeypatch, com_calls):
|
||||
"""The Dispatch fallback is the one that launches an application.
|
||||
|
||||
Honouring the setting on ``GetActiveObject`` and then quietly launching
|
||||
AutoCAD on the fallback would be worse than ignoring the setting entirely.
|
||||
"""
|
||||
monkeypatch.setenv("CAD_PROGID", OTHER)
|
||||
cb._acad_app()
|
||||
assert [progid for _, progid in com_calls] == [OTHER, OTHER]
|
||||
assert DEFAULT not in [progid for _, progid in com_calls]
|
||||
|
||||
|
||||
def test_a_failed_connection_names_the_progid_it_tried(monkeypatch, com_calls):
|
||||
def _boom(progid):
|
||||
com_calls.append(("Dispatch", progid))
|
||||
raise OSError("class not registered (fake)")
|
||||
|
||||
monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom)
|
||||
monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered")
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
cb._acad_app()
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "Nope.NotRegistered" in message, "a bare 'cannot connect to AutoCAD' misdirects"
|
||||
assert "CAD_PROGID" in message, "the message must say which knob to turn"
|
||||
assert DEFAULT not in [progid for _, progid in com_calls], "must never fall back to AutoCAD"
|
||||
|
||||
|
||||
def test_a_failed_connection_does_not_poison_the_cache(monkeypatch, com_calls):
|
||||
def _boom(progid):
|
||||
raise OSError("class not registered (fake)")
|
||||
|
||||
monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom)
|
||||
monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered")
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
cb._acad_app()
|
||||
assert "app" not in cb._COM_STATE
|
||||
@@ -33,11 +33,12 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
|
||||
"reason": None,
|
||||
}
|
||||
assert caps["features"]["mleader"]["mode"] == "composite"
|
||||
# v1.4: paper-space layouts/viewports are native on both backends;
|
||||
# projecting model content through viewports stays COM-only, and 3D
|
||||
# ACIS solids remain impossible headlessly.
|
||||
# Paper-space layouts/viewports are native on both backends. Viewport
|
||||
# model projection was declared COM-only in v1.4 and is not — measured in
|
||||
# tests/test_viewport_ops.py, which renders a sheet holding nothing but a
|
||||
# viewport. 3D ACIS solids do remain impossible headlessly.
|
||||
assert caps["features"]["paper_space"]["supported"] is True
|
||||
assert caps["features"]["viewport_render"]["supported"] is False
|
||||
assert caps["features"]["viewport_render"]["supported"] is True
|
||||
assert caps["features"]["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""``@capability``: the escape hatch that is not allowed to become a hole.
|
||||
|
||||
M7 split a 1854-line ABC with 112 abstract methods into per-domain contracts.
|
||||
The split makes contract changes navigable; it does not fix the reason contract
|
||||
changes hurt, which is that every method is an `@abstractmethod`, so adding one
|
||||
makes *both* backends fail at instantiation until both are written — and two
|
||||
test modules construct `ComBackend` at import time, so a half-finished edit
|
||||
takes the whole suite red.
|
||||
|
||||
`@capability` gives such a method a default. The default is the dangerous part,
|
||||
so it is pinned here:
|
||||
|
||||
* It **raises** `UnsupportedCapabilityError`, never returns a quiet
|
||||
`{"ok": False}`. A method nobody implemented must be indistinguishable from a
|
||||
deliberate refusal, not from a soft failure — the v1.5.0 honesty pass exists
|
||||
because things that quietly did nothing looked like things that worked.
|
||||
* Its capability key is **declared in both backends' capability maps**. Without
|
||||
this test the decorator would be a way to ship refusals that
|
||||
`system_capabilities` never mentions, which is worse than the build break it
|
||||
replaces: a client that pre-checks the feature map would be told the call is
|
||||
fine and then be refused.
|
||||
* Overriding it is ordinary subclassing, so an implementing backend pays
|
||||
nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import AutoCADBackend, UnsupportedCapabilityError
|
||||
from backends.capability import capability_keys, is_capability_default
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
# Marked per-test rather than module-wide: half of these are plain registry
|
||||
# checks, and pytest-asyncio warns on a sync test carrying the mark.
|
||||
asyncio_test = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _feature_keys(backend) -> set[str]:
|
||||
return set(backend.capabilities().to_dict()["features"])
|
||||
|
||||
|
||||
# ── the registry is real ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_at_least_one_method_uses_the_decorator():
|
||||
"""An unused abstraction is dead weight; this keeps the gate meaningful."""
|
||||
assert capability_keys(), "no @capability methods — delete the decorator or use it"
|
||||
|
||||
|
||||
def test_every_declared_key_exists_in_both_capability_maps():
|
||||
"""The gate. A refusal the feature map never mentions is a lie by omission."""
|
||||
ezdxf_keys = _feature_keys(EzdxfBackend())
|
||||
com_keys = _feature_keys(ComBackend())
|
||||
for key in capability_keys():
|
||||
assert key in ezdxf_keys, f"@capability({key!r}) is undeclared on the ezdxf backend"
|
||||
assert key in com_keys, f"@capability({key!r}) is undeclared on the COM backend"
|
||||
|
||||
|
||||
def test_the_contract_still_exposes_every_method_it_used_to():
|
||||
"""The mixin split moved code; it must not have dropped any."""
|
||||
for names in capability_keys().values():
|
||||
for name in names:
|
||||
assert callable(getattr(AutoCADBackend, name, None)), f"{name} vanished in the split"
|
||||
|
||||
|
||||
# ── the default behaves like a deliberate refusal ───────────────────────────
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_a_backend_that_does_not_implement_it_refuses_with_the_key():
|
||||
backend = ComBackend()
|
||||
assert is_capability_default(ComBackend, "entity_change_space"), (
|
||||
"fixture assumption: the COM backend inherits the default here"
|
||||
)
|
||||
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.entity_change_space(["1A"], "2B")
|
||||
|
||||
assert excinfo.value.capability == "chspace"
|
||||
payload = excinfo.value.to_dict()
|
||||
assert payload["ok"] is False
|
||||
assert payload["capability"] == "chspace"
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_the_refusal_names_the_backend_and_says_what_to_do_instead():
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await ComBackend().entity_change_space(["1A"], "2B")
|
||||
message = str(excinfo.value)
|
||||
assert "com" in message
|
||||
assert "ezdxf" in message, "a refusal that does not name the way out is half a refusal"
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_the_default_never_returns_a_soft_failure():
|
||||
"""The design decision this file exists to defend.
|
||||
|
||||
Returning ``{"ok": False, "capability": ...}`` would satisfy every caller's
|
||||
type expectations and silently mean "nothing happened".
|
||||
"""
|
||||
try:
|
||||
result = await ComBackend().entity_change_space(["1A"], "2B")
|
||||
except UnsupportedCapabilityError:
|
||||
return
|
||||
pytest.fail(f"the default returned {result!r} instead of raising")
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_an_implementing_backend_is_unaffected(backend):
|
||||
assert not is_capability_default(EzdxfBackend, "entity_change_space")
|
||||
assert inspect.iscoroutinefunction(EzdxfBackend.entity_change_space)
|
||||
result = await backend.entity_change_space([], "nope")
|
||||
assert result["ok"] is False
|
||||
assert "error" in result, "the real implementation still does its own validation"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""T0.7 — a capability refusal has to survive the trip to the client.
|
||||
|
||||
The backends know precisely why they cannot do a thing: they raise
|
||||
``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
|
||||
key of ``capabilities().features`` so a client can pre-check the same boundary
|
||||
through ``system_capabilities``. That type does not reach anybody.
|
||||
``FastMCP.call_tool`` rewraps any non-``FastMCPError`` as
|
||||
``ToolError(f"Error calling tool {name!r}: {e}") from e``; the ``from e`` keeps
|
||||
the cause alive **in process** — which is the only reason ``cad_batch`` can
|
||||
classify it — but ``__cause__`` does not serialise. Over the wire the client got
|
||||
an English sentence and nothing else, so "this backend cannot do that" was
|
||||
indistinguishable from "your arguments were wrong" except by substring matching.
|
||||
|
||||
Subclassing ``FastMCPError`` does not fix this; it changes nothing the client can
|
||||
observe. The one channel that carries a machine-readable payload *and* the error
|
||||
flag is ``ToolResult(structured_content=..., is_error=True)``, so a single
|
||||
middleware — the last place the exception type is still knowable — converts the
|
||||
refusal, and no tool function changes at all.
|
||||
|
||||
The other half is ``cad_batch``: a tool may decline *by value* rather than by
|
||||
raising, and counting that as a succeeded step is the same class of lie as
|
||||
writing DXF bytes into a ``.dwg`` or reporting a rollback the backend refused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
|
||||
import server
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(monkeypatch):
|
||||
"""In-memory client on a fresh headless document.
|
||||
|
||||
The env var is what ``server._make_backend`` reads; setting
|
||||
``config.settings.backend`` would leave a Windows box with AutoCAD open
|
||||
talking to the operator's live drawing.
|
||||
"""
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
async with Client(server.mcp) as connected:
|
||||
status = (await connected.call_tool("system_status", {})).structured_content or {}
|
||||
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
|
||||
await connected.call_tool("drawing_new", {})
|
||||
yield connected
|
||||
|
||||
|
||||
# ── the wire contract ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_refusal_reaches_the_client_as_a_machine_readable_payload(client, tmp_path):
|
||||
target = tmp_path / "part.dwg"
|
||||
result = await client.call_tool("drawing_save_as", {"path": str(target)}, raise_on_error=False)
|
||||
|
||||
assert result.is_error is True
|
||||
payload = result.structured_content
|
||||
assert isinstance(payload, dict), "a refusal must not arrive as prose only"
|
||||
assert payload["ok"] is False
|
||||
assert payload["kind"] == "unsupported"
|
||||
assert payload["capability"] == "dwg"
|
||||
assert payload["tool"] == "drawing_save_as"
|
||||
assert payload["backend"] == "ezdxf"
|
||||
|
||||
# The prose channel still works for clients that only read text, and it is
|
||||
# the FULL sentence — a refusal that names no way forward is not help.
|
||||
assert payload["error"] in result.content[0].text
|
||||
assert ".dxf" in payload["error"]
|
||||
assert not target.exists(), "a refusal must not leave a mislabelled file behind"
|
||||
|
||||
|
||||
async def test_the_capability_is_a_key_a_client_can_pre_check(client, tmp_path):
|
||||
"""The whole point of the key: ask before calling, get the same answer."""
|
||||
caps = (await client.call_tool("system_capabilities", {})).structured_content or {}
|
||||
features = caps["features"]
|
||||
|
||||
result = await client.call_tool(
|
||||
"drawing_save_as", {"path": str(tmp_path / "x.dwg")}, raise_on_error=False
|
||||
)
|
||||
capability = result.structured_content["capability"]
|
||||
assert features[capability]["supported"] is False
|
||||
|
||||
|
||||
async def test_ordinary_errors_are_left_exactly_as_they_were(client):
|
||||
"""The middleware must not turn every failure into a capability refusal."""
|
||||
result = await client.call_tool("entity_get", {"handle": "NOSUCH"}, raise_on_error=False)
|
||||
assert result.is_error is True
|
||||
payload = result.structured_content
|
||||
assert not (isinstance(payload, dict) and payload.get("kind") == "unsupported")
|
||||
|
||||
|
||||
async def test_a_successful_call_is_untouched(client):
|
||||
result = await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 5, "y2": 0})
|
||||
assert result.is_error is False
|
||||
assert result.structured_content["handle"]
|
||||
|
||||
|
||||
# ── one refusal mechanism, not two ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_solid_tools_raise_the_typed_refusal_instead_of_returning_a_dict():
|
||||
"""Returning ``{"ok": False}`` puts a refusal outside the middleware's reach.
|
||||
|
||||
The payload the client sees is unchanged; what flips is ``is_error``, from a
|
||||
quiet False to an honest True.
|
||||
"""
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
await backend.drawing_new()
|
||||
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.solid_box(0, 0, 0, 10, 10, 10)
|
||||
|
||||
assert excinfo.value.capability == "solid_3d"
|
||||
assert excinfo.value.to_dict() == {
|
||||
"ok": False,
|
||||
"error": str(excinfo.value),
|
||||
"capability": "solid_3d",
|
||||
}
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
async def test_the_refusal_type_lives_on_the_shared_base():
|
||||
"""Both backends must be able to raise the same class, not two look-alikes."""
|
||||
from backends.ezdxf_backend import UnsupportedCapabilityError as from_ezdxf
|
||||
|
||||
assert from_ezdxf is UnsupportedCapabilityError
|
||||
|
||||
|
||||
# ── cad_batch: a declined step is not a succeeded step ──────────────────────
|
||||
|
||||
|
||||
async def _batch(client, steps, **kwargs):
|
||||
payload = {"steps": steps, **kwargs}
|
||||
return (await client.call_tool("cad_batch", payload)).structured_content
|
||||
|
||||
|
||||
async def test_batch_does_not_count_a_step_that_declined_by_value_as_succeeded(client):
|
||||
"""``drawing_redo`` with nothing to redo returns ``{"ok": False}`` and did nothing."""
|
||||
result = await _batch(
|
||||
client,
|
||||
[
|
||||
{"tool": "entity_create_line", "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 0}},
|
||||
{"tool": "drawing_redo", "args": {}},
|
||||
],
|
||||
on_error="continue",
|
||||
)
|
||||
|
||||
assert result["succeeded"] == 1, "the redo did nothing; it cannot be a success"
|
||||
assert result["failed"] == 1
|
||||
redo_row = result["results"][1]
|
||||
assert redo_row["status"] == "error"
|
||||
assert redo_row["error"]["kind"] in {"refused", "unsupported"}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""M6 — CHSPACE: moving geometry between model space and a sheet.
|
||||
|
||||
AutoCAD's CHSPACE moves objects *through* a viewport, scaling and translating
|
||||
them by that viewport's zoom so they look identical on screen before and after.
|
||||
ezdxf's `move_to_layout` does the move and applies **no geometric change at
|
||||
all** — so the obvious implementation reports `ok: true`, produces a 100 mm
|
||||
feature as 100 mm of paper inside a 1:2 viewport, and writes a file that audits
|
||||
perfectly clean. That is the exact class of silent lie this release exists to
|
||||
remove, so the transform is the tool, not a nicety.
|
||||
|
||||
The matrix is `viewport.get_transformation_matrix()`, not a hand-rolled
|
||||
`translate(-view_center) @ scale @ translate(center)`. The hand-rolled version
|
||||
ignores `view_target_point`, and with a target point set it misplaces geometry
|
||||
by tens of millimetres while `is_top_view` is still True and the twist angle is
|
||||
still zero — i.e. past every guard one would naturally write. The regression
|
||||
test below pins the result against what ezdxf's own rendering pipeline projects.
|
||||
|
||||
What this refuses, and why each refusal is not pedantry:
|
||||
|
||||
* **Non-top views and twisted viewports** — ezdxf returns a meaningless
|
||||
top-view matrix for the first with no complaint, and rotates about the paper
|
||||
origin for the second.
|
||||
* **Dimensions** — `transform` halves `get_measurement()` while the baked
|
||||
dimension-block text still reads the old value. Whichever the consumer
|
||||
trusts, the other is a lie, so a dimension moves only with
|
||||
`freeze_dimensions=True`, which bakes the pre-transform measurement into the
|
||||
text first.
|
||||
* **ACIS bodies** — `3DSOLID`, `BODY`, `REGION` and `SURFACE` accept
|
||||
`transform()` and move nothing; the matrix is parked in a pending
|
||||
transformation the headless engine cannot apply.
|
||||
* **Tables and proxies** — `ACAD_TABLE`, `ACAD_PROXY_ENTITY` and `OLE2FRAME`
|
||||
raise `NotImplementedError` from `transform()`, and `hasattr(e, "transform")`
|
||||
is `True` for all of them, so a `hasattr` dispatch does not filter them out.
|
||||
* **Viewports** — ezdxf will happily move one into model space; AutoCAD's
|
||||
CHSPACE never does.
|
||||
* **Entities already in the target space** — `move_to_layout` to the same
|
||||
layout silently succeeds, and a no-op must not read as a move.
|
||||
|
||||
Clipping is *flagged*, not refused: AutoCAD also lets you move geometry that
|
||||
falls outside the viewport, so each moved entity reports `inside_viewport` and
|
||||
`on_sheet` rather than being rejected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
SHEET = "A3-Sheet"
|
||||
|
||||
|
||||
async def _sheet_with_viewport(backend, *, scale=0.5, name=SHEET, **vp_dxf):
|
||||
"""An A3 sheet with one 1:2 viewport centred on the page."""
|
||||
await backend.layout_create(name)
|
||||
layout = backend._doc.layouts.get(name)
|
||||
layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
|
||||
created = await backend.viewport_create(name, 210, 148.5, 200, 150, 50, 25, scale=scale)
|
||||
viewport = backend._doc.entitydb.get(created["handle"])
|
||||
for key, value in vp_dxf.items():
|
||||
viewport.dxf.set(key, value)
|
||||
return created["handle"], viewport
|
||||
|
||||
|
||||
def _layout_of(backend, handle: str) -> str:
|
||||
"""The layout tab an entity lives on, by owning block record."""
|
||||
doc = backend._doc
|
||||
owner = doc.entitydb.get(handle).dxf.owner
|
||||
if owner == doc.modelspace().block_record.dxf.handle:
|
||||
return "Model"
|
||||
for name in doc.layouts.names():
|
||||
if name != "Model" and doc.layouts.get(name).block_record.dxf.handle == owner:
|
||||
return name
|
||||
return owner
|
||||
|
||||
|
||||
# ── the transform is the point ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_moving_to_paper_scales_the_geometry_by_the_viewport(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
|
||||
line = await backend.entity_create_line(50, 25, 150, 25) # 100 mm through the view centre
|
||||
|
||||
result = await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["moved"][0]["handle"] == line.handle
|
||||
moved = backend._doc.entitydb.get(line.handle)
|
||||
length = (moved.dxf.end - moved.dxf.start).magnitude
|
||||
assert length == pytest.approx(50.0), "a 100 mm model line is 50 mm of paper at 1:2"
|
||||
|
||||
|
||||
async def test_the_moved_geometry_lands_where_the_renderer_projects_it(backend):
|
||||
"""Regression test for the hand-rolled matrix that ignores view_target_point.
|
||||
|
||||
With ``view_target_point`` set, the naive
|
||||
``translate(-view_center) @ scale @ translate(center)`` misplaces the view
|
||||
centre by 25 mm while ``is_top_view`` is True and the twist is zero — so no
|
||||
guard catches it and only this comparison does.
|
||||
"""
|
||||
from ezdxf.math import Vec3
|
||||
|
||||
vp, viewport = await _sheet_with_viewport(backend, scale=0.5, view_target_point=(50, 25, 0))
|
||||
expected = viewport.get_transformation_matrix().transform(Vec3(60, 30, 0))
|
||||
line = await backend.entity_create_line(60, 30, 70, 30)
|
||||
|
||||
await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
moved = backend._doc.entitydb.get(line.handle)
|
||||
assert moved.dxf.start.x == pytest.approx(expected.x)
|
||||
assert moved.dxf.start.y == pytest.approx(expected.y)
|
||||
|
||||
|
||||
async def test_the_move_preserves_the_handle(backend):
|
||||
"""Every tool in this server addresses entities by handle."""
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
line = await backend.entity_create_line(50, 25, 100, 25)
|
||||
|
||||
await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
info = await backend.entity_get(line.handle)
|
||||
assert info.handle == line.handle
|
||||
assert _layout_of(backend, line.handle) == SHEET
|
||||
|
||||
|
||||
async def test_a_round_trip_returns_the_original_coordinates(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
|
||||
line = await backend.entity_create_line(50, 25, 150, 25)
|
||||
|
||||
await backend.entity_change_space([line.handle], vp, direction="to_paper")
|
||||
await backend.entity_change_space([line.handle], vp, direction="to_model")
|
||||
|
||||
moved = backend._doc.entitydb.get(line.handle)
|
||||
assert moved.dxf.start.x == pytest.approx(50.0)
|
||||
assert moved.dxf.end.x == pytest.approx(150.0)
|
||||
assert _layout_of(backend, line.handle) == "Model"
|
||||
|
||||
|
||||
async def test_the_reported_scale_matches_the_viewport(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.25)
|
||||
line = await backend.entity_create_line(50, 25, 90, 25)
|
||||
|
||||
result = await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
assert result["scale"] == pytest.approx(0.25)
|
||||
assert result["viewport"] == vp
|
||||
|
||||
|
||||
# ── what it refuses ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_twisted_viewport_is_refused(backend):
|
||||
"""ezdxf rotates about the paper origin; the alternative is unvalidatable."""
|
||||
vp, _ = await _sheet_with_viewport(backend, view_twist_angle=30.0)
|
||||
line = await backend.entity_create_line(50, 25, 100, 25)
|
||||
|
||||
result = await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "twist" in result["error"].lower()
|
||||
assert _layout_of(backend, line.handle) == "Model", "a refused move must not move anything"
|
||||
|
||||
|
||||
async def test_a_non_top_view_viewport_is_refused(backend):
|
||||
"""ezdxf hands back a meaningless top-view matrix with no complaint."""
|
||||
vp, _ = await _sheet_with_viewport(backend, view_direction_vector=(1, 1, 1))
|
||||
line = await backend.entity_create_line(50, 25, 100, 25)
|
||||
|
||||
result = await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert _layout_of(backend, line.handle) == "Model"
|
||||
|
||||
|
||||
async def test_a_viewport_handle_that_is_not_a_viewport_is_refused(backend):
|
||||
await _sheet_with_viewport(backend)
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
result = await backend.entity_change_space([line.handle], line.handle)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
async def test_an_unfrozen_dimension_is_refused(backend):
|
||||
"""transform() halves the measurement while the block text keeps the old one."""
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40)
|
||||
|
||||
result = await backend.entity_change_space([dim.handle], vp)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["moved"] == []
|
||||
reason = result["refused"][0]["reason"]
|
||||
assert "dimension" in reason.lower() and "freeze_dimensions" in reason
|
||||
|
||||
|
||||
async def test_freezing_bakes_the_measurement_before_scaling(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
|
||||
dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40)
|
||||
|
||||
result = await backend.entity_change_space([dim.handle], vp, freeze_dimensions=True)
|
||||
|
||||
assert result["ok"] is True
|
||||
row = result["moved"][0]
|
||||
assert row["frozen_text"] == "100"
|
||||
assert backend._doc.entitydb.get(dim.handle).dxf.text == "100"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("maker", ["add_body", "add_3dsolid", "add_region"])
|
||||
async def test_acis_bodies_are_refused_because_transform_moves_nothing(backend, maker):
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
entity = getattr(backend._doc.modelspace(), maker)()
|
||||
|
||||
result = await backend.entity_change_space([entity.dxf.handle], vp)
|
||||
|
||||
assert result["moved"] == []
|
||||
assert entity.dxftype() in result["refused"][0]["reason"]
|
||||
|
||||
|
||||
async def test_moving_a_viewport_itself_is_refused(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
other, _ = await _sheet_with_viewport(backend, name="Second")
|
||||
|
||||
result = await backend.entity_change_space([other], vp)
|
||||
|
||||
assert result["moved"] == []
|
||||
assert "VIEWPORT" in result["refused"][0]["reason"]
|
||||
|
||||
|
||||
async def test_an_entity_already_in_the_target_space_is_refused(backend):
|
||||
"""`move_to_layout` to the same layout silently succeeds."""
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
line = await backend.entity_create_line(50, 25, 100, 25)
|
||||
await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
result = await backend.entity_change_space([line.handle], vp)
|
||||
|
||||
assert result["moved"] == []
|
||||
assert "already" in result["refused"][0]["reason"].lower()
|
||||
|
||||
|
||||
async def test_an_entity_on_a_different_sheet_is_refused(backend):
|
||||
"""`move_to_layout` raises a raw DXFValueError from deep inside ezdxf here.
|
||||
|
||||
Worse, it raises *after* the transform has already been applied: the failed
|
||||
call left a line that had been scaled and translated by the viewport and
|
||||
then stayed exactly where it was, on the wrong sheet.
|
||||
"""
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
|
||||
await backend.layout_create("Other-Sheet")
|
||||
await backend.layout_set_current("Other-Sheet")
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
before = backend._doc.entitydb.get(line.handle).dxf.end
|
||||
|
||||
result = await backend.entity_change_space([line.handle], vp, direction="to_model")
|
||||
|
||||
assert result["moved"] == []
|
||||
reason = result["refused"][0]["reason"]
|
||||
assert "Other-Sheet" in reason
|
||||
assert backend._doc.entitydb.get(line.handle).dxf.end == before, (
|
||||
"a refused entity must not be left rescaled"
|
||||
)
|
||||
assert _layout_of(backend, line.handle) == "Other-Sheet"
|
||||
|
||||
|
||||
async def test_one_bad_handle_does_not_rescale_the_good_ones_and_abort(backend):
|
||||
"""A raw exception mid-loop would leave the batch half-applied."""
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
|
||||
await backend.layout_create("Other-Sheet")
|
||||
await backend.layout_set_current("Other-Sheet")
|
||||
stray = await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
good = await backend.entity_create_line(50, 25, 150, 25)
|
||||
|
||||
result = await backend.entity_change_space([stray.handle, good.handle], vp)
|
||||
|
||||
assert [row["handle"] for row in result["moved"]] == [good.handle]
|
||||
assert result["refused"][0]["handle"] == stray.handle
|
||||
assert _layout_of(backend, good.handle) == SHEET
|
||||
|
||||
|
||||
async def test_an_unknown_handle_is_reported_without_stopping_the_others(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
line = await backend.entity_create_line(50, 25, 100, 25)
|
||||
|
||||
result = await backend.entity_change_space([line.handle, "DEADBEEF"], vp)
|
||||
|
||||
assert [row["handle"] for row in result["moved"]] == [line.handle]
|
||||
assert result["refused"][0]["handle"] == "DEADBEEF"
|
||||
|
||||
|
||||
async def test_an_unknown_direction_is_refused(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
line = await backend.entity_create_line(50, 25, 100, 25)
|
||||
result = await backend.entity_change_space([line.handle], vp, direction="sideways")
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
async def test_no_handles_is_refused_rather_than_reported_as_success(backend):
|
||||
vp, _ = await _sheet_with_viewport(backend)
|
||||
result = await backend.entity_change_space([], vp)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── what it flags rather than refuses ───────────────────────────────────────
|
||||
|
||||
|
||||
async def test_geometry_outside_the_viewport_is_flagged_not_refused(backend):
|
||||
"""AutoCAD moves it too; the caller just has to be told it left the sheet."""
|
||||
vp, _ = await _sheet_with_viewport(backend, scale=0.5)
|
||||
inside = await backend.entity_create_line(50, 25, 60, 25)
|
||||
outside = await backend.entity_create_line(800, 25, 900, 25)
|
||||
|
||||
result = await backend.entity_change_space([inside.handle, outside.handle], vp)
|
||||
|
||||
rows = {row["handle"]: row for row in result["moved"]}
|
||||
assert rows[inside.handle]["inside_viewport"] is True
|
||||
assert rows[inside.handle]["on_sheet"] is True
|
||||
assert rows[outside.handle]["inside_viewport"] is False
|
||||
assert rows[outside.handle]["on_sheet"] is False
|
||||
@@ -0,0 +1,132 @@
|
||||
"""``ComBackend._run`` deadline honesty — the STA executor must only be rebuilt
|
||||
when *our* deadline actually expired.
|
||||
|
||||
On Python 3.11+ ``TimeoutError`` **is** ``asyncio.TimeoutError`` (and an
|
||||
``OSError`` subclass), so a wrapped COM call that raises ``TimeoutError`` itself
|
||||
— a socket read against a dead network share, a helper that re-raises one —
|
||||
arrives at ``except TimeoutError`` looking exactly like a blown deadline. Taking
|
||||
it at face value told the user AutoCAD "did not respond within 60s" for a call
|
||||
that failed instantly, and (far worse than the ezdxf equivalent) threw away the
|
||||
live single-thread STA executor and the cached AutoCAD connection with it.
|
||||
|
||||
These tests exercise the real ``_run`` — a plain ``ThreadPoolExecutor`` stands in
|
||||
for the COM apartment, no AutoCAD and no win32 calls involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
import backends.com_backend as cb
|
||||
import config
|
||||
from backends.com_backend import ComBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend():
|
||||
"""A ComBackend wired to a real (AutoCAD-free) single-thread executor."""
|
||||
b = ComBackend()
|
||||
# No initializer: _com_init would CoInitialize a real STA apartment.
|
||||
b._executor = ThreadPoolExecutor(max_workers=1)
|
||||
b._connected = True
|
||||
yield b
|
||||
if b._executor is not None:
|
||||
b._executor.shutdown(wait=False)
|
||||
b._executor = None
|
||||
|
||||
|
||||
def _dead_share():
|
||||
"""Stand-in for a COM/file call that raises TimeoutError of its own accord."""
|
||||
raise TimeoutError("the network share went away")
|
||||
|
||||
|
||||
async def test_call_raising_TimeoutError_is_not_blamed_on_the_deadline(com_backend, monkeypatch):
|
||||
"""A TimeoutError *from the call* is an ordinary failure, not our deadline."""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 30)
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(TimeoutError, match="network share") as excinfo:
|
||||
await com_backend._run(_dead_share)
|
||||
|
||||
assert time.monotonic() - started < 5, "it failed instantly; nothing waited 30s"
|
||||
assert "did not respond" not in str(excinfo.value)
|
||||
assert not isinstance(excinfo.value, RuntimeError), "the original error must survive"
|
||||
|
||||
|
||||
async def test_call_raising_TimeoutError_keeps_the_sta_executor(com_backend, monkeypatch):
|
||||
"""Rebuilding the apartment on an ordinary error drops a live AutoCAD session."""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 30)
|
||||
monkeypatch.setitem(cb._COM_STATE, "app", object())
|
||||
live = com_backend._executor
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await com_backend._run(_dead_share)
|
||||
|
||||
assert com_backend._executor is live, "the STA executor must not be rebuilt"
|
||||
assert com_backend._connected is True, "the AutoCAD connection must not be dropped"
|
||||
assert "app" in cb._COM_STATE, "the cached COM app must survive an ordinary error"
|
||||
assert await asyncio.wait_for(com_backend._run(lambda: "next"), timeout=5) == "next"
|
||||
|
||||
|
||||
async def test_disabled_timeout_still_surfaces_a_callable_TimeoutError(com_backend, monkeypatch):
|
||||
"""With COM_CALL_TIMEOUT=0 there is no deadline at all to blame."""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 0)
|
||||
live = com_backend._executor
|
||||
|
||||
with pytest.raises(TimeoutError, match="network share"):
|
||||
await com_backend._run(_dead_share)
|
||||
|
||||
assert com_backend._executor is live
|
||||
assert com_backend._connected is True
|
||||
|
||||
|
||||
async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeypatch):
|
||||
"""The real overrun path is deliberate and must keep working: the worker is
|
||||
stuck inside an uncancellable COM call, so it is abandoned and replaced."""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
|
||||
monkeypatch.setitem(cb._COM_STATE, "app", object())
|
||||
stuck = com_backend._executor
|
||||
entered = threading.Event()
|
||||
|
||||
def _hang():
|
||||
entered.set()
|
||||
time.sleep(2.0)
|
||||
return "too late"
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(RuntimeError, match="did not respond") as excinfo:
|
||||
await com_backend._run(_hang)
|
||||
|
||||
assert entered.is_set()
|
||||
assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out"
|
||||
assert isinstance(excinfo.value.__cause__, TimeoutError)
|
||||
assert com_backend._executor is not stuck, "the stuck apartment must be replaced"
|
||||
assert com_backend._connected is False
|
||||
assert "app" not in cb._COM_STATE, "the cached app belongs to the abandoned thread"
|
||||
|
||||
# The anti-deadlock guarantee: the next call runs on the fresh executor
|
||||
# instead of queueing behind the still-sleeping worker.
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 10)
|
||||
assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh"
|
||||
|
||||
|
||||
async def test_ordinary_errors_are_unchanged(com_backend, monkeypatch):
|
||||
"""Sanity: non-timeout failures never touched the executor and still don't."""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 5)
|
||||
live = com_backend._executor
|
||||
|
||||
def _boom():
|
||||
raise ValueError("nope")
|
||||
|
||||
with pytest.raises(ValueError, match="nope"):
|
||||
await com_backend._run(_boom)
|
||||
|
||||
assert com_backend._executor is live
|
||||
assert await com_backend._run(lambda x: x * 2, 21) == 42
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Gates for the AutoCAD command / synonym alias corpus (``discovery.aliases``).
|
||||
|
||||
FastMCP's ``BM25SearchTransform`` indexes only tool name + description +
|
||||
parameter names + parameter descriptions (see
|
||||
``fastmcp/server/transforms/search/base.py::_extract_searchable_text``).
|
||||
AutoCAD command names appear in none of those for this server, so a drafter
|
||||
searching for ``BPOLY`` or ``QSELECT`` scores df=0 and gets nothing back.
|
||||
|
||||
These tests both *prove* that gap is real against the live registry and lock the
|
||||
corpus that closes it to the live tool list, so the corpus can never silently
|
||||
drift away from the registered tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import server
|
||||
from discovery.aliases import (
|
||||
SHARED_ACAD_COMMANDS,
|
||||
TOOL_ALIASES,
|
||||
ToolAliases,
|
||||
alias_text,
|
||||
aliases_for,
|
||||
)
|
||||
|
||||
# AutoCAD command tokens: uppercase alphanumerics, no spaces, at least two
|
||||
# characters (single-letter aliases like "L"/"C" are deliberately excluded --
|
||||
# they are pure BM25 noise and collide with ordinary prose).
|
||||
_ACAD_RE = re.compile(r"^[A-Z][A-Z0-9]+$")
|
||||
|
||||
# Commands proven below to be absent from every live tool's searchable text.
|
||||
#
|
||||
# Deliberately NOT in this list, and why:
|
||||
# FILLET -- already present: the tool is literally named ``entity_fillet``.
|
||||
# XCLIP -- the server has no xref/clipping support at all; any mapping
|
||||
# would misroute the search, so the corpus honestly omits it.
|
||||
# REVCLOUD -- no revision-cloud tool exists; a revcloud would have to be
|
||||
# hand-built from arcs, so no single tool is the right answer.
|
||||
#: AutoCAD commands that appear nowhere in the live tool text, so a
|
||||
#: description-only ranker scores them df=0 and returns nothing.
|
||||
#:
|
||||
#: BPOLY and QSELECT used to be here and were removed in v1.5.0 (M8), when
|
||||
#: `boundary_trace` and `selection_filter` landed and named those commands in
|
||||
#: their own docstrings. The alias corpus is no longer the *only* way to reach
|
||||
#: them, and saying otherwise would overstate what the corpus is worth. Two
|
||||
#: commands moving out of this list is the honest cost of the drawing tools
|
||||
#: growing to cover them — the corpus still carries the rest.
|
||||
DF0_COMMANDS = (
|
||||
"PEDIT",
|
||||
"WBLOCK",
|
||||
"OVERKILL",
|
||||
"LAYTRANS",
|
||||
"MATCHPROP",
|
||||
)
|
||||
|
||||
|
||||
async def _registered_tools() -> list:
|
||||
return [t for t in await server._registered_tools() if getattr(t, "name", None)]
|
||||
|
||||
|
||||
async def _registered_names() -> set[str]:
|
||||
return {t.name for t in await _registered_tools()}
|
||||
|
||||
|
||||
def _searchable_text(tool) -> str:
|
||||
"""Mirror of fastmcp's ``_extract_searchable_text`` -- what BM25 indexes."""
|
||||
parts = [tool.name]
|
||||
if tool.description:
|
||||
parts.append(tool.description)
|
||||
schema = getattr(tool, "parameters", None) or {}
|
||||
for param_name, param_info in (schema.get("properties") or {}).items():
|
||||
parts.append(param_name)
|
||||
if isinstance(param_info, dict) and param_info.get("description"):
|
||||
parts.append(str(param_info["description"]))
|
||||
return " ".join(parts).lower()
|
||||
|
||||
|
||||
def _acad_owners() -> dict[str, list[str]]:
|
||||
owners: dict[str, list[str]] = {}
|
||||
for tool_name, record in TOOL_ALIASES.items():
|
||||
for command in record.acad:
|
||||
owners.setdefault(command, []).append(tool_name)
|
||||
for names in owners.values():
|
||||
names.sort()
|
||||
return owners
|
||||
|
||||
|
||||
# ── coverage ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_registered_tool_has_an_alias_record():
|
||||
"""Adding a tool without an alias record must fail this gate."""
|
||||
missing = sorted(await _registered_names() - set(TOOL_ALIASES))
|
||||
assert not missing, f"tools with no alias record: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_alias_key_is_an_unknown_tool():
|
||||
"""Catches typos and tools that were renamed or removed."""
|
||||
unknown = sorted(set(TOOL_ALIASES) - await _registered_names())
|
||||
assert not unknown, f"alias keys that are not registered tools: {unknown}"
|
||||
|
||||
|
||||
def test_every_record_is_a_tool_aliases_instance():
|
||||
bad = sorted(k for k, v in TOOL_ALIASES.items() if not isinstance(v, ToolAliases))
|
||||
assert not bad, f"records that are not ToolAliases: {bad}"
|
||||
|
||||
|
||||
# ── shape ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_acad_commands_are_uppercase_and_space_free():
|
||||
bad: list[str] = []
|
||||
for tool_name, record in TOOL_ALIASES.items():
|
||||
for command in record.acad:
|
||||
if not _ACAD_RE.fullmatch(command):
|
||||
bad.append(f"{tool_name}:{command!r}")
|
||||
assert not bad, f"malformed AutoCAD command tokens: {bad}"
|
||||
|
||||
|
||||
def test_acad_commands_are_unique_within_a_record():
|
||||
bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.acad)) != len(v.acad))
|
||||
assert not bad, f"records with a repeated AutoCAD command: {bad}"
|
||||
|
||||
|
||||
def test_synonyms_are_lowercase_non_empty_and_trimmed():
|
||||
bad: list[str] = []
|
||||
for tool_name, record in TOOL_ALIASES.items():
|
||||
for phrase in record.synonyms:
|
||||
if not phrase or phrase != phrase.lower() or phrase != phrase.strip():
|
||||
bad.append(f"{tool_name}:{phrase!r}")
|
||||
assert not bad, f"malformed synonyms: {bad}"
|
||||
|
||||
|
||||
def test_every_tool_has_at_least_one_synonym():
|
||||
"""``acad`` may legitimately be empty; a record with nothing in it is dead weight."""
|
||||
empty = sorted(k for k, v in TOOL_ALIASES.items() if not v.synonyms)
|
||||
assert not empty, f"records with no synonyms: {empty}"
|
||||
|
||||
|
||||
def test_synonyms_are_unique_within_a_record():
|
||||
bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.synonyms)) != len(v.synonyms))
|
||||
assert not bad, f"records with a repeated synonym: {bad}"
|
||||
|
||||
|
||||
# ── uniqueness ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Rule: one AutoCAD command routes to exactly one tool. The only exceptions are
|
||||
# the commands in ``SHARED_ACAD_COMMANDS`` -- option/dialog-driven umbrella
|
||||
# commands (LAYER, ZOOM, ARRAY, LAYOUT, MEASUREGEOM, SETVAR) and the two places
|
||||
# where this server genuinely exposes one operation through two tools (INSERT ->
|
||||
# block_insert / entity_create_block_ref, ERASE -> entity_delete /
|
||||
# entity_delete_many). Anything else is a copy-paste bug.
|
||||
|
||||
|
||||
def test_each_acad_command_routes_to_a_single_tool():
|
||||
unexpected = {
|
||||
command: names
|
||||
for command, names in _acad_owners().items()
|
||||
if len(names) > 1 and command not in SHARED_ACAD_COMMANDS
|
||||
}
|
||||
assert not unexpected, f"AutoCAD command claimed by several tools: {unexpected}"
|
||||
|
||||
|
||||
def test_shared_command_allowlist_has_no_stale_entries():
|
||||
actually_shared = {c for c, names in _acad_owners().items() if len(names) > 1}
|
||||
stale = sorted(SHARED_ACAD_COMMANDS - actually_shared)
|
||||
assert not stale, f"SHARED_ACAD_COMMANDS lists commands that are not shared: {stale}"
|
||||
|
||||
|
||||
# ── the df=0 regression guard ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_autocad_commands_are_absent_from_live_tool_text():
|
||||
"""Proves the gap: these commands score df=0 against the live BM25 corpus."""
|
||||
tools = await _registered_tools()
|
||||
hits = {
|
||||
command: sorted(t.name for t in tools if command.lower() in _searchable_text(t))
|
||||
for command in DF0_COMMANDS
|
||||
}
|
||||
leaked = {c: names for c, names in hits.items() if names}
|
||||
assert not leaked, f"command already indexed -- drop it from DF0_COMMANDS: {leaked}"
|
||||
|
||||
|
||||
def test_key_autocad_commands_are_covered_by_the_corpus():
|
||||
"""...and proves the corpus closes it."""
|
||||
owners = _acad_owners()
|
||||
missing = sorted(c for c in DF0_COMMANDS if c not in owners)
|
||||
assert not missing, f"df=0 commands still unreachable via the corpus: {missing}"
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_aliases_for_returns_the_record_or_none():
|
||||
assert aliases_for("entity_fillet") is TOOL_ALIASES["entity_fillet"]
|
||||
assert aliases_for("no_such_tool") is None
|
||||
|
||||
|
||||
def test_alias_text_contains_commands_and_synonyms():
|
||||
record = TOOL_ALIASES["entity_create_circle"]
|
||||
text = alias_text("entity_create_circle")
|
||||
for token in (*record.acad, *record.synonyms):
|
||||
assert token in text
|
||||
assert alias_text("no_such_tool") == ""
|
||||
@@ -0,0 +1,668 @@
|
||||
"""The discovery search transform must find tools a drafter can actually name.
|
||||
|
||||
Stock ``BM25SearchTransform`` indexes tool name + description + parameter names
|
||||
+ parameter descriptions and nothing else. Measured against this server's live
|
||||
catalog, that means ``PEDIT``, ``WBLOCK``, ``OVERKILL``,
|
||||
``LAYTRANS`` and ``MATCHPROP`` all score df=0 and return *nothing*,
|
||||
and "how many entities are on the GEOMETRY layer" returns ``entity_delete_many``
|
||||
as its top hit. Both are proved here against the real catalog before anything is
|
||||
asserted about the replacement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.server.transforms.search import BM25SearchTransform
|
||||
from fastmcp.server.transforms.search.base import serialize_tools_for_output_json
|
||||
|
||||
import config
|
||||
import server
|
||||
from discovery.serialize import serialize_search_results
|
||||
from discovery.transform import CadSearchTransform, tool_risk
|
||||
from tests.data.discovery_golden import ALL_CASES, HOLDOUT_CASES, TUNING_CASES
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def catalog():
|
||||
"""The real, untransformed 131-tool catalog.
|
||||
|
||||
Synchronous on purpose: ``_registered_tools`` only reads the in-process
|
||||
component registry, and a module-scoped *async* fixture would need its own
|
||||
event-loop scope under ``asyncio_mode = strict``.
|
||||
"""
|
||||
return asyncio.run(server._registered_tools())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transform():
|
||||
return CadSearchTransform()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stock():
|
||||
return BM25SearchTransform()
|
||||
|
||||
|
||||
def _names(tools) -> list[str]:
|
||||
return [t.name for t in tools]
|
||||
|
||||
|
||||
# ── the df=0 vocabulary gap ─────────────────────────────────────────────────
|
||||
|
||||
# Verified against the live catalog: stock BM25 returns *zero* hits for every
|
||||
# one of these. FILLET is deliberately absent — it is already indexed (df=2,
|
||||
# the tool is literally named entity_fillet), so citing it would overstate the
|
||||
# gap.
|
||||
#
|
||||
# BPOLY and QSELECT left this list in v1.5.0 (M8): `boundary_trace` and
|
||||
# `selection_filter` name those commands in their own docstrings, so stock BM25
|
||||
# now finds them and the gap is genuinely closed for those two. Keeping them
|
||||
# here would have made the corpus look more load-bearing than it is. The
|
||||
# remaining five are still unreachable without it.
|
||||
DF_ZERO_COMMANDS = [
|
||||
("PEDIT", "entity_edit_geometry"),
|
||||
("WBLOCK", "block_create_from_entities"),
|
||||
("OVERKILL", "drawing_refine"),
|
||||
("LAYTRANS", "template_apply_layers"),
|
||||
("MATCHPROP", "entity_set_properties"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
|
||||
async def test_stock_bm25_cannot_find_these_commands_at_all(stock, catalog, command, expected):
|
||||
"""Guard the guard: the gap is real, not an artefact of a weak assertion."""
|
||||
assert await stock._search(catalog, command) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS)
|
||||
async def test_autocad_command_names_resolve(transform, catalog, command, expected):
|
||||
assert _names(transform.rank(catalog, command).hits)[0] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "expected"),
|
||||
[
|
||||
("DLI", "dimension_linear"),
|
||||
("REC", "entity_create_rectangle"),
|
||||
("CHA", "entity_chamfer"),
|
||||
("TR", "entity_trim"),
|
||||
("MI", "entity_mirror"),
|
||||
("XL", "construction_xline"),
|
||||
],
|
||||
)
|
||||
async def test_short_command_aliases_resolve(transform, catalog, command, expected):
|
||||
"""Two- and three-letter aliases are what drafters actually type."""
|
||||
assert _names(transform.rank(catalog, command).hits)[0] == expected
|
||||
|
||||
|
||||
async def test_searchable_text_extends_the_stock_document(transform, catalog):
|
||||
"""The hook adds alias vocabulary; it never drops the stock text."""
|
||||
from fastmcp.server.transforms.search.base import _extract_searchable_text
|
||||
|
||||
tool = next(t for t in catalog if t.name == "entity_create_polyline")
|
||||
text = transform._extract_searchable_text(tool)
|
||||
assert _extract_searchable_text(tool) in text
|
||||
assert "PLINE" in text
|
||||
assert "closed profile" in text
|
||||
|
||||
|
||||
async def test_a_tool_with_no_alias_record_is_still_indexed(transform, catalog):
|
||||
"""An un-aliased tool must stay findable, not vanish from the index."""
|
||||
tool = next(t for t in catalog if t.name == "entity_create_line")
|
||||
stripped = tool.model_copy(update={"name": "zzz_unaliased_probe"})
|
||||
hits = transform.rank([*catalog, stripped], "zzz_unaliased_probe").hits
|
||||
assert _names(hits)[0] == "zzz_unaliased_probe"
|
||||
|
||||
|
||||
# ── the two boosts ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_an_exact_command_outranks_tools_that_share_a_description_word(transform, catalog):
|
||||
""" "CIRCLE" is the command; a dozen tools merely mention circles."""
|
||||
hits = _names(transform.rank(catalog, "CIRCLE", limit=5).hits)
|
||||
assert hits[0] == "entity_create_circle"
|
||||
|
||||
|
||||
async def test_a_command_name_that_is_also_an_english_word_stays_english(transform, catalog):
|
||||
"""LAYER is a command *and* a noun.
|
||||
|
||||
Boosting on a bare lowercase "layer" made layer_modify / layer_list /
|
||||
layer_create the top three for this query and pushed layer_delete out of
|
||||
the answer entirely — exactly backwards. Measured regression, not a
|
||||
hypothetical.
|
||||
"""
|
||||
hits = _names(transform.rank(catalog, "delete the layer called GEOMETRY", limit=8).hits)
|
||||
assert "layer_delete" in hits
|
||||
assert not {"layer_create", "layer_modify", "layer_list"} & set(hits[:3])
|
||||
|
||||
|
||||
async def test_the_same_command_in_capitals_is_treated_as_a_command(transform, catalog):
|
||||
"""The other half of the rule: LAYER typed as a command routes to LAYER tools."""
|
||||
hits = set(_names(transform.rank(catalog, "LAYER", limit=5).hits))
|
||||
assert {"layer_create", "layer_list", "layer_modify"} <= hits
|
||||
|
||||
|
||||
async def test_a_lone_lowercase_command_still_counts_as_a_command(transform, catalog):
|
||||
"""Nobody shouts when the command is the whole message."""
|
||||
assert _names(transform.rank(catalog, "bpoly").hits)[0] == "boundary_trace"
|
||||
|
||||
|
||||
async def test_articles_do_not_break_synonym_phrase_matching(transform, catalog):
|
||||
"""The corpus says "delete a layer"; users say "delete the layer"."""
|
||||
assert "layer_delete" in _names(transform.rank(catalog, "delete the layer", limit=5).hits)
|
||||
|
||||
|
||||
# ── the risk filter ─────────────────────────────────────────────────────────
|
||||
|
||||
COUNTING_QUERY = "how many entities are on the GEOMETRY layer"
|
||||
|
||||
|
||||
async def test_stock_bm25_answers_a_counting_question_with_destructive_tools(stock, catalog):
|
||||
"""Guard the guard: the destructive-hit problem is measured, not assumed.
|
||||
|
||||
This asserted the top hit was ``entity_delete_many`` until
|
||||
``analysis_measure_entity`` was added and displaced it to second. The
|
||||
ranking order is incidental; what the risk ceiling exists to prevent is a
|
||||
read-only question being answered with tools that destroy geometry, and that
|
||||
is still exactly what stock BM25 does here.
|
||||
"""
|
||||
hits = await stock._search(catalog, COUNTING_QUERY)
|
||||
risky = [t.name for t in hits if tool_risk(t) == "destructive"]
|
||||
assert risky, f"expected destructive hits among {_names(hits)}"
|
||||
assert "entity_delete_many" in risky
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("drawing_info", "read"),
|
||||
("analysis_entity_stats", "read"),
|
||||
("entity_create_line", "write"),
|
||||
("entity_delete_many", "destructive"),
|
||||
("layer_delete", "destructive"),
|
||||
("transaction_rollback", "destructive"),
|
||||
],
|
||||
)
|
||||
async def test_risk_is_derived_from_the_mcp_annotations(catalog, name, expected):
|
||||
tool = next(t for t in catalog if t.name == name)
|
||||
assert tool_risk(tool) == expected
|
||||
|
||||
|
||||
async def test_read_ceiling_keeps_destructive_tools_out_of_a_counting_answer(transform, catalog):
|
||||
outcome = transform.rank(catalog, COUNTING_QUERY, risk="read")
|
||||
assert [tool_risk(t) for t in outcome.hits] == ["read"] * len(outcome.hits)
|
||||
assert "analysis_entity_stats" in _names(outcome.hits)
|
||||
|
||||
|
||||
async def test_write_ceiling_allows_mutation_but_not_destruction(transform, catalog):
|
||||
outcome = transform.rank(catalog, "delete the entities on the GEOMETRY layer", risk="write")
|
||||
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
|
||||
|
||||
|
||||
async def test_the_filter_frees_slots_rather_than_shrinking_the_answer(transform, catalog):
|
||||
"""Filtering must promote lower-ranked safe tools, not just delete rows."""
|
||||
unfiltered = transform.rank(catalog, COUNTING_QUERY, limit=5)
|
||||
filtered = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
|
||||
assert len(unfiltered.hits) == 5
|
||||
assert len(filtered.hits) == 5
|
||||
assert set(_names(filtered.hits)) - set(_names(unfiltered.hits))
|
||||
|
||||
|
||||
async def test_the_filter_records_exactly_what_it_hid(transform, catalog):
|
||||
outcome = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read")
|
||||
assert "entity_delete_many" in _names(outcome.hidden)
|
||||
# Only tools the caller would otherwise have seen — not the whole catalog.
|
||||
assert len(outcome.hidden) <= 5
|
||||
|
||||
|
||||
async def test_nothing_is_recorded_as_hidden_when_the_filter_is_off(transform, catalog):
|
||||
assert transform.rank(catalog, COUNTING_QUERY, limit=5).hidden == ()
|
||||
|
||||
|
||||
# ── the risk filter must be visible in the payload ──────────────────────────
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, tools):
|
||||
self._tools = list(tools)
|
||||
|
||||
async def list_tools(self, run_middleware: bool = True):
|
||||
return list(self._tools)
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
"""Just enough Context for ``_get_visible_tools`` to reach the catalog."""
|
||||
|
||||
def __init__(self, tools):
|
||||
self.fastmcp = _FakeServer(tools)
|
||||
|
||||
|
||||
async def _search_payload(transform, catalog, query: str, **kwargs):
|
||||
tool = transform._make_search_tool()
|
||||
return await tool.fn(query=query, ctx=_FakeContext(catalog), **kwargs)
|
||||
|
||||
|
||||
async def test_the_payload_says_when_the_risk_filter_removed_hits(transform, catalog):
|
||||
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="read")
|
||||
notice = [line for line in payload.splitlines() if line.startswith("Risk filter:")]
|
||||
assert len(notice) == 1
|
||||
assert "entity_delete_many" in notice[0]
|
||||
assert 'risk="any"' in notice[0]
|
||||
|
||||
|
||||
async def test_a_mixed_intent_is_never_silently_halved(transform, catalog):
|
||||
""" "list the layers then delete GEOMETRY" is half a read and half a delete."""
|
||||
payload = await _search_payload(
|
||||
transform, catalog, "list the layers then delete GEOMETRY", risk="read"
|
||||
)
|
||||
assert "layer_list" in payload
|
||||
notice = next(line for line in payload.splitlines() if line.startswith("Risk filter:"))
|
||||
assert "entity_delete" in notice
|
||||
|
||||
|
||||
async def test_no_notice_when_the_filter_removed_nothing(transform, catalog):
|
||||
payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="any")
|
||||
assert "Risk filter:" not in payload
|
||||
|
||||
|
||||
async def test_a_structured_serializer_gets_the_notice_as_a_sibling_key(catalog):
|
||||
"""There is no line to append to when the serializer returns JSON."""
|
||||
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
|
||||
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="read")
|
||||
assert isinstance(payload["results"], list)
|
||||
assert "entity_delete_many" in payload["notice"]
|
||||
|
||||
|
||||
async def test_a_structured_serializer_returns_bare_results_when_nothing_was_hidden(catalog):
|
||||
structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json)
|
||||
payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="any")
|
||||
assert isinstance(payload, list)
|
||||
|
||||
|
||||
# ── the search tool's own schema ────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_search_tool_advertises_limit_and_risk(transform):
|
||||
properties = transform._make_search_tool().parameters["properties"]
|
||||
assert set(properties) == {"query", "limit", "risk"}
|
||||
assert properties["risk"]["enum"] == ["any", "write", "read"]
|
||||
assert properties["limit"]["default"] == transform._max_results
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("asked", "served"), [(999, 25), (0, 1), (-4, 1), (3, 3)])
|
||||
async def test_limit_is_clamped_to_a_usable_range(transform, catalog, asked, served):
|
||||
outcome = transform.rank(catalog, "line", limit=asked)
|
||||
assert len(outcome.hits) == served
|
||||
|
||||
|
||||
async def test_an_unknown_risk_value_is_rejected_rather_than_ignored(transform, catalog):
|
||||
with pytest.raises(ValueError, match="risk"):
|
||||
transform.rank(catalog, "line", risk="mostly-harmless")
|
||||
|
||||
|
||||
# ── the compact serializer ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _tools(catalog, *names):
|
||||
by_name = {t.name: t for t in catalog}
|
||||
return [by_name[n] for n in names]
|
||||
|
||||
|
||||
async def test_one_card_per_tool_on_one_line_each(catalog):
|
||||
tools = _tools(catalog, "entity_create_circle", "entity_delete", "drawing_info")
|
||||
lines = serialize_search_results(tools).splitlines()
|
||||
assert len(lines) == 3
|
||||
assert lines[0].startswith("entity_create_circle")
|
||||
|
||||
|
||||
async def test_a_card_carries_name_summary_and_required_params(catalog):
|
||||
(line,) = serialize_search_results(_tools(catalog, "entity_create_circle")).splitlines()
|
||||
assert "entity_create_circle" in line
|
||||
assert "req: cx, cy, radius" in line
|
||||
|
||||
|
||||
async def test_a_card_marks_the_risk_level(catalog):
|
||||
text = serialize_search_results(
|
||||
_tools(catalog, "drawing_info", "entity_create_circle", "entity_delete")
|
||||
)
|
||||
assert "drawing_info (read)" in text
|
||||
assert "entity_create_circle (write)" in text
|
||||
assert "entity_delete (destructive)" in text
|
||||
|
||||
|
||||
async def test_a_card_carries_the_autocad_command(catalog):
|
||||
(line,) = serialize_search_results(_tools(catalog, "entity_chamfer")).splitlines()
|
||||
assert "acad: CHAMFER" in line
|
||||
|
||||
|
||||
async def test_the_cad_meta_summary_wins_over_the_docstring(catalog):
|
||||
"""@cad_tool exists to give a hit a hand-written one-liner."""
|
||||
tool = next(t for t in catalog if t.name == "transaction_begin")
|
||||
summary = (tool.meta or {})["cad"]["summary"]
|
||||
assert summary in serialize_search_results([tool])
|
||||
|
||||
|
||||
async def test_the_first_docstring_line_is_the_fallback_summary(catalog):
|
||||
"""A card still reads well for a tool that carries no @cad_tool summary.
|
||||
|
||||
The fallback is exercised by stripping the meta channel off a real tool
|
||||
rather than by naming one that happens to lack it: the @cad_tool rollout
|
||||
is filling the catalog in, so "a tool without cad meta" is not a stable
|
||||
fixture, while the serializer's fallback branch has to keep working for
|
||||
any tool a transform hands it (a mounted server's, say).
|
||||
"""
|
||||
tool = next(t for t in catalog if t.name == "entity_create_circle")
|
||||
bare = tool.model_copy(update={"meta": None})
|
||||
first_line = bare.description.strip().splitlines()[0]
|
||||
assert first_line in serialize_search_results([bare])
|
||||
|
||||
|
||||
async def test_a_long_summary_is_truncated_on_a_word_boundary(catalog):
|
||||
tool = next(t for t in catalog if t.name == "entity_create_circle")
|
||||
verbose = tool.model_copy(update={"description": "word " * 200, "meta": None})
|
||||
(line,) = serialize_search_results([verbose]).splitlines()
|
||||
assert len(line) < 250
|
||||
assert line.count("...") == 1
|
||||
|
||||
|
||||
async def test_a_zero_argument_tool_says_so(catalog):
|
||||
(line,) = serialize_search_results(_tools(catalog, "drawing_info")).splitlines()
|
||||
assert "req: (none)" in line
|
||||
|
||||
|
||||
async def test_an_empty_result_set_is_explicit():
|
||||
assert serialize_search_results([]) == "No tools matched the query."
|
||||
|
||||
|
||||
# Budgets read off the measured output, not chosen first and enforced onto the
|
||||
# renderer. Over the 15 REALISTIC_QUERIES below at limit=5 the worst rendered
|
||||
# payload measured 798 characters and the worst single card in the whole
|
||||
# 131-tool catalog measured 191 (p50 123, p90 160). The thresholds sit ~30%
|
||||
# above those so an added tool with a long docstring does not turn into a
|
||||
# spurious CI failure — a real regression would be a multiple, not a nudge.
|
||||
MAX_CARD_CHARS = 256
|
||||
MAX_PAYLOAD_CHARS = 1024
|
||||
|
||||
REALISTIC_QUERIES = [
|
||||
"FILLET",
|
||||
"BPOLY",
|
||||
"round the corner",
|
||||
"bill of materials",
|
||||
"how many entities are on the GEOMETRY layer",
|
||||
"draw a hole in the plate",
|
||||
"put a leader note on the chamfer",
|
||||
"dimension everything with a chain",
|
||||
"make a title block",
|
||||
"save the drawing as a pdf",
|
||||
"select every circle on the GEOMETRY layer",
|
||||
"gear",
|
||||
"keyway in the hub bore",
|
||||
"screenshot",
|
||||
"check the drawing before finalising",
|
||||
]
|
||||
|
||||
|
||||
async def test_no_single_card_exceeds_the_measured_budget(catalog):
|
||||
"""Checked over every registered tool, not just the ones a query happens to hit."""
|
||||
worst = max(catalog, key=lambda tool: len(serialize_search_results([tool])))
|
||||
assert len(serialize_search_results([worst])) <= MAX_CARD_CHARS, worst.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", REALISTIC_QUERIES)
|
||||
async def test_a_five_hit_payload_stays_inside_the_measured_budget(transform, catalog, query):
|
||||
hits = transform.rank(catalog, query, limit=5).hits
|
||||
assert len(serialize_search_results(hits)) <= MAX_PAYLOAD_CHARS
|
||||
|
||||
|
||||
async def test_the_serializer_is_far_smaller_than_stock_json(transform, catalog):
|
||||
"""Payload size is a real gain, but it is *not* the differentiator.
|
||||
|
||||
Stock BM25 with this same serializer would close most of the gap; what it
|
||||
cannot do is find BPOLY at all. Asserted as a ratio so it stays honest.
|
||||
"""
|
||||
hits = transform.rank(catalog, "round the corner", limit=5).hits
|
||||
compact = len(serialize_search_results(hits))
|
||||
stock_json = len(json.dumps(serialize_tools_for_output_json(hits)))
|
||||
assert compact < stock_json / 5
|
||||
|
||||
|
||||
# ── the golden set ──────────────────────────────────────────────────────────
|
||||
|
||||
TOP_K = 3
|
||||
|
||||
# Measured on the tuning set with the scorer frozen: top-1 47/51 (92.2%),
|
||||
# top-3 50/51 (98.0%). The bars sit below those, not on them.
|
||||
TUNING_TOP1_BAR = 0.85
|
||||
TUNING_TOP3_BAR = 0.94
|
||||
|
||||
# Declared *before* the holdout set was measured for the first time, and not
|
||||
# revised afterwards. Lower than the tuning bar on purpose: a ranker that
|
||||
# scores the same on data it never saw is the exception, not the expectation.
|
||||
HOLDOUT_TOP3_BAR = 0.80
|
||||
|
||||
# A tuning case that fails for a reason this module cannot fix. "smaller" and
|
||||
# "by half" appear nowhere in discovery/aliases.py (which has "shrink" but not
|
||||
# "smaller"), so no scoring change reaches entity_scale — only a corpus edit
|
||||
# does, and the corpus is a separate deliverable. Left in the set rather than
|
||||
# quietly deleted: it is the honest ceiling of an alias-driven approach.
|
||||
KNOWN_CORPUS_GAPS = {"make the whole shape smaller by half"}
|
||||
|
||||
|
||||
def _tuning_params():
|
||||
return [
|
||||
pytest.param(
|
||||
case,
|
||||
marks=(
|
||||
[pytest.mark.xfail(reason="vocabulary missing from discovery/aliases.py")]
|
||||
if case.query in KNOWN_CORPUS_GAPS
|
||||
else []
|
||||
),
|
||||
id=str(case),
|
||||
)
|
||||
for case in TUNING_CASES
|
||||
]
|
||||
|
||||
|
||||
async def _accuracy(transform, stock, catalog, cases):
|
||||
"""(mine_top1, mine_topk, stock_top1, stock_topk, misses) over ``cases``."""
|
||||
mine1 = mine_k = stock1 = stock_k = 0
|
||||
misses = []
|
||||
for case in cases:
|
||||
# risk="any" for both, so the A/B never credits the risk filter.
|
||||
ours = _names(transform.rank(catalog, case.query, limit=5).hits)
|
||||
theirs = _names(await stock._search(catalog, case.query))
|
||||
mine1 += ours[:1] == [case.expect]
|
||||
stock1 += theirs[:1] == [case.expect]
|
||||
if case.expect in ours[:TOP_K]:
|
||||
mine_k += 1
|
||||
else:
|
||||
misses.append(f"{case.query!r} want={case.expect} got={ours[:TOP_K]}")
|
||||
stock_k += case.expect in theirs[:TOP_K]
|
||||
return mine1, mine_k, stock1, stock_k, misses
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _tuning_params())
|
||||
async def test_tuning_case_finds_its_tool(transform, catalog, case):
|
||||
hits = _names(transform.rank(catalog, case.query, limit=5).hits)
|
||||
assert case.expect in hits[:TOP_K], hits
|
||||
|
||||
|
||||
async def test_tuning_accuracy_holds_its_measured_bar(transform, stock, catalog):
|
||||
mine1, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, TUNING_CASES)
|
||||
n = len(TUNING_CASES)
|
||||
assert mine1 / n >= TUNING_TOP1_BAR, f"top-1 {mine1}/{n}"
|
||||
assert mine_k / n >= TUNING_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
|
||||
|
||||
|
||||
async def test_holdout_accuracy_clears_the_bar_declared_before_it_was_measured(
|
||||
transform, stock, catalog
|
||||
):
|
||||
_, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, HOLDOUT_CASES)
|
||||
n = len(HOLDOUT_CASES)
|
||||
assert mine_k / n >= HOLDOUT_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}"
|
||||
|
||||
|
||||
async def test_it_beats_stock_bm25_on_the_whole_golden_set(transform, stock, catalog):
|
||||
"""The A/B. Both sides run unfiltered, so this measures vocabulary and
|
||||
ranking only — none of the credit goes to the risk filter.
|
||||
|
||||
Scored on top-1, not a top-`TOP_K` ratio. A ratio between two scores over a
|
||||
shared corpus is not a property of this transform: improving an unrelated
|
||||
tool's docstring adds vocabulary to *both* indexes, so a `mine_k >= 2 *
|
||||
stock_k` assertion moves when stock gets **better**, which is a good outcome
|
||||
being reported as a regression. It fired exactly that way when the undo/redo
|
||||
docstrings were written. Top-1 is also the number that matters — a search
|
||||
tool returns one answer to act on.
|
||||
"""
|
||||
mine1, mine_k, stock1, stock_k, _ = await _accuracy(transform, stock, catalog, ALL_CASES)
|
||||
n = len(ALL_CASES)
|
||||
report = (
|
||||
f"top-1 mine {mine1}/{n} vs stock {stock1}/{n}; "
|
||||
f"top-{TOP_K} mine {mine_k}/{n} vs stock {stock_k}/{n}"
|
||||
)
|
||||
assert mine1 >= 2 * stock1, report
|
||||
assert mine_k > stock_k, report
|
||||
|
||||
|
||||
async def test_stock_bm25_cannot_answer_a_fifth_of_the_set_at_all(transform, stock, catalog):
|
||||
"""The thesis, stated as the thing no amount of tuning can change.
|
||||
|
||||
The corpus gap is *data*, not parameters: AutoCAD command names appear in
|
||||
none of the tool descriptions, so those queries score df=0 and stock returns
|
||||
an empty list. No serializer and no BM25 constant invents vocabulary that is
|
||||
not in the index — which is why the alias corpus, not the ranking, is what
|
||||
this transform actually contributes.
|
||||
"""
|
||||
empty_stock = [c.query for c in ALL_CASES if not await stock._search(catalog, c.query)]
|
||||
empty_mine = [c.query for c in ALL_CASES if not transform.rank(catalog, c.query, limit=5).hits]
|
||||
|
||||
assert len(empty_stock) >= 15, f"only {len(empty_stock)} empty — has the corpus changed?"
|
||||
assert empty_mine == [], f"the alias corpus must cover every case; empty: {empty_mine}"
|
||||
|
||||
|
||||
COUNTING_CASES = [case for case in ALL_CASES if case.kind == "counting"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", COUNTING_CASES, ids=str)
|
||||
async def test_a_counting_question_never_answers_with_a_destructive_tool(transform, catalog, case):
|
||||
outcome = transform.rank(catalog, case.query, limit=5, risk=case.risk)
|
||||
assert case.risk == "read"
|
||||
assert "destructive" not in {tool_risk(t) for t in outcome.hits}
|
||||
assert case.expect in _names(outcome.hits)[:TOP_K]
|
||||
|
||||
|
||||
async def test_the_risk_ceiling_is_what_removes_the_delete_tool(transform, catalog):
|
||||
"""Not the ranking — the ranking still surfaces it, and says that it did."""
|
||||
query = "how many entities are on the GEOMETRY layer"
|
||||
assert "entity_delete_many" in _names(transform.rank(catalog, query, limit=5).hits)
|
||||
filtered = transform.rank(catalog, query, limit=5, risk="read")
|
||||
assert "entity_delete_many" not in _names(filtered.hits)
|
||||
assert "entity_delete_many" in filtered.notice()
|
||||
|
||||
|
||||
# ── DISCOVERY_MODE ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_discovery_mode():
|
||||
"""Attaching a transform mutates the shared server; always put it back."""
|
||||
yield
|
||||
server._apply_discovery_mode("off")
|
||||
|
||||
|
||||
async def test_discovery_is_off_by_default(monkeypatch):
|
||||
"""v1.5.0 ships the flag, not the switch: turning it on is a wire-level
|
||||
breaking change for every connected client and needs its own migration."""
|
||||
monkeypatch.delenv("DISCOVERY_MODE", raising=False)
|
||||
assert config.Settings().discovery_mode == "off"
|
||||
|
||||
|
||||
async def test_the_env_var_selects_search_mode(monkeypatch):
|
||||
monkeypatch.setenv("DISCOVERY_MODE", "SEARCH")
|
||||
assert config.Settings().discovery_mode == "search"
|
||||
|
||||
|
||||
async def test_off_leaves_the_whole_catalog_on_the_wire():
|
||||
server._apply_discovery_mode("off")
|
||||
async with Client(server.mcp) as client:
|
||||
names = {tool.name for tool in await client.list_tools()}
|
||||
assert len(names) > 100
|
||||
assert "search_tools" not in names
|
||||
|
||||
|
||||
async def test_search_replaces_the_catalog_with_the_two_synthetic_tools():
|
||||
server._apply_discovery_mode("search")
|
||||
async with Client(server.mcp) as client:
|
||||
names = {tool.name for tool in await client.list_tools()}
|
||||
assert names == {"search_tools", "call_tool"}
|
||||
|
||||
|
||||
async def test_search_mode_is_reversible_and_attaches_only_once():
|
||||
server._apply_discovery_mode("search")
|
||||
server._apply_discovery_mode("search")
|
||||
server._apply_discovery_mode("off")
|
||||
async with Client(server.mcp) as client:
|
||||
names = {tool.name for tool in await client.list_tools()}
|
||||
assert len(names) > 100
|
||||
|
||||
|
||||
async def test_an_unknown_discovery_mode_falls_back_to_off():
|
||||
assert server._apply_discovery_mode("semantic-vector-magic") == "off"
|
||||
async with Client(server.mcp) as client:
|
||||
names = {tool.name for tool in await client.list_tools()}
|
||||
assert len(names) > 100
|
||||
|
||||
|
||||
async def test_the_search_tool_answers_on_the_wire():
|
||||
"""End to end: a real client, a real AutoCAD command, a real payload."""
|
||||
server._apply_discovery_mode("search")
|
||||
async with Client(server.mcp) as client:
|
||||
result = await client.call_tool("search_tools", {"query": "BPOLY"})
|
||||
assert "boundary_trace" in result.content[0].text
|
||||
|
||||
|
||||
async def test_the_risk_notice_survives_to_the_wire():
|
||||
server._apply_discovery_mode("search")
|
||||
async with Client(server.mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"search_tools",
|
||||
{"query": "how many entities are on the GEOMETRY layer", "risk": "read"},
|
||||
)
|
||||
text = result.content[0].text
|
||||
assert "analysis_entity_stats" in text
|
||||
assert "Risk filter:" in text
|
||||
assert "entity_delete_many" in text
|
||||
|
||||
|
||||
async def test_search_cannot_surface_a_tool_the_profile_disabled():
|
||||
"""Capability-aware discovery has to survive the transform.
|
||||
|
||||
The 3D solid tools are registered but disabled unless ENABLE_3D=true, so
|
||||
searching for one must come back empty rather than advertising a call that
|
||||
would only be rejected. This works because the search reads the *filtered*
|
||||
catalog through _get_visible_tools, not the raw registry.
|
||||
"""
|
||||
server._apply_discovery_mode("search")
|
||||
async with Client(server.mcp) as client:
|
||||
result = await client.call_tool("search_tools", {"query": "BOX", "limit": 10})
|
||||
assert "solid_box" not in result.content[0].text
|
||||
|
||||
|
||||
async def test_limit_reaches_the_wire():
|
||||
server._apply_discovery_mode("search")
|
||||
async with Client(server.mcp) as client:
|
||||
result = await client.call_tool("search_tools", {"query": "layer", "limit": 2})
|
||||
assert len(result.content[0].text.splitlines()) == 2
|
||||
|
||||
|
||||
async def test_search_mode_does_not_shrink_what_the_server_reports_about_itself():
|
||||
"""system_about reads the untransformed registry; prove it under the transform."""
|
||||
server._apply_discovery_mode("search")
|
||||
assert await server._registered_tool_count() > 100
|
||||
assert len(await server._tool_groups()) > 5
|
||||
+25
-4
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@@ -46,14 +48,33 @@ async def test_drawing_purge(backend):
|
||||
async def test_drawing_audit(backend):
|
||||
result = await backend.drawing_audit()
|
||||
assert result.get("ok") is True
|
||||
# Asserting only `ok` is why the audit debt survived to 1.5.0: the tool
|
||||
# repaired documents and reported nothing about it, and this test was green
|
||||
# throughout. The contract is the whole key set (see backends/base.py).
|
||||
assert {"ok", "repaired", "fixes", "fix_count", "errors", "error_count"} <= result.keys()
|
||||
|
||||
|
||||
async def test_drawing_undo(backend):
|
||||
async def test_drawing_undo(backend, monkeypatch):
|
||||
# `assert isinstance(result, dict)` used to be the whole test, and it passed
|
||||
# for years against an undo that could only ever fail. See
|
||||
# tests/test_undo_redo.py for the full contract.
|
||||
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
|
||||
await backend.drawing_new()
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
|
||||
result = await backend.drawing_undo()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(list(backend._doc.modelspace())) == 0
|
||||
|
||||
|
||||
async def test_drawing_redo(backend):
|
||||
async def test_drawing_redo(backend, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
|
||||
await backend.drawing_new()
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.drawing_undo()
|
||||
|
||||
result = await backend.drawing_redo()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert len(list(backend._doc.modelspace())) == 1
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""T0.5 — ``entity_measure``: ask the drawing, not the model's memory.
|
||||
|
||||
Companion to ``tests/test_measure.py``, which covers the arithmetic. This file
|
||||
covers the part that was actually missing: addressing an entity **by handle**.
|
||||
|
||||
Before this, neither backend could answer "what is the area of the polyline I
|
||||
just drew". ``analysis_measure_area`` shoelaced points the caller supplied, and
|
||||
the live COM backend did the same — despite having a document, a
|
||||
``HandleToObject`` lookup it already used fourteen times, and an ActiveX ``Area``
|
||||
property sitting right there.
|
||||
|
||||
Two distinctions here are load-bearing and are asserted, not assumed:
|
||||
|
||||
* A LINE has no area on *any* engine, so that is a plain error — tagging it with
|
||||
a ``capability`` would falsely imply "switch backends and this works".
|
||||
* A REGION has no area on the *headless* engine only, so that is a capability
|
||||
refusal naming the live backend as the way forward.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
#: 100x100 square, bottom edge bulged outward into a semicircle of radius 50.
|
||||
BULGED_SQUARE = [(0, 0, 0, 0, 0.0), (100, 0, 0, 0, 1.0), (100, 100, 0, 0, 0.0), (0, 100, 0, 0, 0.0)]
|
||||
TRUE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0
|
||||
TRUE_PERIMETER = 300.0 + math.pi * 50.0
|
||||
|
||||
|
||||
def _add_bulged_square(backend, close=True):
|
||||
pline = backend._doc.modelspace().add_lwpolyline(BULGED_SQUARE, format="xyseb", close=close)
|
||||
return pline.dxf.handle
|
||||
|
||||
|
||||
# ── the capability that did not exist ───────────────────────────────────────
|
||||
|
||||
|
||||
async def test_measures_a_bulged_polyline_exactly(backend):
|
||||
"""The 28.2% the reported-points workflow lost."""
|
||||
handle = _add_bulged_square(backend)
|
||||
|
||||
result = await backend.entity_measure(handle)
|
||||
|
||||
assert result["handle"] == handle
|
||||
assert result["type"] == "LWPOLYLINE"
|
||||
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
|
||||
assert result["perimeter"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
|
||||
assert result["method"] == "analytic_bulge"
|
||||
assert result["exact"] is True
|
||||
assert result["flatten_tolerance"] is None
|
||||
assert result["closed"] is True
|
||||
assert result["assumed_closed"] is False
|
||||
assert result["self_intersecting"] is False
|
||||
assert result["backend"] == "ezdxf"
|
||||
|
||||
|
||||
async def test_a_straight_polyline_reports_the_plain_analytic_method(backend):
|
||||
pline = backend._doc.modelspace().add_lwpolyline(
|
||||
[(0, 0), (10, 0), (10, 10), (0, 10)], close=True
|
||||
)
|
||||
result = await backend.entity_measure(pline.dxf.handle)
|
||||
|
||||
assert result["area"] == pytest.approx(100.0)
|
||||
assert result["perimeter"] == pytest.approx(40.0)
|
||||
assert result["method"] == "analytic"
|
||||
assert result["exact"] is True
|
||||
|
||||
|
||||
async def test_measures_a_circle(backend):
|
||||
circle = await backend.entity_create_circle(5, 5, 4)
|
||||
result = await backend.entity_measure(circle.handle)
|
||||
|
||||
assert result["area"] == pytest.approx(math.pi * 16.0)
|
||||
assert result["perimeter"] == pytest.approx(2 * math.pi * 4.0)
|
||||
assert result["method"] == "analytic"
|
||||
assert result["exact"] is True
|
||||
assert result["closed"] is True
|
||||
|
||||
|
||||
async def test_an_open_boundary_says_it_was_closed(backend):
|
||||
"""AutoCAD's AREA closes an open boundary; the payload must admit it."""
|
||||
handle = _add_bulged_square(backend, close=False)
|
||||
|
||||
result = await backend.entity_measure(handle)
|
||||
|
||||
assert result["closed"] is False
|
||||
assert result["assumed_closed"] is True
|
||||
assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
|
||||
|
||||
|
||||
async def test_a_self_intersecting_polyline_is_flagged(backend):
|
||||
"""Shoelace cancels crossed lobes, so the 0.0 has to come with a warning."""
|
||||
pline = backend._doc.modelspace().add_lwpolyline(
|
||||
[(0, 0), (100, 100), (100, 0), (0, 100)], close=True
|
||||
)
|
||||
result = await backend.entity_measure(pline.dxf.handle)
|
||||
|
||||
assert result["self_intersecting"] is True
|
||||
|
||||
|
||||
# ── the two kinds of "no" ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_line_has_no_area_on_any_engine_so_it_is_not_a_capability_gap(backend):
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend.entity_measure(line.handle)
|
||||
|
||||
assert not isinstance(excinfo.value, UnsupportedCapabilityError), (
|
||||
"tagging this `capability` would tell the user to switch backends, "
|
||||
"where no backend can give a LINE an area"
|
||||
)
|
||||
assert "LWPOLYLINE" in str(excinfo.value), "the refusal must list what IS measurable"
|
||||
|
||||
|
||||
async def test_a_region_is_a_real_capability_boundary(backend):
|
||||
"""ezdxf stores ACIS opaquely and cannot evaluate it; live AutoCAD can."""
|
||||
region = backend._doc.modelspace().new_entity("REGION", {})
|
||||
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.entity_measure(region.dxf.handle)
|
||||
|
||||
assert excinfo.value.capability == "measure_area_acis"
|
||||
assert "com" in str(excinfo.value).lower(), "a refusal must name the way forward"
|
||||
|
||||
|
||||
async def test_measure_capability_is_declared_on_both_backends():
|
||||
assert EzdxfBackend().capabilities().features["measure_area_acis"].supported is False
|
||||
assert ComBackend().capabilities().features["measure_area_acis"].supported is True
|
||||
|
||||
|
||||
# ── HATCH: the type the refusal advertised and then refused ─────────────────
|
||||
#
|
||||
# `_MEASURABLE_TYPES` named HATCH, so a caller who asked for a hatch's area got
|
||||
# a RuntimeError whose own text listed HATCH as measurable. Whichever half was
|
||||
# right, the pair could not both be — and a hatch is the one entity whose area
|
||||
# a drafter actually asks for, because it is the section they have to fill.
|
||||
|
||||
|
||||
def _hatch_with_island(backend, *, style: int = 0):
|
||||
"""20x20 outer square, 10x10 island. Filled area is 400 - 100 = 300."""
|
||||
hatch = backend._doc.modelspace().add_hatch(color=1)
|
||||
hatch.dxf.hatch_style = style
|
||||
hatch.paths.add_polyline_path(
|
||||
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
|
||||
)
|
||||
island = hatch.paths.add_edge_path(flags=0)
|
||||
island.add_line((5, 5), (15, 5))
|
||||
island.add_line((15, 5), (15, 15))
|
||||
island.add_line((15, 15), (5, 15))
|
||||
island.add_line((5, 15), (5, 5))
|
||||
return hatch.dxf.handle
|
||||
|
||||
|
||||
async def test_a_hatch_reports_the_area_it_actually_fills(backend):
|
||||
handle = _hatch_with_island(backend)
|
||||
|
||||
result = await backend.entity_measure(handle)
|
||||
|
||||
assert result["type"] == "HATCH"
|
||||
assert result["area"] == pytest.approx(300.0, abs=1e-6), (
|
||||
"the island is a hole in the fill; counting it is the 33% error"
|
||||
)
|
||||
assert result["loop_count"] == 2
|
||||
assert result["hatch_style"] == "normal"
|
||||
assert result["exact"] is True, "every edge here is a straight line — nothing was flattened"
|
||||
|
||||
|
||||
async def test_hatch_style_ignore_fills_straight_over_the_island(backend):
|
||||
"""AutoCAD's HPISLAND=2 fills the island too. The number must follow."""
|
||||
handle = _hatch_with_island(backend, style=2)
|
||||
|
||||
result = await backend.entity_measure(handle)
|
||||
|
||||
assert result["area"] == pytest.approx(400.0, abs=1e-6)
|
||||
assert result["hatch_style"] == "ignore"
|
||||
|
||||
|
||||
async def test_a_nested_island_alternates_under_the_normal_style(backend):
|
||||
"""Depth 2 is where `normal` and `outer` stop agreeing, so it is the only
|
||||
case that proves the depths were computed rather than assumed."""
|
||||
hatch = backend._doc.modelspace().add_hatch(color=1)
|
||||
hatch.dxf.hatch_style = 0 # normal
|
||||
hatch.paths.add_polyline_path(
|
||||
[(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16
|
||||
)
|
||||
hatch.paths.add_polyline_path([(4, 4), (16, 4), (16, 16), (4, 16)], is_closed=True, flags=0)
|
||||
hatch.paths.add_polyline_path([(8, 8), (12, 8), (12, 12), (8, 12)], is_closed=True, flags=0)
|
||||
|
||||
result = await backend.entity_measure(hatch.dxf.handle)
|
||||
|
||||
# 400 - 144 + 16: the innermost loop is filled again.
|
||||
assert result["area"] == pytest.approx(272.0, abs=1e-6)
|
||||
assert result["loop_count"] == 3
|
||||
|
||||
|
||||
async def test_a_curved_hatch_edge_says_it_was_flattened(backend):
|
||||
hatch = backend._doc.modelspace().add_hatch(color=1)
|
||||
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
|
||||
|
||||
result = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-5)
|
||||
|
||||
assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3)
|
||||
assert result["exact"] is False, "a flattened arc is not an exact area"
|
||||
assert result["flatten_tolerance"] == 1e-5
|
||||
|
||||
|
||||
async def test_tightening_the_tolerance_does_not_make_a_curved_hatch_exact(backend):
|
||||
"""The floor is the arc-to-Bezier conversion, not the flattening.
|
||||
|
||||
A caller who sees `flatten_tolerance` naturally assumes a smaller number
|
||||
buys a better area, all the way to the truth. For hatch edges it does not:
|
||||
ezdxf hands the boundary over as cubic Beziers, and tightening the
|
||||
tolerance converges on the *Bezier's* area, which sits ~0.028% above the
|
||||
circle it stands in for. The residual does not shrink with the tolerance,
|
||||
which is why `exact` is False rather than the tolerance being presented as
|
||||
the accuracy knob.
|
||||
"""
|
||||
hatch = backend._doc.modelspace().add_hatch(color=1)
|
||||
hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360)
|
||||
|
||||
true_area = math.pi * 100.0
|
||||
coarse = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-3)
|
||||
fine = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-7)
|
||||
finest = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-9)
|
||||
|
||||
# Four orders of magnitude of extra tolerance buy nothing after the first.
|
||||
assert finest["area"] == pytest.approx(fine["area"], rel=1e-7)
|
||||
residual = abs(finest["area"] - true_area) / true_area
|
||||
assert residual > 2e-4, "the Bezier error survives any tolerance"
|
||||
assert abs(coarse["area"] - true_area) / true_area < residual, (
|
||||
"the coarse run is only closer by accident — an inscribed chord error "
|
||||
"that happens to cancel part of the Bezier overshoot"
|
||||
)
|
||||
assert finest["exact"] is False
|
||||
|
||||
|
||||
# ── entity_get stops dropping the curve ─────────────────────────────────────
|
||||
|
||||
|
||||
async def test_entity_get_reports_area_and_length_for_a_closed_polyline(backend):
|
||||
"""``props["length"] = ent.length()`` was dead code — LWPolyline has no
|
||||
``length()``, so the AttributeError went to a debug log and the key silently
|
||||
never appeared."""
|
||||
handle = _add_bulged_square(backend)
|
||||
|
||||
info = await backend.entity_get(handle)
|
||||
|
||||
assert info.properties["area"] == pytest.approx(TRUE_AREA, rel=1e-9)
|
||||
assert info.properties["length"] == pytest.approx(TRUE_PERIMETER, rel=1e-9)
|
||||
|
||||
|
||||
async def test_entity_get_still_reports_only_xy_points(backend):
|
||||
"""The bulge feeds the measurement; it must not change the points contract."""
|
||||
handle = _add_bulged_square(backend)
|
||||
info = await backend.entity_get(handle)
|
||||
|
||||
assert all(len(point) == 2 for point in info.properties["points"])
|
||||
@@ -0,0 +1,325 @@
|
||||
"""F2 — LIST: the raw DXF attribute set for one handle.
|
||||
|
||||
`entity_get` reports a *curated* view: the handful of derived fields the drafting
|
||||
tools need, already flattened to WCS xy. `entity_measure` reports lengths and
|
||||
areas. Neither answers the question AutoCAD's LIST answers — "what is actually
|
||||
stored on this object?" — and that is the only reason this tool exists. It must
|
||||
not restate the other two.
|
||||
|
||||
Four measured facts shape every test below.
|
||||
|
||||
* **The dump is not JSON-serialisable as it comes out of ezdxf.**
|
||||
`entity.dxfattribs()` returns `Vec3` for `start` / `end` / `insert` / `center`.
|
||||
Measured: `json.dumps` raises ``Object of type Vec3 is not JSON serializable``
|
||||
for LINE, CIRCLE, TEXT and INSERT. A tool that hands that dict back either dies
|
||||
at the wire boundary or gets silently coerced into `"Vec3(0.0, 0.0, 0.0)"` —
|
||||
a string where the caller expected a point. So `json.dumps(result)` is asserted
|
||||
outright, for all five point-carrying types, and the values are asserted to
|
||||
still be *numbers* afterwards: stringifying the Vec3 would satisfy `json.dumps`
|
||||
and destroy the payload.
|
||||
|
||||
* **The dump is WCS, like everything else crossing a tool boundary.**
|
||||
A mirrored CIRCLE stores `center = (30, 20)` with `extrusion = (0, 0, -1)`;
|
||||
it is drawn at (-30, 20), which is what `entity_get` reports. A raw
|
||||
`dxfattribs()` echo therefore contradicts `entity_get` in the same payload by
|
||||
60 mm — the exact defect T0.4 was opened to remove. The dump still reports the
|
||||
`extrusion` it translated *from*, because a caller who cannot see the frame
|
||||
cannot tell a translated point from an untranslated one.
|
||||
|
||||
* **`dxfattribs()` on an LWPOLYLINE contains no geometry at all.**
|
||||
Measured: the keys are `flags`, `handle`, `layer`, `owner` — the vertices live
|
||||
in `get_points()`, not in the attribute dict. A tool that dumps `dxfattribs()`
|
||||
and stops reports a polyline with no points and calls it a full property dump.
|
||||
The vertices must carry their **bulge** too: `entity_get` gives bare `[x, y]`
|
||||
pairs, and reconstructing a bulged outline from those loses 28.2% of the area.
|
||||
|
||||
* **Only stored attributes are reported.** `thickness` is absent from a fresh
|
||||
LINE and appears once set. Padding the dump with schema defaults would make an
|
||||
explicitly-set 0.0 indistinguishable from "never touched", which is the one
|
||||
question a raw attribute dump exists to answer.
|
||||
|
||||
The refusal: an unknown or deleted handle raises `RuntimeError`, matching
|
||||
`entity_get`. Returning `{}` for a handle that is not there would report "this
|
||||
entity has no properties" for an entity that has no existence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
#: Mirror line = the Y axis (x -> -x). This is what produces extrusion (0, 0, -1).
|
||||
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def _numbers(value):
|
||||
"""Assert `value` is a real numeric sequence and return its x, y.
|
||||
|
||||
Accepts a 2- or 3-component point: the arity of the dump is the tool
|
||||
author's call, but "it is a sequence of numbers" is not. `bool` is excluded
|
||||
because `isinstance(True, int)` would otherwise wave a coerced flag through.
|
||||
"""
|
||||
assert isinstance(value, (list, tuple)), f"expected a point, got {value!r}"
|
||||
assert len(value) in (2, 3), f"expected a 2D or 3D point, got {value!r}"
|
||||
for component in value:
|
||||
assert isinstance(component, (int, float)) and not isinstance(component, bool), (
|
||||
f"point component {component!r} is not a number"
|
||||
)
|
||||
return float(value[0]), float(value[1])
|
||||
|
||||
|
||||
async def _one_of_each(backend):
|
||||
"""A LINE, LWPOLYLINE, INSERT, TEXT and CIRCLE — every Vec3-carrying type."""
|
||||
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
|
||||
return {
|
||||
"LINE": (await backend.entity_create_line(0, 0, 100, 50)).handle,
|
||||
"LWPOLYLINE": (
|
||||
await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
||||
).handle,
|
||||
"INSERT": (await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)).handle,
|
||||
"TEXT": (await backend.entity_create_text("PART A", 40, 15, 3.0)).handle,
|
||||
"CIRCLE": (await backend.entity_create_circle(30, 20, 5)).handle,
|
||||
}
|
||||
|
||||
|
||||
# ── the wire boundary ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dxftype", ["LINE", "LWPOLYLINE", "INSERT", "TEXT", "CIRCLE"])
|
||||
async def test_the_dump_survives_json_encoding(backend, dxftype):
|
||||
"""Measured: raw `dxfattribs()` raises `Object of type Vec3 is not JSON serializable`."""
|
||||
handles = await _one_of_each(backend)
|
||||
|
||||
result = await backend.analysis_list_properties(handles[dxftype])
|
||||
|
||||
encoded = json.dumps(result)
|
||||
assert json.loads(encoded) == result, "the payload changed shape on the way through JSON"
|
||||
|
||||
|
||||
async def test_a_lines_endpoints_are_numbers_not_stringified_vectors(backend):
|
||||
"""`str(Vec3(...))` would satisfy json.dumps and hand the caller a string."""
|
||||
line = await backend.entity_create_line(0, 0, 100, 50)
|
||||
|
||||
dump = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
|
||||
|
||||
assert _numbers(dump["start"]) == pytest.approx((0.0, 0.0))
|
||||
assert _numbers(dump["end"]) == pytest.approx((100.0, 50.0))
|
||||
|
||||
|
||||
async def test_a_texts_insertion_point_is_numbers_not_a_stringified_vector(backend):
|
||||
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
||||
|
||||
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
|
||||
|
||||
assert _numbers(dump["insert"]) == pytest.approx((40.0, 15.0))
|
||||
assert dump["text"] == "PART A"
|
||||
assert dump["height"] == pytest.approx(3.0)
|
||||
|
||||
|
||||
async def test_an_inserts_insertion_point_is_numbers_not_a_stringified_vector(backend):
|
||||
backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0)
|
||||
insert = await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)
|
||||
|
||||
dump = (await backend.analysis_list_properties(insert.handle))["dxf_attributes"]
|
||||
|
||||
assert _numbers(dump["insert"]) == pytest.approx((10.0, 20.0))
|
||||
assert dump["name"] == "BOLT"
|
||||
assert dump["xscale"] == pytest.approx(2.0)
|
||||
assert dump["yscale"] == pytest.approx(3.0)
|
||||
|
||||
|
||||
# ── coordinates are WCS ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_mirrored_circles_centre_is_reported_in_wcs(backend):
|
||||
"""Stored (30, 20) with extrusion -Z; drawn at (-30, 20). The dump says drawn.
|
||||
|
||||
Echoing `dxfattribs()` puts a 60 mm contradiction inside one payload, because
|
||||
`entity_get` on the same handle reports (-30, 20).
|
||||
"""
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
assert backend._doc.entitydb.get(mirrored.handle).dxf.center.x == pytest.approx(30.0), (
|
||||
"precondition: the DXF really does store the un-negated x"
|
||||
)
|
||||
|
||||
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
|
||||
|
||||
assert _numbers(dump["center"]) == pytest.approx((-30.0, 20.0))
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
assert _numbers(dump["center"]) == pytest.approx(tuple(info.properties["center"]))
|
||||
|
||||
|
||||
async def test_the_dump_still_names_the_frame_it_translated_from(backend):
|
||||
"""Drop the extrusion and a caller cannot tell a translated point from a raw one."""
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
|
||||
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
|
||||
|
||||
assert list(dump["extrusion"]) == pytest.approx([0.0, 0.0, -1.0])
|
||||
assert dump["radius"] == pytest.approx(5.0), "radius is frame-invariant in a flat frame"
|
||||
|
||||
|
||||
# ── what entity_get deliberately does not carry ─────────────────────────────
|
||||
|
||||
|
||||
async def test_a_polylines_vertices_are_in_the_dump(backend):
|
||||
"""Measured: `dxfattribs()` for an LWPOLYLINE is flags/handle/layer/owner only.
|
||||
|
||||
Dumping it verbatim describes a polyline without saying where it is.
|
||||
"""
|
||||
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
||||
assert "points" not in backend._doc.entitydb.get(poly.handle).dxfattribs(), (
|
||||
"precondition: ezdxf keeps the vertices out of the attribute dict"
|
||||
)
|
||||
|
||||
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
|
||||
|
||||
assert [_numbers(pt) for pt in dump["points"]] == pytest.approx(
|
||||
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
|
||||
)
|
||||
|
||||
|
||||
async def test_a_polyline_vertex_carries_its_bulge(backend):
|
||||
"""`entity_get` gives bare [x, y]; rebuilding a bulged outline from those
|
||||
loses 28.2% of the area. The full dump is where the bulge has to live."""
|
||||
poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
|
||||
backend._doc.entitydb.get(poly.handle).set_points(
|
||||
[(0, 0, 0.0), (10, 0, 0.5), (10, 10, 0.0)], format="xyb"
|
||||
)
|
||||
|
||||
dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"]
|
||||
|
||||
bulges = [pt[2] for pt in dump["points"]]
|
||||
assert len(bulges) == 3, "every vertex reports a bulge, including the straight ones"
|
||||
assert bulges == pytest.approx([0.0, 0.5, 0.0])
|
||||
|
||||
|
||||
async def test_text_style_and_obliquity_reach_the_caller(backend):
|
||||
"""Neither appears anywhere in `entity_get`, and both change what is plotted."""
|
||||
backend._doc.styles.new("ISO", dxfattribs={"font": "isocp.shx"})
|
||||
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
||||
entity = backend._doc.entitydb.get(text.handle)
|
||||
entity.dxf.style = "ISO"
|
||||
entity.dxf.oblique = 15.0
|
||||
|
||||
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
|
||||
|
||||
assert dump["style"] == "ISO"
|
||||
assert dump["oblique"] == pytest.approx(15.0)
|
||||
info = await backend.entity_get(text.handle)
|
||||
assert "style" not in info.properties, "precondition: entity_get is the curated view"
|
||||
|
||||
|
||||
async def test_the_dump_reports_what_is_stored_not_schema_defaults(backend):
|
||||
"""A dump padded with defaults cannot answer "did the drafter set this?".
|
||||
|
||||
That is the only question a raw attribute dump exists to answer, so an
|
||||
unset `thickness` must be absent rather than reported as 0.0.
|
||||
"""
|
||||
line = await backend.entity_create_line(0, 0, 100, 50)
|
||||
|
||||
before = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
|
||||
assert "thickness" not in before
|
||||
assert "ltscale" not in before
|
||||
|
||||
entity = backend._doc.entitydb.get(line.handle)
|
||||
entity.dxf.thickness = 2.0
|
||||
entity.dxf.ltscale = 0.5
|
||||
after = (await backend.analysis_list_properties(line.handle))["dxf_attributes"]
|
||||
|
||||
assert after["thickness"] == pytest.approx(2.0)
|
||||
assert after["ltscale"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
# ── the top level ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_the_type_and_layer_sit_at_the_top_level(backend):
|
||||
"""A caller asking "what is this?" should not have to dig into the dump."""
|
||||
circle = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
|
||||
|
||||
result = await backend.analysis_list_properties(circle.handle)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["handle"] == circle.handle
|
||||
assert result["type"] == "CIRCLE"
|
||||
assert result["layer"] == "GEOMETRY"
|
||||
assert result["dxf_attributes"]["layer"] == "GEOMETRY", "the two must not disagree"
|
||||
|
||||
|
||||
async def test_the_summary_does_not_contradict_entity_get(backend):
|
||||
"""`properties` restates the curated view, so it must at least agree with it.
|
||||
|
||||
This is the test that argues for deleting the key: a second copy of
|
||||
`entity_get`'s output can only ever be identical or wrong.
|
||||
"""
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
|
||||
result = await backend.analysis_list_properties(mirrored.handle)
|
||||
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
assert result["properties"]["center"] == pytest.approx(info.properties["center"])
|
||||
|
||||
|
||||
# ── the refusal ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_an_unknown_handle_is_refused_not_answered_with_an_empty_dump(backend):
|
||||
"""`{}` would read as "this entity has no properties" for a nonexistent entity."""
|
||||
with pytest.raises(RuntimeError, match="FFFFFF"):
|
||||
await backend.analysis_list_properties("FFFFFF")
|
||||
|
||||
|
||||
async def test_a_deleted_handle_is_refused_too(backend):
|
||||
"""`entitydb.get` does not filter destroyed entities, so the guard must."""
|
||||
line = await backend.entity_create_line(0, 0, 100, 50)
|
||||
await backend.entity_delete(line.handle)
|
||||
|
||||
with pytest.raises(RuntimeError, match=line.handle):
|
||||
await backend.analysis_list_properties(line.handle)
|
||||
|
||||
|
||||
# ── round trip ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_the_dump_describes_what_actually_lands_in_the_file(backend, tmp_path):
|
||||
"""The dump is a translation of the file, not a snapshot of in-memory state.
|
||||
|
||||
The reloaded DXF still stores the OCS centre; the dump reported the WCS one.
|
||||
Both readings of the same circle must remain consistent after a save.
|
||||
"""
|
||||
import ezdxf
|
||||
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"]
|
||||
|
||||
path = tmp_path / "listed.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(mirrored.handle)
|
||||
|
||||
assert reloaded.dxf.center.x == pytest.approx(30.0), "the file keeps the OCS x"
|
||||
assert list(reloaded.dxf.extrusion) == pytest.approx(list(dump["extrusion"]))
|
||||
assert reloaded.dxf.radius == pytest.approx(dump["radius"])
|
||||
assert _numbers(dump["center"]) == pytest.approx((-reloaded.dxf.center.x, 20.0))
|
||||
|
||||
|
||||
async def test_a_texts_dump_round_trips_through_a_saved_file(backend, tmp_path):
|
||||
import ezdxf
|
||||
|
||||
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
||||
dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"]
|
||||
|
||||
path = tmp_path / "listed_text.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(text.handle)
|
||||
|
||||
assert reloaded.dxf.text == dump["text"]
|
||||
assert reloaded.dxf.height == pytest.approx(dump["height"])
|
||||
assert _numbers(dump["insert"]) == pytest.approx((reloaded.dxf.insert.x, reloaded.dxf.insert.y))
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Honesty guarantees for the headless ezdxf backend.
|
||||
|
||||
T0.1 — the DWG lie. ezdxf has no DWG writer, so any save that would put DXF
|
||||
bytes behind a ``.dwg`` name must refuse with the machine-readable
|
||||
``{"ok": False, "error": ..., "capability": ...}`` shape instead, and the
|
||||
boundary must be discoverable up-front through the capability map.
|
||||
|
||||
T0.3 — the audit lie, which ran the *other* way. ``Drawing.audit()`` repairs the
|
||||
document in place, so ``auditor.fixes`` is a log of mutations already
|
||||
applied. ``drawing_audit`` threw that list away, never marked the document
|
||||
dirty (so ``drawing_close(save=True)`` discarded the repairs it had just
|
||||
made), and rendered the errors it did report as ``<ErrorEntry object at
|
||||
0x...>``. The tool said "0 errors" about a file it had silently altered.
|
||||
|
||||
T0.6 — no per-call timeout. Every ezdxf method funnels through ``_async``, which
|
||||
serialises on a single ``asyncio.Lock``; one hung sync call used to hold
|
||||
that lock forever and deadlock every later tool call on the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── T0.1 — DWG refusal ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_save_as_dwg_refuses_with_capability_tagged_shape(backend, tmp_path):
|
||||
target = tmp_path / "part.dwg"
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend.drawing_save_as(str(target), "dwg")
|
||||
|
||||
payload = excinfo.value.to_dict()
|
||||
assert payload["ok"] is False
|
||||
assert payload["capability"] == "dwg"
|
||||
assert excinfo.value.capability == "dwg"
|
||||
|
||||
message = payload["error"]
|
||||
assert message == str(excinfo.value)
|
||||
lowered = message.lower()
|
||||
assert "dwg" in lowered
|
||||
assert ".dxf" in lowered # points at the headless alternative
|
||||
assert "com" in lowered # ...and at the live backend
|
||||
assert not target.exists(), "refusal must not leave a mislabelled file behind"
|
||||
|
||||
|
||||
async def test_save_as_refuses_dwg_extension_even_when_fmt_says_dxf(backend, tmp_path):
|
||||
"""The bytes must match the name: a .dwg path is a DWG request, whatever fmt says."""
|
||||
target = tmp_path / "sneaky.dwg"
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend.drawing_save_as(str(target), "dxf")
|
||||
assert excinfo.value.capability == "dwg"
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
async def test_drawing_save_refuses_dwg_path(backend, tmp_path):
|
||||
"""drawing_save(path) took any path and wrote DXF bytes into it."""
|
||||
target = tmp_path / "saved.dwg"
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend.drawing_save(str(target))
|
||||
assert excinfo.value.capability == "dwg"
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
async def test_dxf_save_still_works(backend, tmp_path):
|
||||
import ezdxf
|
||||
|
||||
await backend.entity_create_line(0, 0, 10, 0)
|
||||
|
||||
as_path = tmp_path / "ok_saveas.dxf"
|
||||
result = await backend.drawing_save_as(str(as_path), "dxf")
|
||||
assert result["ok"] is True
|
||||
assert result["format"] == "dxf"
|
||||
assert ezdxf.readfile(str(as_path)) is not None
|
||||
|
||||
save_path = tmp_path / "ok_save.dxf"
|
||||
assert (await backend.drawing_save(str(save_path)))["ok"] is True
|
||||
assert save_path.exists()
|
||||
|
||||
export_path = tmp_path / "ok_export.dxf"
|
||||
assert (await backend.drawing_export_dxf(str(export_path)))["ok"] is True
|
||||
assert export_path.exists()
|
||||
|
||||
|
||||
async def test_capability_map_reports_the_dwg_boundary(backend):
|
||||
features = backend.capabilities().to_dict()["features"]
|
||||
assert features["dxf"]["supported"] is True
|
||||
assert features["dwg"]["supported"] is False
|
||||
assert features["dwg"]["reason"]
|
||||
|
||||
|
||||
async def test_both_backends_declare_the_same_capability_keys():
|
||||
"""A capability only helps clients if both maps answer the same question."""
|
||||
ezdxf_keys = set(EzdxfBackend().capabilities().features)
|
||||
com_keys = set(ComBackend().capabilities().features)
|
||||
assert ezdxf_keys == com_keys
|
||||
assert "dwg" in com_keys
|
||||
assert ComBackend().capabilities().features["dwg"].supported is True
|
||||
|
||||
|
||||
# ── T0.6 — per-call timeout / anti-deadlock ─────────────────────────────────
|
||||
|
||||
|
||||
async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
|
||||
entered = threading.Event()
|
||||
|
||||
def _hang():
|
||||
entered.set()
|
||||
time.sleep(2.0)
|
||||
return "too late"
|
||||
|
||||
started = time.monotonic()
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend._async(_hang)
|
||||
assert entered.is_set()
|
||||
assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out"
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "EZDXF_CALL_TIMEOUT" in message
|
||||
assert "0.2" in message
|
||||
|
||||
# The anti-deadlock guarantee: the NEXT call must not queue behind the
|
||||
# abandoned worker thread (which is still sleeping).
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10)
|
||||
info = await asyncio.wait_for(backend.drawing_info(), timeout=3)
|
||||
assert info.backend == "ezdxf"
|
||||
line = await asyncio.wait_for(backend.entity_create_line(0, 0, 1, 1), timeout=3)
|
||||
assert line.handle
|
||||
|
||||
|
||||
async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(backend, monkeypatch):
|
||||
"""A live call owns its lock: queueing past the timeout must not abandon it."""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
|
||||
live = backend._lock
|
||||
await live.acquire() # stand in for a call that is still running
|
||||
try:
|
||||
started = time.monotonic()
|
||||
with pytest.raises(RuntimeError, match="busy"):
|
||||
await backend._async(lambda: "never runs")
|
||||
assert time.monotonic() - started < 1.0
|
||||
assert backend._lock is live, "the in-flight call's lock must not be swapped out"
|
||||
finally:
|
||||
live.release()
|
||||
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5)
|
||||
assert await asyncio.wait_for(backend._async(lambda: "after"), timeout=2) == "after"
|
||||
|
||||
|
||||
async def test_zero_timeout_disables_the_check(backend, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0)
|
||||
|
||||
def _slow():
|
||||
time.sleep(0.3)
|
||||
return "finished"
|
||||
|
||||
assert await backend._async(_slow) == "finished"
|
||||
|
||||
|
||||
async def test_async_still_serialises_calls(backend):
|
||||
"""The timeout rewrite must not drop mutual exclusion on the ezdxf document."""
|
||||
trace: list[str] = []
|
||||
|
||||
def _work(tag):
|
||||
trace.append(f"enter:{tag}")
|
||||
time.sleep(0.05)
|
||||
trace.append(f"exit:{tag}")
|
||||
return tag
|
||||
|
||||
results = await asyncio.gather(*(backend._async(_work, tag) for tag in "abc"))
|
||||
assert sorted(results) == ["a", "b", "c"]
|
||||
assert trace == [step for tag in results for step in (f"enter:{tag}", f"exit:{tag}")]
|
||||
|
||||
|
||||
async def test_a_call_raising_TimeoutError_is_not_reported_as_the_deadline(backend, monkeypatch):
|
||||
"""A TimeoutError *from the call* is a normal failure, not our deadline.
|
||||
|
||||
``TimeoutError`` is ``asyncio.TimeoutError`` on 3.11+ and an ``OSError``
|
||||
subclass, so an ezdxf read off a dead network share raises exactly what
|
||||
``wait_for`` raises. Blaming the deadline for it would tell the user their
|
||||
drawing "may be left mid-write" and orphan a lock, for a call that never
|
||||
ran long at all.
|
||||
"""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 30)
|
||||
live = backend._lock
|
||||
|
||||
def _boom():
|
||||
raise TimeoutError("the network share went away")
|
||||
|
||||
with pytest.raises(TimeoutError, match="network share"):
|
||||
await backend._async(_boom)
|
||||
|
||||
assert backend._lock is live, "a call that merely failed must not abandon its lock"
|
||||
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
|
||||
|
||||
|
||||
async def test_errors_release_the_lock(backend, monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5)
|
||||
|
||||
def _boom():
|
||||
raise ValueError("nope")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await backend._async(_boom)
|
||||
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
|
||||
|
||||
|
||||
# ── T0.3 — audit reports what it repaired, and keeps it ─────────────────────
|
||||
|
||||
|
||||
def _write_damaged_dxf(path) -> None:
|
||||
"""A DXF referencing a linetype and a text style that were never defined.
|
||||
|
||||
Both are defects ezdxf's ``Auditor`` *repairs* — UNDEFINED_LINETYPE (100)
|
||||
and UNDEFINED_TEXT_STYLE (102) — by discarding the dangling attribute. The
|
||||
names are chosen to survive as literal bytes in the saved file so a test can
|
||||
prove the repair reached disk.
|
||||
"""
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.new("R2010")
|
||||
msp = doc.modelspace()
|
||||
msp.add_line((0, 0), (10, 0), dxfattribs={"linetype": "GHOSTLINE"})
|
||||
msp.add_text("hello", dxfattribs={"style": "NOSUCHSTYLE"})
|
||||
doc.saveas(str(path))
|
||||
|
||||
|
||||
def _write_block_cycle_dxf(path) -> None:
|
||||
"""Two blocks that insert each other — a defect the Auditor CANNOT repair.
|
||||
|
||||
This is the only way to get entries into ``auditor.errors`` (as opposed to
|
||||
``auditor.fixes``), which is what makes the error-rendering assertion
|
||||
possible at all.
|
||||
"""
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.new("R2010")
|
||||
block_a = doc.blocks.new("BLK_A")
|
||||
block_b = doc.blocks.new("BLK_B")
|
||||
block_a.add_blockref("BLK_B", (0, 0))
|
||||
block_b.add_blockref("BLK_A", (0, 0))
|
||||
doc.modelspace().add_blockref("BLK_A", (0, 0))
|
||||
doc.saveas(str(path))
|
||||
|
||||
|
||||
async def test_audit_reports_the_repairs_it_applied(backend, tmp_path):
|
||||
"""``doc.audit()`` mutates; a tool that hides those mutations is lying."""
|
||||
target = tmp_path / "damaged.dxf"
|
||||
_write_damaged_dxf(target)
|
||||
await backend.drawing_open(str(target))
|
||||
|
||||
result = await backend.drawing_audit()
|
||||
|
||||
assert result["repaired"] is True
|
||||
assert result["fix_count"] == 2
|
||||
assert {fix["code"] for fix in result["fixes"]} == {100, 102}
|
||||
for fix in result["fixes"]:
|
||||
assert isinstance(fix["message"], str) and fix["message"]
|
||||
assert fix["name"]
|
||||
|
||||
|
||||
async def test_audit_marks_the_document_dirty_when_it_repaired(backend, tmp_path):
|
||||
"""The load-bearing half: a reported repair that close() throws away is worse.
|
||||
|
||||
``drawing_close`` saves only ``if save and self._dirty``. Reporting the fixes
|
||||
without marking the document dirty would promise three repairs and then lose
|
||||
every one of them.
|
||||
"""
|
||||
target = tmp_path / "damaged.dxf"
|
||||
_write_damaged_dxf(target)
|
||||
before = target.read_bytes()
|
||||
assert b"GHOSTLINE" in before and b"NOSUCHSTYLE" in before
|
||||
|
||||
await backend.drawing_open(str(target))
|
||||
await backend.drawing_audit()
|
||||
|
||||
info = await backend.drawing_info()
|
||||
assert info.saved is False, "audit mutated the document; it is no longer 'saved'"
|
||||
|
||||
await backend.drawing_close(save=True)
|
||||
after = target.read_bytes()
|
||||
assert after != before, "the repair must reach disk, not be discarded on close"
|
||||
assert b"GHOSTLINE" not in after
|
||||
assert b"NOSUCHSTYLE" not in after
|
||||
|
||||
|
||||
async def test_audit_clean_document_reports_no_repair(backend):
|
||||
"""The opposite direction of the same lie: never claim a repair not made."""
|
||||
result = await backend.drawing_audit()
|
||||
assert result["repaired"] is False
|
||||
assert result["fix_count"] == 0
|
||||
assert result["fixes"] == []
|
||||
assert (await backend.drawing_info()).saved is True
|
||||
|
||||
|
||||
async def test_audit_errors_are_readable_messages_not_object_reprs(backend, tmp_path):
|
||||
"""``ErrorEntry`` has no ``__str__``; ``str(e)`` yielded a memory address."""
|
||||
target = tmp_path / "cycle.dxf"
|
||||
_write_block_cycle_dxf(target)
|
||||
await backend.drawing_open(str(target))
|
||||
|
||||
result = await backend.drawing_audit()
|
||||
|
||||
assert result["error_count"] >= 2
|
||||
for err in result["errors"]:
|
||||
assert isinstance(err, dict)
|
||||
assert err["code"] == 104
|
||||
assert "block reference cycle" in err["message"].lower()
|
||||
assert "ErrorEntry object at" not in err["message"]
|
||||
|
||||
|
||||
async def test_audit_detail_capability_is_declared_per_backend():
|
||||
"""A client must be able to tell "nothing was wrong" from "we cannot see"."""
|
||||
assert EzdxfBackend().capabilities().features["audit_detail"].supported is True
|
||||
|
||||
com_audit = ComBackend().capabilities().features["audit_detail"]
|
||||
assert com_audit.supported is False
|
||||
assert com_audit.reason == "audit_result_not_readable_over_com"
|
||||
|
||||
|
||||
async def test_audit_discloses_an_entity_it_deleted(backend):
|
||||
"""The most destructive repair is the one that names no handle.
|
||||
|
||||
``Auditor.check_owner_exist`` *deletes* an entity whose owner handle points
|
||||
nowhere, and calls ``fixed_error`` without ``dxf_entity=`` — so ``handle`` is
|
||||
``None`` for exactly the fix a user most needs to know about. Before the
|
||||
dirty-flag fix the missing ``_mark_dirty()`` accidentally shielded people
|
||||
from this; now the deletion reaches disk on the next save, so the report has
|
||||
to carry it.
|
||||
"""
|
||||
await backend.entity_create_line(0, 0, 5, 5)
|
||||
await backend.entity_create_circle(1, 1, 2)
|
||||
doomed = next(iter(backend._doc.modelspace()))
|
||||
doomed.dxf.owner = "DEAD" # the damage a truncated write leaves behind
|
||||
|
||||
result = await backend.drawing_audit()
|
||||
|
||||
assert result["repaired"] is True
|
||||
deletions = [fix for fix in result["fixes"] if "Deleted" in fix["message"]]
|
||||
assert deletions, f"a deletion must be reported, got {result['fixes']}"
|
||||
assert "LINE" in deletions[0]["message"]
|
||||
assert deletions[0]["handle"] is None, (
|
||||
"ezdxf reports this fix without the entity, so the handle is genuinely "
|
||||
"unknown — inventing one would be worse than admitting it"
|
||||
)
|
||||
assert len(list(backend._doc.modelspace())) == 1, "the entity really is gone"
|
||||
assert (await backend.drawing_info()).saved is False
|
||||
|
||||
|
||||
async def test_audit_entry_names_an_unknown_code_honestly():
|
||||
"""A code outside AuditError must not be dressed up as a name."""
|
||||
from backends.ezdxf_backend import _audit_entry
|
||||
|
||||
entry = types.SimpleNamespace(code=999, message="something new", entity=None)
|
||||
assert _audit_entry(entry) == {
|
||||
"code": 999,
|
||||
"name": "UNKNOWN",
|
||||
"message": "something new",
|
||||
"handle": None,
|
||||
}
|
||||
+211
-2
@@ -65,8 +65,10 @@ def test_parse_fit_code():
|
||||
@pytest.mark.parametrize(
|
||||
"code,nominal",
|
||||
[
|
||||
("K7", 20.0), # transition hole letters need the delta-rule — excluded
|
||||
("P7", 20.0),
|
||||
# K7/P7 used to be here: the delta rule derives them from tables already
|
||||
# in the module, so they are authored as of 1.5.0 and pinned against
|
||||
# published values above.
|
||||
("t6", 20.0), # ISO 286 does not define shaft t below 24 mm
|
||||
("x6", 20.0), # letter outside the authored subset (regex rejects)
|
||||
("H3", 20.0), # grade below authored range
|
||||
("H12", 20.0), # grade above authored range
|
||||
@@ -116,3 +118,210 @@ def test_fit_passthrough_without_code():
|
||||
"deviation",
|
||||
"x",
|
||||
)
|
||||
|
||||
|
||||
# ── ISO 286 transition / interference HOLES (K, M, N, P) ────────────────────
|
||||
#
|
||||
# These were refused by name until 1.5.0. They are not new table data: ISO 286
|
||||
# derives an upper-letter hole from the same-letter shaft by the delta rule,
|
||||
#
|
||||
# ES = -ei + Δ, Δ = IT(n) − IT(n−1)
|
||||
#
|
||||
# so every value below comes from tables already authored in this module. The
|
||||
# expected numbers are the published ISO 286 ones, cross-checked by hand — the
|
||||
# point of pinning them is that the *rule* is right, not that the arithmetic runs.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "nominal", "upper_um", "lower_um"),
|
||||
[
|
||||
("K7", 20.0, 6, -15),
|
||||
("M7", 20.0, 0, -21),
|
||||
("N7", 20.0, -7, -28),
|
||||
("P7", 20.0, -14, -35),
|
||||
("K7", 50.0, 7, -18),
|
||||
("N7", 50.0, -8, -33),
|
||||
("P7", 50.0, -17, -42),
|
||||
("K6", 20.0, 2, -11),
|
||||
("N6", 20.0, -11, -24),
|
||||
("P6", 20.0, -18, -31),
|
||||
],
|
||||
)
|
||||
def test_transition_hole_letters_match_published_iso286(code, nominal, upper_um, lower_um):
|
||||
fit = fit_lookup(code, nominal)
|
||||
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
|
||||
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_h7_over_p6_is_a_genuine_interference_fit():
|
||||
"""The pairing check the tables exist for: the shaft must be bigger."""
|
||||
hole = fit_lookup("H7", 20.0)
|
||||
shaft = fit_lookup("p6", 20.0)
|
||||
assert shaft.lower_mm > hole.upper_mm - hole.it_value_mm, "min shaft > min hole"
|
||||
assert shaft.lower_mm > 0 and hole.lower_mm == 0
|
||||
|
||||
|
||||
def test_the_delta_collapses_above_the_grade_the_rule_covers():
|
||||
"""ISO 286 applies Δ only to IT<=8 for K/M/N and IT<=7 for P; then ES = -ei."""
|
||||
from engineering.fits import _SHAFT_EI, _size_index
|
||||
|
||||
index = _size_index(20.0)
|
||||
n9 = fit_lookup("N9", 20.0)
|
||||
assert n9.upper_mm == pytest.approx(-_SHAFT_EI["n"][index] / 1000.0, abs=1e-9)
|
||||
|
||||
p8 = fit_lookup("P8", 20.0)
|
||||
assert p8.upper_mm == pytest.approx(-_SHAFT_EI["p"][index] / 1000.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_grade_4_is_refused_rather_than_guessed():
|
||||
"""Δ needs IT3, which is outside the authored IT4-IT11 range."""
|
||||
with pytest.raises(ValueError, match="IT5"):
|
||||
fit_lookup("K4", 20.0)
|
||||
|
||||
|
||||
# ── ISO 286 interference SHAFTS (r, s, t, u) ────────────────────────────────
|
||||
#
|
||||
# These need their own fundamental deviations, sub-stepped more finely than the
|
||||
# main size steps above 50 mm, so unlike K/M/N/P they are table data rather than
|
||||
# a rule. Authored table data is only as good as its provenance, so every value
|
||||
# is cross-checked below against ISO 286-1's own derivation formulae — an
|
||||
# independent source that agrees or does not. What survives is stated; what
|
||||
# diverges is listed by name, so a reader with the standard checks five numbers
|
||||
# instead of ninety-five.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "nominal", "upper_um", "lower_um"),
|
||||
[
|
||||
# ei + IT above the nominal — these are all interference shafts.
|
||||
("r6", 20.0, 41, 28),
|
||||
("s6", 20.0, 48, 35),
|
||||
("u6", 20.0, 54, 41),
|
||||
("s7", 50.0, 68, 43),
|
||||
("u6", 30.0, 61, 48), # 24-30 sub-step: u jumps 41 -> 48 inside 18-30
|
||||
("s6", 60.0, 72, 53), # 50-65 sub-step, IT6 from the main 50-80 step
|
||||
("t6", 60.0, 85, 66),
|
||||
],
|
||||
)
|
||||
def test_interference_shafts_match_published_iso286(code, nominal, upper_um, lower_um):
|
||||
fit = fit_lookup(code, nominal)
|
||||
assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9)
|
||||
assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_the_sub_steps_are_real_and_not_flattened():
|
||||
"""Above 50 mm these letters split the main steps; collapsing them is a
|
||||
silent error of tens of microns."""
|
||||
assert fit_lookup("u6", 55.0).lower_mm != fit_lookup("u6", 70.0).lower_mm
|
||||
assert fit_lookup("u6", 20.0).lower_mm != fit_lookup("u6", 28.0).lower_mm
|
||||
|
||||
|
||||
def test_t_is_refused_below_24_mm_because_iso_does_not_define_it():
|
||||
with pytest.raises(ValueError, match="24"):
|
||||
fit_lookup("t6", 20.0)
|
||||
assert fit_lookup("t6", 30.0).lower_mm > 0
|
||||
|
||||
|
||||
# -- provenance: the table against ISO 286-1's own formulae ------------------
|
||||
|
||||
|
||||
def test_r_is_the_geometric_mean_of_p_and_s():
|
||||
"""ISO 286 *defines* r that way, and p is already pinned in this module.
|
||||
|
||||
24/24 sub-steps agree within 1 um, which validates r and s together against
|
||||
data that was trusted before either was authored.
|
||||
"""
|
||||
import math
|
||||
|
||||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI, _SHAFT_EI_FINE, _size_index
|
||||
|
||||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||||
mid = (step[0] + step[1]) / 2.0
|
||||
p = _SHAFT_EI["p"][_size_index(mid)]
|
||||
s = _SHAFT_EI_FINE["s"][index]
|
||||
r = _SHAFT_EI_FINE["r"][index]
|
||||
assert abs(round(math.sqrt(p * s)) - r) <= 1, f"r at {step}: table {r}, sqrt(p*s) {p}/{s}"
|
||||
|
||||
|
||||
#: Steps where ISO 286's rounding rules put the published value more than 1 um
|
||||
#: from the formula. Listed rather than hidden: these are the only values a
|
||||
#: reader with the standard needs to check by hand.
|
||||
KNOWN_FORMULA_DIVERGENCES = {
|
||||
("u", (250, 280)),
|
||||
("u", (400, 450)),
|
||||
("u", (450, 500)),
|
||||
("t", (24, 30)),
|
||||
("t", (450, 500)),
|
||||
}
|
||||
|
||||
|
||||
#: Below this the formulae do not describe the published tables at all — ISO
|
||||
#: tabulates the small sizes directly. Measured for u: the formula is 6 um low at
|
||||
#: 1-3 mm and only converges from 18 mm up (-6, -7, -5, -2, +1). Scoping the
|
||||
#: check here is a statement about where the cross-check has authority, not a
|
||||
#: threshold moved until the test passed.
|
||||
FORMULA_VALID_FROM_MM = 18
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("letter", "coefficient"), [("t", 0.63), ("u", 1.0)])
|
||||
def test_t_and_u_track_their_iso_formula(letter, coefficient):
|
||||
"""ISO 286-1: ei = IT7 + k*D, D the geometric mean of the step bounds.
|
||||
|
||||
The standard rounds its published tables, so exact equality is not the
|
||||
claim — agreement within 1 um everywhere except a listed handful is.
|
||||
"""
|
||||
import math
|
||||
|
||||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
|
||||
|
||||
checked = 0
|
||||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||||
ei = _SHAFT_EI_FINE[letter][index]
|
||||
if ei is None or step[0] < FORMULA_VALID_FROM_MM:
|
||||
continue
|
||||
checked += 1
|
||||
mid = (step[0] + step[1]) / 2.0
|
||||
it7 = _it_um(7, _size_index(mid))
|
||||
formula = it7 + coefficient * math.sqrt(step[0] * step[1])
|
||||
drift = abs(round(formula) - ei)
|
||||
if (letter, step) in KNOWN_FORMULA_DIVERGENCES:
|
||||
assert 1 < drift <= 3, f"{letter} at {step} drifted {drift} um — re-check the table"
|
||||
else:
|
||||
assert drift <= 1, f"{letter} at {step}: table {ei}, formula {formula:.1f}"
|
||||
assert checked >= 18, f"only {checked} steps cross-checked for {letter}"
|
||||
|
||||
|
||||
def test_s_above_50_tracks_its_iso_formula():
|
||||
import math
|
||||
|
||||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
|
||||
|
||||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||||
if step[0] < 50:
|
||||
continue
|
||||
mid = (step[0] + step[1]) / 2.0
|
||||
formula = _it_um(7, _size_index(mid)) + 0.4 * math.sqrt(step[0] * step[1])
|
||||
assert abs(round(formula) - _SHAFT_EI_FINE["s"][index]) <= 1, f"s at {step}"
|
||||
|
||||
|
||||
def test_s_below_50_sits_in_the_iso_band_above_it8():
|
||||
"""ISO 286: for D <= 50, s is IT8 plus a small step. Monotone, 0..4 um."""
|
||||
from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index
|
||||
|
||||
deltas = []
|
||||
for index, step in enumerate(_FINE_SIZE_STEPS):
|
||||
if step[0] >= 50:
|
||||
break
|
||||
mid = (step[0] + step[1]) / 2.0
|
||||
deltas.append(_SHAFT_EI_FINE["s"][index] - _it_um(8, _size_index(mid)))
|
||||
assert all(0 <= d <= 4 for d in deltas), deltas
|
||||
assert deltas == sorted(deltas), f"the step above IT8 must not decrease: {deltas}"
|
||||
|
||||
|
||||
def test_h7_over_s6_is_tighter_than_h7_over_p6():
|
||||
"""The reason these letters exist: progressively heavier interference."""
|
||||
p6 = fit_lookup("p6", 20.0).lower_mm
|
||||
r6 = fit_lookup("r6", 20.0).lower_mm
|
||||
s6 = fit_lookup("s6", 20.0).lower_mm
|
||||
u6 = fit_lookup("u6", 20.0).lower_mm
|
||||
assert p6 < r6 < s6 < u6
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""F4 — hatch depth: gradient fill, in-place edit, curved boundary edges.
|
||||
|
||||
`entity_create_hatch` can do exactly one thing: stamp a pattern fill into a
|
||||
closed *polyline* boundary. Everything a section drawing actually needs after
|
||||
that — restyling the fill, changing the island rule, bounding the fill with an
|
||||
arc — has no tool. These three fill that gap, and each carries a measured trap:
|
||||
|
||||
* **Gradient fill is a replacement, not a layer.** `set_gradient()` flips
|
||||
`solid_fill` to 1, rewrites `pattern_name` to `SOLID` and drops `.pattern` to
|
||||
``None``. A tool that reported "gradient applied" while leaving the caller
|
||||
believing the ANSI31 hatching is still underneath would be describing a
|
||||
drawing that does not exist. Every gradient assertion here goes through
|
||||
saveas/readfile, because gradient data lives in its own DXF subrecord and a
|
||||
gradient that vanishes on save is the classic failure mode.
|
||||
|
||||
* **`pattern_scale` is two numbers, not one.** ezdxf stores the scale twice:
|
||||
as `dxf.pattern_scale` *and* baked into the pattern definition lines' offset
|
||||
vectors. Setting only the DXF attribute is the perfect silent no-op — every
|
||||
report says scale 4.0, every renderer draws scale 2.0. So the scale and angle
|
||||
tests measure the pattern line *pitch* and *angle*, not the attribute.
|
||||
Measured here: ANSI31 at scale 1.0 has a 3.175 mm pitch; scale 2.0 is 6.35.
|
||||
ezdxf's `set_pattern_scale`/`set_pattern_angle` are absolute, not relative.
|
||||
|
||||
* **A partial edit must stay partial.** `hatch_edit` takes five optional
|
||||
parameters; the data-loss bug is the implementation that rebuilds the hatch
|
||||
from defaults and quietly resets the four the caller did not name. Note that
|
||||
a hatch born from `entity_create_hatch` already has `hatch_style == 1`
|
||||
(OUTER), because that is ezdxf's `set_pattern_fill` default — so "normal" is
|
||||
a real change, not a no-op, and `changed` has to distinguish a parameter that
|
||||
was *supplied* from an attribute that actually *moved*.
|
||||
|
||||
* **Flat vertex lists straighten curves.** This is the fidelity point of
|
||||
`hatch_add_boundary`. A hatch boundary API that only accepts points turns
|
||||
every fillet and bore into a chord and still reports a boundary. So the arc
|
||||
test asserts the reloaded path is an `EdgePath` holding an `ArcEdge` with its
|
||||
radius and sweep intact — a flattened arc would come back as a `PolylinePath`
|
||||
and pass any "did a path appear?" check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
SQUARE = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]]
|
||||
|
||||
RED = [255, 0, 0]
|
||||
BLUE = [0, 0, 255]
|
||||
|
||||
|
||||
def _entity(backend, handle):
|
||||
return backend._doc.entitydb.get(handle)
|
||||
|
||||
|
||||
async def _reloaded(backend, tmp_path, handle, name="hatch.dxf"):
|
||||
"""Round-trip the whole document and hand back the same entity."""
|
||||
import ezdxf
|
||||
|
||||
path = tmp_path / name
|
||||
await backend.drawing_save_as(str(path))
|
||||
return ezdxf.readfile(str(path)).entitydb.get(handle)
|
||||
|
||||
|
||||
def _pattern_pitch(hatch):
|
||||
"""Distance between the hatch pattern's definition lines, in drawing units."""
|
||||
offset = hatch.pattern.lines[0].offset
|
||||
return math.hypot(offset[0], offset[1])
|
||||
|
||||
|
||||
def _path_types(hatch):
|
||||
return [type(path).__name__ for path in hatch.paths]
|
||||
|
||||
|
||||
# ── gradient fill ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_gradient_survives_save_and_reload(backend, tmp_path):
|
||||
"""Gradient data lives in its own subrecord; losing it on save is silent."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_set_gradient(
|
||||
created.handle, RED, BLUE, rotation=30.0, tint=0.4, name="CURVED"
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["gradient"]["name"] == "CURVED"
|
||||
assert list(result["gradient"]["color1"]) == RED
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
gradient = reloaded.gradient
|
||||
assert gradient is not None, "the gradient did not survive the round-trip"
|
||||
assert tuple(gradient.color1) == (255, 0, 0)
|
||||
assert tuple(gradient.color2) == (0, 0, 255)
|
||||
assert gradient.rotation == pytest.approx(30.0)
|
||||
assert gradient.tint == pytest.approx(0.4)
|
||||
assert gradient.name == "CURVED"
|
||||
|
||||
|
||||
async def test_a_gradient_replaces_the_pattern_fill_it_lands_on(backend, tmp_path):
|
||||
"""A hatch is gradient-filled *or* pattern-filled. Never both."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
|
||||
assert _entity(backend, created.handle).dxf.pattern_name == "ANSI31"
|
||||
|
||||
await backend.hatch_set_gradient(created.handle, RED, BLUE)
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
assert reloaded.pattern is None, "the ANSI31 line definition is still there"
|
||||
assert reloaded.dxf.solid_fill == 1
|
||||
assert reloaded.dxf.pattern_name == "SOLID"
|
||||
|
||||
|
||||
async def test_a_gradient_keeps_the_boundary_it_fills(backend, tmp_path):
|
||||
"""A gradient with no boundary paints nothing at all."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
await backend.hatch_set_gradient(created.handle, RED, BLUE)
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
assert _path_types(reloaded) == ["PolylinePath"]
|
||||
assert len(reloaded.paths[0].vertices) == len(SQUARE)
|
||||
|
||||
|
||||
async def test_a_one_colour_gradient_stores_its_tint(backend, tmp_path):
|
||||
"""one_color+tint is a distinct DXF mode; dropping the tint flattens it."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_set_gradient(
|
||||
created.handle, [10, 20, 30], [0, 0, 0], one_color=True, tint=0.75, name="SPHERICAL"
|
||||
)
|
||||
assert result["ok"] is True
|
||||
|
||||
gradient = (await _reloaded(backend, tmp_path, created.handle)).gradient
|
||||
assert int(gradient.one_color) == 1
|
||||
assert gradient.tint == pytest.approx(0.75)
|
||||
assert gradient.name == "SPHERICAL"
|
||||
|
||||
|
||||
async def test_a_gradient_on_a_line_is_refused_naming_the_type(backend):
|
||||
"""Only a HATCH carries gradient data; a LINE would swallow it silently."""
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
|
||||
result = await backend.hatch_set_gradient(line.handle, RED, BLUE)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "LINE" in result["error"]
|
||||
assert "HATCH" in result["error"]
|
||||
|
||||
|
||||
# ── hatch edit ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("style, expected", [("normal", 0), ("outer", 1), ("ignore", 2)])
|
||||
async def test_each_island_style_maps_to_its_dxf_value(backend, tmp_path, style, expected):
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
# Start from a different style so every case is a real move, not a no-op.
|
||||
_entity(backend, created.handle).dxf.hatch_style = (expected + 1) % 3
|
||||
|
||||
result = await backend.hatch_edit(created.handle, style=style)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert "style" in result["changed"]
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle, f"style-{style}.dxf")
|
||||
assert reloaded.dxf.hatch_style == expected
|
||||
|
||||
|
||||
async def test_editing_the_scale_leaves_every_other_attribute_alone(backend):
|
||||
"""A partial edit that resets what it was not asked about is data loss."""
|
||||
created = await backend.entity_create_hatch(
|
||||
"ANSI31", SQUARE, scale=2.0, angle=15.0, layer="HATCH", color=3
|
||||
)
|
||||
style_before = _entity(backend, created.handle).dxf.hatch_style
|
||||
|
||||
await backend.hatch_edit(created.handle, scale=4.0)
|
||||
|
||||
hatch = _entity(backend, created.handle)
|
||||
assert hatch.dxf.pattern_name == "ANSI31"
|
||||
assert hatch.dxf.pattern_angle == pytest.approx(15.0)
|
||||
assert hatch.dxf.color == 3
|
||||
assert hatch.dxf.layer == "HATCH"
|
||||
assert hatch.dxf.hatch_style == style_before
|
||||
assert len(hatch.paths) == 1
|
||||
|
||||
|
||||
async def test_a_new_scale_rescales_the_pattern_definition(backend, tmp_path):
|
||||
"""Setting only dxf.pattern_scale is the perfect silent no-op."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
|
||||
assert _pattern_pitch(_entity(backend, created.handle)) == pytest.approx(6.35)
|
||||
|
||||
await backend.hatch_edit(created.handle, scale=4.0)
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
assert reloaded.dxf.pattern_scale == pytest.approx(4.0)
|
||||
assert _pattern_pitch(reloaded) == pytest.approx(12.7), "the lines did not move apart"
|
||||
|
||||
|
||||
async def test_a_new_angle_rotates_the_pattern_definition(backend, tmp_path):
|
||||
"""Same trap as scale: the angle is stored twice and must move twice."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE, angle=15.0)
|
||||
assert _entity(backend, created.handle).pattern.lines[0].angle == pytest.approx(60.0)
|
||||
|
||||
await backend.hatch_edit(created.handle, angle=30.0)
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
assert reloaded.dxf.pattern_angle == pytest.approx(30.0)
|
||||
assert reloaded.pattern.lines[0].angle == pytest.approx(75.0), "the lines did not rotate"
|
||||
|
||||
|
||||
async def test_a_new_pattern_and_colour_round_trip(backend, tmp_path):
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, color=3)
|
||||
|
||||
result = await backend.hatch_edit(created.handle, pattern="ANSI32", color=5)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert set(result["changed"]) == {"pattern", "color"}
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
assert reloaded.dxf.pattern_name == "ANSI32"
|
||||
assert reloaded.dxf.color == 5
|
||||
assert reloaded.dxf.pattern_scale == pytest.approx(2.0), "the scale was reset"
|
||||
|
||||
|
||||
async def test_changed_lists_only_the_attributes_that_moved(backend):
|
||||
""" "I was asked to set it" and "it is now different" are different answers."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, angle=15.0)
|
||||
|
||||
result = await backend.hatch_edit(created.handle, scale=4.0, angle=15.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert set(result["changed"]) == {"scale"}
|
||||
|
||||
|
||||
async def test_an_edit_that_moves_nothing_reports_nothing(backend):
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
|
||||
|
||||
result = await backend.hatch_edit(created.handle, scale=2.0)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["changed"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0.0, -1.0])
|
||||
async def test_a_non_positive_pattern_scale_is_refused(backend, bad):
|
||||
"""Scale 0 collapses the definition offsets to zero: an infinite line count."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_edit(created.handle, scale=bad)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "scale" in result["error"]
|
||||
|
||||
|
||||
async def test_an_unknown_island_style_is_refused_naming_the_valid_ones(backend):
|
||||
"""Silently falling back to `normal` would fill islands nobody asked to fill."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_edit(created.handle, style="outermost")
|
||||
|
||||
assert result["ok"] is False
|
||||
for valid in ("normal", "outer", "ignore"):
|
||||
assert valid in result["error"]
|
||||
|
||||
|
||||
# ── boundary edges ──────────────────────────────────────────────────────────
|
||||
|
||||
ARC_EDGE = {
|
||||
"type": "arc",
|
||||
"center": [110.0, 0.0],
|
||||
"radius": 10.0,
|
||||
"start_angle": 0.0,
|
||||
"end_angle": 180.0,
|
||||
"ccw": True,
|
||||
}
|
||||
LINE_EDGE = {"type": "line", "start": [100.0, 0.0], "end": [120.0, 0.0]}
|
||||
|
||||
|
||||
async def test_an_arc_boundary_edge_survives_as_an_arc(backend, tmp_path):
|
||||
"""The fidelity point: a flattened arc comes back as a PolylinePath."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
|
||||
assert result["ok"] is True
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
assert _path_types(reloaded) == ["PolylinePath", "EdgePath"]
|
||||
edges = reloaded.paths[1].edges
|
||||
assert [type(edge).__name__ for edge in edges] == ["LineEdge", "ArcEdge"]
|
||||
arc = edges[1]
|
||||
assert arc.radius == pytest.approx(10.0)
|
||||
assert tuple(arc.center)[:2] == pytest.approx((110.0, 0.0))
|
||||
assert arc.start_angle == pytest.approx(0.0)
|
||||
assert arc.end_angle == pytest.approx(180.0)
|
||||
assert arc.ccw is True
|
||||
|
||||
|
||||
async def test_the_added_path_is_counted_alongside_the_existing_one(backend):
|
||||
"""path_count is the hatch's total, so the original boundary is still there."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
|
||||
|
||||
assert result["handle"] == created.handle
|
||||
assert result["path_count"] == 2
|
||||
assert result["edge_types"] == ["line", "arc"]
|
||||
|
||||
|
||||
async def test_an_ellipse_boundary_edge_keeps_its_ratio(backend, tmp_path):
|
||||
"""The minor/major ratio *is* the ellipse; drop it and you have a circle."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
edge = {
|
||||
"type": "ellipse",
|
||||
"center": [200.0, 100.0],
|
||||
"major_axis": [30.0, 0.0],
|
||||
"ratio": 0.4,
|
||||
"start_angle": 0.0,
|
||||
"end_angle": 360.0,
|
||||
"ccw": True,
|
||||
}
|
||||
result = await backend.hatch_add_boundary(created.handle, [edge])
|
||||
assert result["edge_types"] == ["ellipse"]
|
||||
|
||||
reloaded = await _reloaded(backend, tmp_path, created.handle)
|
||||
stored = reloaded.paths[1].edges[0]
|
||||
assert type(stored).__name__ == "EllipseEdge"
|
||||
assert stored.ratio == pytest.approx(0.4)
|
||||
assert tuple(stored.major_axis)[:2] == pytest.approx((30.0, 0.0))
|
||||
assert tuple(stored.center)[:2] == pytest.approx((200.0, 100.0))
|
||||
|
||||
|
||||
async def test_an_unknown_edge_type_is_refused_naming_the_valid_ones(backend):
|
||||
"""Skipping the edge it cannot build would leave an open boundary."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_add_boundary(
|
||||
created.handle, [{"type": "bezier", "start": [0, 0], "end": [1, 1]}]
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "bezier" in result["error"]
|
||||
for valid in ("line", "arc", "ellipse"):
|
||||
assert valid in result["error"]
|
||||
|
||||
|
||||
async def test_an_edge_missing_a_key_is_refused_naming_the_key(backend):
|
||||
"""An arc defaulted to radius 0 is a boundary that encloses nothing."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_add_boundary(
|
||||
created.handle,
|
||||
[{"type": "arc", "center": [0.0, 0.0], "start_angle": 0.0, "end_angle": 90.0}],
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "radius" in result["error"]
|
||||
|
||||
|
||||
async def test_a_boundary_with_no_edges_is_refused(backend):
|
||||
"""An empty edge path bounds nothing and renders as a hatch with a hole."""
|
||||
created = await backend.entity_create_hatch("ANSI31", SQUARE)
|
||||
|
||||
result = await backend.hatch_add_boundary(created.handle, [])
|
||||
|
||||
assert result["ok"] is False
|
||||
@@ -0,0 +1,392 @@
|
||||
"""M6 — deleting, renaming and copying sheet tabs.
|
||||
|
||||
`layout_list/create/set_current` shipped in v1.4; the rest of the tab lifecycle
|
||||
did not, so a sheet could be made and never unmade. What makes this more than
|
||||
three thin wrappers is that ezdxf's own guards stop well short of where a tool
|
||||
boundary has to stop, and the gaps are all silent:
|
||||
|
||||
* `doc.layouts.delete("")` does not refuse — it deletes the *first* paper-space
|
||||
layout and reports nothing unusual, and `doc.layouts.get("")` likewise
|
||||
resolves to the active paper space. A caller who passed an empty string by
|
||||
accident loses a sheet.
|
||||
* `doc.layouts.rename()` skips the `is_valid_table_name` check that `new()`
|
||||
performs, so `Bad/Name` and even `""` are accepted and the file still audits
|
||||
clean.
|
||||
* Deleting or renaming the *current* layout leaves `self._current_space`
|
||||
pointing at a name that no longer exists. `_msp()` then silently falls back to
|
||||
model space while `layout_list()` still reports the dead name — so
|
||||
`entity_create_*` lands somewhere the caller was never told about.
|
||||
* The entities on a deleted layout are destroyed but stay in `entitydb`, where
|
||||
`_get_entity`'s `is None` check does not see them. Fetching a handle from a
|
||||
deleted sheet used to raise `AttributeError: 'Line' object has no attribute
|
||||
'dxf'` instead of a refusal.
|
||||
|
||||
ezdxf has no layout-copy API at all (`xref.load_paperspace` is cross-document
|
||||
only), so `layout_copy` is a construction — and everything a construction can
|
||||
get wrong here is invisible to the Auditor, which reports zero errors on a
|
||||
duplicated tab order, a duplicated main viewport and a hatch whose boundary
|
||||
still points into the layout it was copied from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
SHEET = "A3-Sheet"
|
||||
OTHER = "A4-Sheet"
|
||||
|
||||
|
||||
def _names(backend) -> list[str]:
|
||||
return list(backend._doc.layouts.names_in_taborder())
|
||||
|
||||
|
||||
def _entities_in(backend, layout_name: str) -> list[str]:
|
||||
space = backend._doc.layouts.get(layout_name)
|
||||
return sorted(e.dxftype() for e in space)
|
||||
|
||||
|
||||
# ── layout_delete ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_deleting_a_layout_removes_the_tab_and_its_geometry(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
|
||||
result = await backend.layout_delete(SHEET)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["deleted"] == SHEET
|
||||
assert result["entities_destroyed"] == 1
|
||||
assert SHEET not in _names(backend)
|
||||
|
||||
|
||||
async def test_deleting_model_space_is_refused(backend):
|
||||
result = await backend.layout_delete("Model")
|
||||
assert result["ok"] is False
|
||||
assert "Model" in result["error"]
|
||||
assert "Model" in _names(backend)
|
||||
|
||||
|
||||
async def test_a_blank_name_does_not_delete_the_first_sheet_it_finds(backend):
|
||||
"""ezdxf's own delete("") destroys Layout1 and says nothing."""
|
||||
before = _names(backend)
|
||||
for blank in ("", " "):
|
||||
result = await backend.layout_delete(blank)
|
||||
assert result["ok"] is False, f"delete({blank!r}) must refuse"
|
||||
assert _names(backend) == before
|
||||
|
||||
|
||||
async def test_deleting_a_missing_layout_refuses_with_a_readable_error(backend):
|
||||
"""ezdxf raises a bare KeyError here; the tool must not leak that."""
|
||||
result = await backend.layout_delete("NOSUCH")
|
||||
assert result["ok"] is False
|
||||
assert "NOSUCH" in result["error"]
|
||||
|
||||
|
||||
async def test_deleting_the_last_paper_space_layout_refuses(backend):
|
||||
"""A DXF document must keep at least one paper-space layout."""
|
||||
for name in _names(backend):
|
||||
if name != "Model" and name != "Layout1":
|
||||
await backend.layout_delete(name)
|
||||
remaining = [n for n in _names(backend) if n != "Model"]
|
||||
assert len(remaining) == 1, "fixture assumption: exactly one sheet left"
|
||||
|
||||
result = await backend.layout_delete(remaining[0])
|
||||
assert result["ok"] is False
|
||||
assert remaining[0] in _names(backend)
|
||||
|
||||
|
||||
async def test_deleting_the_current_layout_leaves_every_report_agreeing(backend):
|
||||
"""The three-way disagreement this test exists to prevent.
|
||||
|
||||
Before the fix: ``layout_list`` said the deleted sheet, ``$TILEMODE`` said
|
||||
paper space, and ``_msp()`` quietly wrote to model space. Whatever the tool
|
||||
decides the new current space is, all three must say the same thing and the
|
||||
next entity must actually land there.
|
||||
"""
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
|
||||
result = await backend.layout_delete(SHEET)
|
||||
assert result["ok"] is True
|
||||
|
||||
current = (await backend.layout_list())["current"]
|
||||
assert current == result["current"]
|
||||
assert current in _names(backend)
|
||||
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
landed = backend._doc.modelspace() if current == "Model" else backend._doc.layouts.get(current)
|
||||
assert line.handle in {e.dxf.handle for e in landed}
|
||||
|
||||
|
||||
async def test_a_handle_from_a_deleted_layout_refuses_instead_of_crashing(backend):
|
||||
"""The destroyed entity stays in entitydb; `is None` does not catch it."""
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
await backend.layout_delete(SHEET)
|
||||
|
||||
with pytest.raises(RuntimeError, match=line.handle):
|
||||
await backend.entity_get(line.handle)
|
||||
|
||||
|
||||
# ── layout_rename ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_renaming_keeps_the_entities_and_their_handles(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
|
||||
result = await backend.layout_rename(SHEET, OTHER)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert OTHER in _names(backend) and SHEET not in _names(backend)
|
||||
info = await backend.entity_get(line.handle)
|
||||
assert info.handle == line.handle
|
||||
|
||||
|
||||
async def test_renaming_the_current_layout_keeps_the_caller_on_it(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
|
||||
await backend.layout_rename(SHEET, OTHER)
|
||||
|
||||
assert (await backend.layout_list())["current"] == OTHER
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
assert line.handle in {e.dxf.handle for e in backend._doc.layouts.get(OTHER)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", " ", "Bad/Name", "Bad*Name", "Bad?Name"])
|
||||
async def test_an_invalid_new_name_is_refused(backend, bad):
|
||||
"""`rename()` accepts all of these and the file still audits clean."""
|
||||
await backend.layout_create(SHEET)
|
||||
result = await backend.layout_rename(SHEET, bad)
|
||||
assert result["ok"] is False
|
||||
assert SHEET in _names(backend)
|
||||
|
||||
|
||||
async def test_renaming_onto_an_existing_name_is_refused(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_create(OTHER)
|
||||
result = await backend.layout_rename(SHEET, OTHER)
|
||||
assert result["ok"] is False
|
||||
assert SHEET in _names(backend) and OTHER in _names(backend)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [("Model", OTHER), (SHEET, "Model")])
|
||||
async def test_model_space_cannot_be_renamed_or_shadowed(backend, args):
|
||||
await backend.layout_create(SHEET)
|
||||
result = await backend.layout_rename(*args)
|
||||
assert result["ok"] is False
|
||||
assert "Model" in _names(backend)
|
||||
|
||||
|
||||
async def test_renaming_a_missing_layout_refuses(backend):
|
||||
result = await backend.layout_rename("NOSUCH", OTHER)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
# ── names are case-insensitive, like the engine underneath ──────────────────
|
||||
#
|
||||
# ezdxf resolves layout names case-insensitively in `get`, `new`, `rename`,
|
||||
# `delete` and `set_active_layout` — but `names()` returns the *stored* case, so
|
||||
# every guard written as `name in doc.layouts.names()` disagrees with the engine
|
||||
# it is guarding. Both directions of that disagreement are bugs: a duplicate
|
||||
# slips past the check and dies on a raw `DXFValueError` from inside ezdxf, and
|
||||
# an existing layout typed in another case is reported as missing.
|
||||
|
||||
|
||||
async def test_creating_a_layout_that_differs_only_by_case_refuses_cleanly(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
result = await backend.layout_create(SHEET.upper())
|
||||
assert result["ok"] is False
|
||||
assert _names(backend).count(SHEET) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda b, name: b.layout_delete(name),
|
||||
lambda b, name: b.layout_set_current(name),
|
||||
lambda b, name: b.viewport_list(name),
|
||||
lambda b, name: b.layout_copy(name, "Copy-Of-It"),
|
||||
],
|
||||
ids=["delete", "set_current", "viewport_list", "copy_source"],
|
||||
)
|
||||
async def test_an_existing_layout_is_found_whatever_case_it_is_typed_in(backend, call):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_create("Spare") # so delete is not refused as the last sheet
|
||||
|
||||
result = await call(backend, SHEET.upper())
|
||||
|
||||
assert result["ok"] is True, f"{SHEET.upper()} must resolve to {SHEET}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda b, name: b.layout_rename("Spare", name),
|
||||
lambda b, name: b.layout_copy("Spare", name),
|
||||
],
|
||||
ids=["rename_target", "copy_target"],
|
||||
)
|
||||
async def test_a_target_name_colliding_only_by_case_refuses_cleanly(backend, call):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_create("Spare")
|
||||
|
||||
result = await call(backend, SHEET.upper())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "Spare" in _names(backend)
|
||||
|
||||
|
||||
async def test_a_layout_can_be_renamed_to_a_different_spelling_of_itself(backend):
|
||||
"""Re-casing a name is a rename onto itself, not a collision."""
|
||||
await backend.layout_create(SHEET)
|
||||
result = await backend.layout_rename(SHEET, SHEET.upper())
|
||||
assert result["ok"] is True
|
||||
assert SHEET.upper() in _names(backend)
|
||||
|
||||
|
||||
# ── layout_copy ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_copy_carries_the_geometry_and_leaves_the_source_alone(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.entity_create_circle(50, 50, 10)
|
||||
await backend.layout_set_current("Model")
|
||||
|
||||
result = await backend.layout_copy(SHEET, OTHER)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["entities_copied"] == 2
|
||||
assert result["skipped"] == []
|
||||
assert _entities_in(backend, OTHER) == _entities_in(backend, SHEET)
|
||||
|
||||
|
||||
async def test_the_copy_is_independent_of_its_source(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
await backend.layout_copy(SHEET, OTHER)
|
||||
|
||||
await backend.layout_delete(SHEET)
|
||||
|
||||
assert _entities_in(backend, OTHER) == ["LINE"]
|
||||
|
||||
|
||||
async def test_the_copy_survives_a_save_and_reload_with_a_clean_audit(backend, tmp_path):
|
||||
"""The Auditor is not evidence on its own, so check the structure too."""
|
||||
import ezdxf
|
||||
from ezdxf.audit import Auditor
|
||||
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
await backend.entity_create_line(0, 0, 100, 0)
|
||||
await backend.layout_set_current("Model")
|
||||
await backend.layout_copy(SHEET, OTHER)
|
||||
|
||||
path = tmp_path / "copied.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
doc = ezdxf.readfile(str(path))
|
||||
|
||||
auditor = Auditor(doc)
|
||||
auditor.run()
|
||||
assert not auditor.errors and not auditor.fixes
|
||||
assert OTHER in doc.layouts.names()
|
||||
assert [e.dxftype() for e in doc.layouts.get(OTHER)] == ["LINE"]
|
||||
|
||||
|
||||
async def test_no_two_layouts_end_up_sharing_a_tab_order(backend):
|
||||
"""A delete leaves a gap; letting new() assign taborder duplicates it."""
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_create("Scratch")
|
||||
await backend.layout_delete("Scratch")
|
||||
await backend.layout_copy(SHEET, OTHER)
|
||||
|
||||
orders = [backend._doc.layouts.get(n).dxf.taborder for n in _names(backend) if n != "Model"]
|
||||
assert len(orders) == len(set(orders)), f"duplicate taborder: {orders}"
|
||||
|
||||
|
||||
async def test_the_copy_keeps_the_source_paper_size(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
source = backend._doc.layouts.get(SHEET)
|
||||
source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
|
||||
expected = source.get_paper_limits()
|
||||
|
||||
await backend.layout_copy(SHEET, OTHER)
|
||||
|
||||
assert backend._doc.layouts.get(OTHER).get_paper_limits() == expected
|
||||
|
||||
|
||||
async def test_copying_model_space_is_refused(backend):
|
||||
"""Otherwise the construction happily dumps model geometry onto a sheet."""
|
||||
result = await backend.layout_copy("Model", OTHER)
|
||||
assert result["ok"] is False
|
||||
assert OTHER not in _names(backend)
|
||||
|
||||
|
||||
async def test_a_blank_source_does_not_resolve_to_whatever_sheet_is_active(backend):
|
||||
"""ezdxf's `layouts.get("")` returns the active paper space."""
|
||||
result = await backend.layout_copy("", OTHER)
|
||||
assert result["ok"] is False
|
||||
assert OTHER not in _names(backend)
|
||||
|
||||
|
||||
async def test_copying_onto_an_existing_name_is_refused(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_create(OTHER)
|
||||
result = await backend.layout_copy(SHEET, OTHER)
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
async def test_an_associative_hatch_does_not_keep_pointing_into_the_source(backend):
|
||||
"""A dangling cross-layout boundary reference the Auditor cannot see."""
|
||||
from ezdxf.math import Vec3
|
||||
|
||||
await backend.layout_create(SHEET)
|
||||
space = backend._doc.layouts.get(SHEET)
|
||||
poly = space.add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10)], close=True)
|
||||
hatch = space.add_hatch(color=2)
|
||||
path = hatch.paths.add_polyline_path(
|
||||
[(Vec3(v[0], v[1]).x, Vec3(v[0], v[1]).y) for v in [(0, 0), (10, 0), (10, 10), (0, 10)]],
|
||||
is_closed=True,
|
||||
)
|
||||
path.source_boundary_objects = [poly.dxf.handle]
|
||||
hatch.set_seed_points([(5, 5)])
|
||||
|
||||
result = await backend.layout_copy(SHEET, OTHER)
|
||||
assert result["ok"] is True
|
||||
|
||||
source_handles = {e.dxf.handle for e in backend._doc.layouts.get(SHEET)}
|
||||
for entity in backend._doc.layouts.get(OTHER):
|
||||
if entity.dxftype() != "HATCH":
|
||||
continue
|
||||
for boundary in entity.paths:
|
||||
leaked = set(getattr(boundary, "source_boundary_objects", [])) & source_handles
|
||||
assert not leaked, f"copy still points at the source layout: {leaked}"
|
||||
|
||||
|
||||
async def test_the_copy_does_not_end_up_with_two_main_viewports(backend):
|
||||
"""Copying a page-set-up layout duplicates the id-1 viewport silently."""
|
||||
await backend.layout_create(SHEET)
|
||||
source = backend._doc.layouts.get(SHEET)
|
||||
source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
|
||||
|
||||
await backend.layout_copy(SHEET, OTHER)
|
||||
|
||||
target = backend._doc.layouts.get(OTHER)
|
||||
mains = [vp for vp in target.viewports() if vp.dxf.get("id", 0) == 1]
|
||||
assert len(mains) <= 1, "a layout may have at most one main viewport"
|
||||
if mains:
|
||||
assert target.dxf.viewport_handle == mains[0].dxf.handle
|
||||
@@ -10,6 +10,7 @@ import pytest_asyncio
|
||||
|
||||
import config
|
||||
import server
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -100,20 +101,35 @@ async def test_export_pdf_layout(backend, tmp_path):
|
||||
|
||||
|
||||
async def test_solids_unsupported_headlessly(backend):
|
||||
box = await backend.solid_box(0, 0, 0, 10, 10, 10)
|
||||
cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10)
|
||||
extrude = await backend.solid_extrude("FF", 10)
|
||||
revolve = await backend.solid_revolve("FF", 0, 0, 0, 1)
|
||||
boolean = await backend.solid_boolean("FF", "FE", "union")
|
||||
for result in (box, cylinder, extrude, revolve, boolean):
|
||||
assert result["ok"] is False
|
||||
assert result["capability"] == "solid_3d"
|
||||
"""T0.7: these refuse by *raising*, so the refusal middleware can see them.
|
||||
|
||||
They used to return ``{"ok": False, "capability": "solid_3d"}``, which left
|
||||
``is_error`` False on a call that did nothing and put the refusal outside
|
||||
the one place that turns a capability boundary into a wire payload. The
|
||||
payload itself is unchanged — ``to_dict()`` still yields exactly what the
|
||||
old return value did.
|
||||
"""
|
||||
calls = [
|
||||
backend.solid_box(0, 0, 0, 10, 10, 10),
|
||||
backend.solid_cylinder(0, 0, 0, 5, 10),
|
||||
backend.solid_extrude("FF", 10),
|
||||
backend.solid_revolve("FF", 0, 0, 0, 1),
|
||||
backend.solid_boolean("FF", "FE", "union"),
|
||||
]
|
||||
for call in calls:
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await call
|
||||
assert excinfo.value.capability == "solid_3d"
|
||||
assert excinfo.value.to_dict()["ok"] is False
|
||||
assert excinfo.value.to_dict()["capability"] == "solid_3d"
|
||||
|
||||
|
||||
async def test_capability_map_reports_paper_space_and_solids(backend):
|
||||
features = backend.capabilities().to_dict()["features"]
|
||||
assert features["paper_space"]["supported"] is True
|
||||
assert features["viewport_render"]["supported"] is False
|
||||
# Not False: the headless renderer does project model content through
|
||||
# viewports. See tests/test_viewport_ops.py for the measurement.
|
||||
assert features["viewport_render"]["supported"] is True
|
||||
assert features["solid_3d"]["supported"] is False
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""T0.5 — measuring the drawing, not the caller's memory of it.
|
||||
|
||||
``analysis_measure_area`` never touched the document: it ran a shoelace over
|
||||
points the *caller* typed in. So "what is the area of the polyline I just drew?"
|
||||
had no answer — the model had to recall the vertices, which is precisely what
|
||||
CLAUDE.md's no-coordinate-guessing rule forbids.
|
||||
|
||||
The only workflow available was worse than absent. ``entity_get`` reported
|
||||
LWPOLYLINE vertices through the default ``get_points()`` format and kept just
|
||||
x and y, silently dropping the **bulge** that makes an edge an arc. Measured on a
|
||||
100x100 square with one edge bulged into an outward semicircle: shoelace over the
|
||||
reported points gives 10000 against a true 13926.9908 — **28.20% low** — and the
|
||||
perimeter 400 against 457.0796, **12.49% low**, with nothing in the response
|
||||
saying so. ``props["length"]`` was dead code: ``LWPolyline`` has no ``length()``
|
||||
method, so the line raised into a debug log and the key simply never appeared.
|
||||
|
||||
Bulge geometry is always a circular arc, so this needs no approximation: area is
|
||||
the shoelace plus each arc's signed circular segment, and perimeter uses arc
|
||||
length. The tests below check that against two independent oracles — closed-form
|
||||
values worked out by hand, and ezdxf's own flattening at a fine tolerance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.measure import (
|
||||
ellipse_area,
|
||||
ellipse_perimeter_ramanujan,
|
||||
is_self_intersecting,
|
||||
polygon_area_perimeter,
|
||||
)
|
||||
|
||||
#: 100x100 square, bottom edge bulged outward into a semicircle (bulge=1.0 means
|
||||
#: a 180 deg sweep, so the chord of 100 is the diameter and the radius is 50).
|
||||
SQUARE_WITH_SEMICIRCLE = [
|
||||
(0.0, 0.0, 0.0),
|
||||
(100.0, 0.0, 1.0),
|
||||
(100.0, 100.0, 0.0),
|
||||
(0.0, 100.0, 0.0),
|
||||
]
|
||||
SEMICIRCLE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0 # 13926.990817
|
||||
SEMICIRCLE_PERIMETER = 300.0 + math.pi * 50.0 # 457.079633
|
||||
|
||||
PLAIN_SQUARE = [(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)]
|
||||
|
||||
|
||||
def _flattened_oracle(vertices, closed, segments_per_arc=200_000):
|
||||
"""Area and perimeter from a dense polyline approximation of the same shape.
|
||||
|
||||
Independent of the implementation under test: it subdivides each arc and
|
||||
runs a plain shoelace, so agreement to 6 decimal places is real evidence
|
||||
rather than a formula checking itself.
|
||||
"""
|
||||
points: list[tuple[float, float]] = []
|
||||
count = len(vertices)
|
||||
limit = count if closed else count - 1
|
||||
for i in range(limit):
|
||||
x1, y1, bulge = vertices[i]
|
||||
x2, y2, _ = vertices[(i + 1) % count]
|
||||
points.append((x1, y1))
|
||||
if abs(bulge) < 1e-12:
|
||||
continue
|
||||
theta = 4.0 * math.atan(bulge)
|
||||
chord = math.hypot(x2 - x1, y2 - y1)
|
||||
radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
|
||||
# Centre lies on the perpendicular bisector, offset by the sagitta side.
|
||||
mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
|
||||
height = radius * math.cos(abs(theta) / 2.0)
|
||||
ux, uy = (y2 - y1) / chord, -(x2 - x1) / chord
|
||||
sign = 1.0 if theta > 0 else -1.0
|
||||
cx, cy = mx - sign * ux * height, my - sign * uy * height
|
||||
a1 = math.atan2(y1 - cy, x1 - cx)
|
||||
steps = max(2, segments_per_arc // max(1, limit))
|
||||
for step in range(1, steps):
|
||||
angle = a1 + theta * step / steps
|
||||
points.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
|
||||
if not closed:
|
||||
points.append((vertices[-1][0], vertices[-1][1]))
|
||||
|
||||
total = len(points)
|
||||
shoelace = sum(
|
||||
points[i][0] * points[(i + 1) % total][1] - points[(i + 1) % total][0] * points[i][1]
|
||||
for i in range(total)
|
||||
)
|
||||
perimeter = sum(
|
||||
math.dist(points[i], points[(i + 1) % total]) for i in range(total if closed else total - 1)
|
||||
)
|
||||
return abs(shoelace) / 2.0, perimeter
|
||||
|
||||
|
||||
# ── the arithmetic the server was missing ───────────────────────────────────
|
||||
|
||||
|
||||
def test_a_straight_polygon_is_the_plain_shoelace():
|
||||
area, perimeter = polygon_area_perimeter(PLAIN_SQUARE, closed=True)
|
||||
assert area == pytest.approx(10000.0)
|
||||
assert perimeter == pytest.approx(400.0)
|
||||
|
||||
|
||||
def test_an_outward_bulge_adds_its_circular_segment():
|
||||
"""The 28.20% the old path lost, recovered exactly."""
|
||||
area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True)
|
||||
assert area == pytest.approx(SEMICIRCLE_AREA, rel=1e-12)
|
||||
assert perimeter == pytest.approx(SEMICIRCLE_PERIMETER, rel=1e-12)
|
||||
# ...and it really is 28% more than the xy-only answer.
|
||||
assert area / 10000.0 == pytest.approx(1.3926990817, rel=1e-9)
|
||||
|
||||
|
||||
def test_an_inward_bulge_subtracts_it():
|
||||
inward = [(0.0, 0.0, 0.0), (100.0, 0.0, -0.5), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)]
|
||||
area, perimeter = polygon_area_perimeter(inward, closed=True)
|
||||
|
||||
oracle_area, oracle_perimeter = _flattened_oracle(inward, closed=True)
|
||||
assert area == pytest.approx(oracle_area, rel=1e-9)
|
||||
assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7)
|
||||
assert area < 10000.0, "an inward bulge must remove material"
|
||||
|
||||
|
||||
def test_a_bulge_matches_a_dense_flattening_of_the_same_arc():
|
||||
area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True)
|
||||
oracle_area, oracle_perimeter = _flattened_oracle(SQUARE_WITH_SEMICIRCLE, closed=True)
|
||||
assert area == pytest.approx(oracle_area, rel=1e-9)
|
||||
assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7)
|
||||
|
||||
|
||||
def test_an_open_boundary_is_closed_implicitly(bulge_free=PLAIN_SQUARE):
|
||||
"""AutoCAD's AREA closes an open boundary; the caller is told it happened."""
|
||||
open_area, open_perimeter = polygon_area_perimeter(bulge_free, closed=False)
|
||||
assert open_area == pytest.approx(10000.0), "the area is of the closed region"
|
||||
assert open_perimeter == pytest.approx(300.0), "the walked length is the open one"
|
||||
|
||||
|
||||
# ── the trap shoelace hides ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_self_intersecting_loop_is_detected():
|
||||
"""Shoelace CANCELS crossed lobes: a bowtie of two 2500 triangles reads 0."""
|
||||
bowtie = [(0.0, 0.0, 0.0), (100.0, 100.0, 0.0), (100.0, 0.0, 0.0), (0.0, 100.0, 0.0)]
|
||||
area, _ = polygon_area_perimeter(bowtie, closed=True)
|
||||
assert area == pytest.approx(0.0, abs=1e-9), "this is exactly why it must be flagged"
|
||||
assert is_self_intersecting([(x, y) for x, y, _ in bowtie]) is True
|
||||
|
||||
|
||||
def test_a_simple_polygon_is_not_flagged():
|
||||
assert is_self_intersecting([(x, y) for x, y, _ in PLAIN_SQUARE]) is False
|
||||
|
||||
|
||||
def test_self_intersection_reports_unknown_rather_than_guessing_on_huge_loops():
|
||||
"""The check is O(n^2); above the cap it must answer None, not False."""
|
||||
from engineering.measure import MAX_SELF_INTERSECT_VERTS
|
||||
|
||||
huge = [(float(i), float(i % 7), 0.0) for i in range(MAX_SELF_INTERSECT_VERTS + 1)]
|
||||
assert is_self_intersecting([(x, y) for x, y, _ in huge]) is None
|
||||
|
||||
|
||||
# ── ellipses ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ellipse_area_is_exact_and_perimeter_is_flagged_approximate():
|
||||
assert ellipse_area(10.0, 0.5) == pytest.approx(math.pi * 10.0 * 5.0)
|
||||
# A circle is the one ellipse whose perimeter is closed-form, so Ramanujan
|
||||
# must be exact there — a cheap check that the approximation is wired right.
|
||||
assert ellipse_perimeter_ramanujan(10.0, 1.0) == pytest.approx(2 * math.pi * 10.0, rel=1e-12)
|
||||
|
||||
|
||||
# ── the two-vertex circle ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_circle_stored_as_two_bulged_vertices_has_its_real_area():
|
||||
"""DXF's most compact circle, and the arithmetic answered 0.0 for it.
|
||||
|
||||
Two semicircular arcs joined into a circle store as a closed LWPOLYLINE of
|
||||
*two* vertices, each bulge 1.0 — what AutoCAD writes for a JOINed pair of
|
||||
arcs, and what `boundary_from_entities` builds from any two-arc loop. The
|
||||
`count < 3` guard treated that as degenerate and returned zero area, which
|
||||
is the same silent-nothing this module exists to stop: the shoelace over
|
||||
two points is genuinely 0, but the two circular segments are the whole
|
||||
shape and they were never added.
|
||||
"""
|
||||
circle = [(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)]
|
||||
|
||||
area, perimeter = polygon_area_perimeter(circle, closed=True)
|
||||
|
||||
assert area == pytest.approx(math.pi * 100.0, rel=1e-12)
|
||||
assert perimeter == pytest.approx(2 * math.pi * 10.0, rel=1e-12)
|
||||
|
||||
|
||||
def test_two_straight_vertices_still_enclose_nothing():
|
||||
"""The fix must not invent area where there is none."""
|
||||
area, perimeter = polygon_area_perimeter([(0.0, 0.0, 0.0), (10.0, 0.0, 0.0)], closed=True)
|
||||
|
||||
assert area == 0.0
|
||||
assert perimeter == pytest.approx(20.0), "there and back"
|
||||
|
||||
|
||||
def test_a_single_vertex_is_still_degenerate():
|
||||
assert polygon_area_perimeter([(0.0, 0.0, 1.0)], closed=True) == (0.0, 0.0)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""T0.4 — coordinates that cross the MCP boundary are WCS, on both engines.
|
||||
|
||||
DXF does not store 2D geometry in world coordinates. A CIRCLE, ARC, LWPOLYLINE,
|
||||
TEXT or INSERT lives in an Object Coordinate System derived from its ``extrusion``
|
||||
vector by the Arbitrary Axis Algorithm. Mirroring flips the extrusion to
|
||||
``(0, 0, -1)`` and leaves the stored x *unnegated*, so reading ``dxf.center``
|
||||
raw returns a point the entity is not at.
|
||||
|
||||
The word "extrusion" appeared nowhere in this repository, so every such read was
|
||||
wrong and silent. Measured: after the server's own ``entity_mirror`` across the Y
|
||||
axis, a circle drawn at (30, 20) reported ``center [30.0, 20.0]`` while the *same
|
||||
response's* ``bounding_box`` said ``min [-35, 15] / max [-25, 25]`` — the payload
|
||||
contradicted itself by 60 mm, and ``point_from_snap``, ``entity_select_smart`` and
|
||||
``entity_offset`` all inherited the error.
|
||||
|
||||
Two fences guard the obvious wrong fixes:
|
||||
|
||||
* ``test_wcs_entities_are_left_alone`` — LINE, MTEXT, ELLIPSE and SPLINE all carry
|
||||
an ``extrusion`` attribute too, and are already correct because ezdxf makes
|
||||
their ``ocs()`` a pass-through. Keying the fix off "has extrusion" breaks them.
|
||||
* ``test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle`` — the read and
|
||||
write paths were *consistently* wrong, so normalising reads without the write
|
||||
inverse would turn a harmless no-op into a real 60 mm teleport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
#: Mirror line = the Y axis, i.e. x -> -x. This is what produces extrusion (0,0,-1).
|
||||
Y_AXIS = (0.0, 0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def _assert_points(actual, expected) -> None:
|
||||
"""pytest.approx refuses nested sequences, so compare point by point."""
|
||||
assert len(actual) == len(expected), f"{actual} != {expected}"
|
||||
for got, want in zip(actual, expected, strict=True):
|
||||
assert got == pytest.approx(want, abs=1e-9), f"{actual} != {expected}"
|
||||
|
||||
|
||||
def _bbox_centre(info) -> tuple[float, float]:
|
||||
"""Centre of the entity's own bounding box — an independent oracle.
|
||||
|
||||
``ezdxf.bbox.extents`` flattens the entity through the same renderer AutoCAD
|
||||
would, so it is WCS whatever the entity's frame is. It is the field that
|
||||
exposed the contradiction, and it is not computed from the OCS attributes.
|
||||
"""
|
||||
box = info.properties["bounding_box"]
|
||||
return (
|
||||
(box["min"][0] + box["max"][0]) / 2.0,
|
||||
(box["min"][1] + box["max"][1]) / 2.0,
|
||||
)
|
||||
|
||||
|
||||
# ── the fence: entities that were never wrong ───────────────────────────────
|
||||
|
||||
|
||||
async def test_wcs_entities_are_left_alone(backend):
|
||||
"""LINE/MTEXT/ELLIPSE/SPLINE carry extrusion but report WCS already."""
|
||||
line = await backend.entity_create_line(10, 5, 30, 5)
|
||||
ellipse = await backend.entity_create_ellipse(20, 40, 10, 5)
|
||||
text = await backend.entity_create_mtext("hello", 25, 60, 2.5)
|
||||
|
||||
for created in (line, ellipse, text):
|
||||
mirrored = await backend.entity_mirror(created.handle, *Y_AXIS)
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
cx, cy = _bbox_centre(info)
|
||||
for key in ("start", "end", "center", "insertion"):
|
||||
point = info.properties.get(key)
|
||||
if point is None:
|
||||
continue
|
||||
assert point[0] < 0, f"{info.type}.{key} must land left of x=0 after mirroring"
|
||||
if "center" in info.properties:
|
||||
assert info.properties["center"] == pytest.approx([cx, cy], abs=1e-6)
|
||||
|
||||
|
||||
# ── the defect ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_entity_get_center_agrees_with_its_own_bounding_box(backend):
|
||||
"""A response that contradicts itself is the clearest possible proof."""
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
|
||||
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
|
||||
assert info.properties["radius"] == pytest.approx(5.0)
|
||||
|
||||
|
||||
async def test_mirrored_arc_reports_wcs_centre_and_sweep(backend):
|
||||
"""A left-handed frame reverses the sweep, so start/end also swap."""
|
||||
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
|
||||
mirrored = await backend.entity_mirror(arc.handle, *Y_AXIS)
|
||||
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
|
||||
|
||||
# Mirroring the first quadrant across x=0 gives the second: the bbox says the
|
||||
# arc occupies x in [-35, -30], y in [20, 25], i.e. 90 deg -> 180 deg.
|
||||
assert info.properties["start_angle"] == pytest.approx(90.0, abs=1e-6)
|
||||
assert info.properties["end_angle"] == pytest.approx(180.0, abs=1e-6)
|
||||
# Length is frame-invariant and must not change.
|
||||
assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 / 2, rel=1e-9)
|
||||
|
||||
|
||||
async def test_mirrored_polyline_points_are_wcs(backend):
|
||||
poly = await backend.entity_create_polyline([[10, 0], [20, 0], [20, 10]], closed=False)
|
||||
mirrored = await backend.entity_mirror(poly.handle, *Y_AXIS)
|
||||
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
|
||||
|
||||
|
||||
async def test_mirrored_text_insertion_is_wcs(backend):
|
||||
text = await backend.entity_create_text("PART A", 40, 15, 3.0)
|
||||
mirrored = await backend.entity_mirror(text.handle, *Y_AXIS)
|
||||
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
assert info.properties["insertion"] == pytest.approx([-40.0, 15.0])
|
||||
|
||||
|
||||
# ── the trap: reads must not be normalised without the write inverse ────────
|
||||
|
||||
|
||||
async def test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle(backend):
|
||||
"""Read the centre, write it straight back: the circle must not move.
|
||||
|
||||
Before the fix this passed for the wrong reason — read and write were
|
||||
*consistently* wrong. It is here so that fixing only the read is caught.
|
||||
"""
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
|
||||
before = await backend.entity_get(mirrored.handle)
|
||||
centre = before.properties["center"]
|
||||
await backend.entity_edit_geometry(mirrored.handle, cx=centre[0], cy=centre[1])
|
||||
after = await backend.entity_get(mirrored.handle)
|
||||
|
||||
assert after.properties["center"] == pytest.approx(centre)
|
||||
assert after.properties["bounding_box"] == before.properties["bounding_box"]
|
||||
|
||||
|
||||
async def test_entity_edit_geometry_moves_a_mirrored_circle_where_asked(backend):
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
|
||||
await backend.entity_edit_geometry(mirrored.handle, cx=-10.0, cy=40.0)
|
||||
|
||||
info = await backend.entity_get(mirrored.handle)
|
||||
assert info.properties["center"] == pytest.approx([-10.0, 40.0])
|
||||
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
|
||||
|
||||
|
||||
async def test_entity_offset_on_a_mirrored_circle_is_concentric_and_shrinks(backend):
|
||||
"""The offset used to land 60 mm away *and* grow instead of shrink."""
|
||||
circle = await backend.entity_create_circle(30, 20, 10)
|
||||
mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS)
|
||||
|
||||
inner = await backend.entity_offset(mirrored.handle, 2.0, side_x=-30.0, side_y=20.0)
|
||||
|
||||
info = await backend.entity_get(inner.handle)
|
||||
assert info.properties["center"] == pytest.approx([-30.0, 20.0])
|
||||
assert info.properties["radius"] == pytest.approx(8.0), "offset toward the centre shrinks"
|
||||
|
||||
|
||||
# ── tilted planes: the one case 2D xy cannot address ────────────────────────
|
||||
|
||||
|
||||
def _tilt(backend, handle, normal=(0.0, 0.6, 0.8)) -> None:
|
||||
"""Put an entity in a plane that is not parallel to WCS XY."""
|
||||
backend._doc.entitydb.get(handle).dxf.extrusion = normal
|
||||
|
||||
|
||||
async def test_a_tilted_entity_omits_what_xy_cannot_express(backend):
|
||||
"""A tilted circle projects to an ellipse, so `radius` has no WCS-XY meaning.
|
||||
|
||||
Omitting the field is silence; reporting 5.0 for a footprint of 5.0 x 3.99
|
||||
would be a fresh silent wrong number of exactly the kind this release exists
|
||||
to remove.
|
||||
"""
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
_tilt(backend, circle.handle)
|
||||
|
||||
info = await backend.entity_get(circle.handle)
|
||||
assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6)
|
||||
assert "plane_normal" in info.properties
|
||||
assert info.properties["plane_normal"] == pytest.approx([0.0, 0.6, 0.8])
|
||||
assert "radius" not in info.properties
|
||||
|
||||
|
||||
async def test_a_tilted_arc_omits_its_angles(backend):
|
||||
arc = await backend.entity_create_arc(30, 20, 5, 0, 90)
|
||||
_tilt(backend, arc.handle)
|
||||
|
||||
info = await backend.entity_get(arc.handle)
|
||||
assert "start_angle" not in info.properties
|
||||
assert "end_angle" not in info.properties
|
||||
assert "plane_normal" in info.properties
|
||||
|
||||
|
||||
async def test_entity_edit_geometry_refuses_a_tilted_plane_with_capability_shape(backend):
|
||||
circle = await backend.entity_create_circle(30, 20, 5)
|
||||
_tilt(backend, circle.handle)
|
||||
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.entity_edit_geometry(circle.handle, cx=1.0, cy=2.0)
|
||||
|
||||
assert excinfo.value.capability == "ocs_tilted_plane"
|
||||
payload = excinfo.value.to_dict()
|
||||
assert payload["ok"] is False
|
||||
assert "entity_move" in payload["error"], "a refusal must name the way forward"
|
||||
|
||||
|
||||
# ── the boundary is pre-checkable ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_ocs_capability_keys_are_declared_on_both_backends():
|
||||
for features in (
|
||||
EzdxfBackend().capabilities().features,
|
||||
ComBackend().capabilities().features,
|
||||
):
|
||||
assert features["ocs_normalized"].supported is True
|
||||
assert features["ocs_normalized"].reason, "the residual must be named, not implied"
|
||||
assert features["ocs_tilted_plane"].supported is False
|
||||
|
||||
|
||||
# ── COM: the one genuinely wrong read on that engine ────────────────────────
|
||||
|
||||
|
||||
def _fake_lwpolyline(coords, normal):
|
||||
"""The minimum surface ``_entity_info`` touches for a lightweight polyline."""
|
||||
import types
|
||||
|
||||
return types.SimpleNamespace(
|
||||
ObjectName="AcDbLWPolyline",
|
||||
Coordinates=coords,
|
||||
Normal=normal,
|
||||
Elevation=0.0,
|
||||
Handle="2F",
|
||||
Layer="0",
|
||||
Color=256,
|
||||
Linetype="ByLayer",
|
||||
Visible=True,
|
||||
Closed=False,
|
||||
Length=30.0,
|
||||
GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)),
|
||||
)
|
||||
|
||||
|
||||
async def test_com_lwpolyline_coordinates_are_translated_to_wcs():
|
||||
"""ActiveX returns WCS for everything *except* polyline Coordinates (OCS).
|
||||
|
||||
Unit-tested against a fake COM object: this machine has a live AutoCAD and
|
||||
connecting to it is forbidden, so this path ships verified only at this
|
||||
level — the CHANGELOG says so.
|
||||
"""
|
||||
from backends.com_backend import _entity_info
|
||||
|
||||
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0, 20.0, 10.0), (0.0, 0.0, -1.0)))
|
||||
_assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]])
|
||||
|
||||
|
||||
async def test_com_lwpolyline_with_identity_normal_is_untouched():
|
||||
from backends.com_backend import _entity_info
|
||||
|
||||
info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0), (0.0, 0.0, 1.0)))
|
||||
_assert_points(info.properties["points"], [[10, 0], [20, 0]])
|
||||
@@ -0,0 +1,142 @@
|
||||
"""M5 — drawing into the layout you selected.
|
||||
|
||||
`layout_set_current("A3-Sheet")` answered `{"ok": true, "current": "A3-Sheet"}`
|
||||
and then every entity created afterwards went into **model space** anyway,
|
||||
because `_msp()` returned `doc.modelspace()` unconditionally on both engines.
|
||||
The tool reported a state change that changed nothing about where geometry
|
||||
landed — proven: a line drawn straight after selecting the layout was in
|
||||
modelspace and not in the layout.
|
||||
|
||||
That is also why the title block could not go on a sheet: it draws through the
|
||||
same `entity_create_*` calls, so `apply_iso_a3_titleblock` could only ever
|
||||
produce a border around model-space geometry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
SHEET = "A3-Sheet"
|
||||
|
||||
|
||||
async def _handles_in(backend, layout_name: str) -> set[str]:
|
||||
doc = backend._doc
|
||||
space = doc.modelspace() if layout_name == "Model" else doc.layouts.get(layout_name)
|
||||
return {e.dxf.handle for e in space}
|
||||
|
||||
|
||||
# ── the current space is where geometry goes ────────────────────────────────
|
||||
|
||||
|
||||
async def test_entities_land_in_the_layout_that_was_selected(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
|
||||
assert line.handle in await _handles_in(backend, SHEET)
|
||||
assert line.handle not in await _handles_in(backend, "Model")
|
||||
|
||||
|
||||
async def test_switching_back_to_model_puts_geometry_back_in_model_space(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
on_sheet = await backend.entity_create_circle(10, 10, 5)
|
||||
await backend.layout_set_current("Model")
|
||||
|
||||
in_model = await backend.entity_create_circle(20, 20, 5)
|
||||
|
||||
assert on_sheet.handle in await _handles_in(backend, SHEET)
|
||||
assert in_model.handle in await _handles_in(backend, "Model")
|
||||
|
||||
|
||||
async def test_a_new_drawing_starts_in_model_space(backend):
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
await backend.drawing_new()
|
||||
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
assert line.handle in await _handles_in(backend, "Model")
|
||||
|
||||
|
||||
async def test_selecting_a_missing_layout_does_not_move_the_current_space(backend):
|
||||
"""A refused switch must leave the caller where they were, not in limbo."""
|
||||
result = await backend.layout_set_current("NOSUCH")
|
||||
assert result["ok"] is False
|
||||
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
assert line.handle in await _handles_in(backend, "Model")
|
||||
|
||||
|
||||
async def test_entity_get_finds_an_entity_drawn_on_a_layout(backend):
|
||||
"""Lookups must follow the geometry, or the handle becomes unusable."""
|
||||
await backend.layout_create(SHEET)
|
||||
await backend.layout_set_current(SHEET)
|
||||
line = await backend.entity_create_line(0, 0, 100, 0)
|
||||
|
||||
info = await backend.entity_get(line.handle)
|
||||
assert info.handle == line.handle
|
||||
assert info.type == "LINE"
|
||||
|
||||
|
||||
# ── the title block, on a sheet ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_titleblock_can_be_drawn_on_a_paper_space_layout(backend):
|
||||
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||
|
||||
await backend.layout_create(SHEET)
|
||||
before_model = await _handles_in(backend, "Model")
|
||||
|
||||
result = await apply_iso_a3_titleblock(
|
||||
backend,
|
||||
metadata=TitleBlockMetadata(title="BRACKET", drawing_no="D-001"),
|
||||
layout=SHEET,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["layout"] == SHEET
|
||||
on_sheet = await _handles_in(backend, SHEET)
|
||||
assert len(on_sheet) > 5, "border, inner frame and the block's text all belong here"
|
||||
assert await _handles_in(backend, "Model") == before_model, "model space untouched"
|
||||
|
||||
|
||||
async def test_titleblock_restores_the_space_the_caller_was_in(backend):
|
||||
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||
|
||||
await backend.layout_create(SHEET)
|
||||
await apply_iso_a3_titleblock(
|
||||
backend,
|
||||
metadata=TitleBlockMetadata(title="X", drawing_no="1"),
|
||||
layout=SHEET,
|
||||
)
|
||||
|
||||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||
assert line.handle in await _handles_in(backend, "Model"), (
|
||||
"drawing a sheet border must not silently move the caller onto the sheet"
|
||||
)
|
||||
|
||||
|
||||
async def test_titleblock_without_a_layout_still_draws_in_model_space(backend):
|
||||
"""The pre-1.5.0 behaviour stays the default."""
|
||||
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||
|
||||
result = await apply_iso_a3_titleblock(
|
||||
backend, metadata=TitleBlockMetadata(title="X", drawing_no="1")
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["layout"] == "Model"
|
||||
assert len(await _handles_in(backend, "Model")) > 5
|
||||
|
||||
|
||||
async def test_titleblock_on_a_missing_layout_refuses(backend):
|
||||
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||
|
||||
with pytest.raises(Exception, match="NOSUCH"):
|
||||
await apply_iso_a3_titleblock(
|
||||
backend,
|
||||
metadata=TitleBlockMetadata(title="X", drawing_no="1"),
|
||||
layout="NOSUCH",
|
||||
)
|
||||
@@ -51,6 +51,50 @@ def test_preflight_normalizes_complete_spec_and_hash_is_deterministic():
|
||||
assert first.spec_hash == second.spec_hash
|
||||
|
||||
|
||||
def test_preflight_question_text_is_english():
|
||||
"""drawing_preflight returns this text to every user of a public English
|
||||
package; it shipped in Turkish through v1.4."""
|
||||
result = preflight_drawing("L bracket", requirements={})
|
||||
by_code = {q.code: q.question for q in result.questions}
|
||||
|
||||
assert by_code["MISSING_UNITS"] == "What drawing units should be used?"
|
||||
assert by_code["MISSING_PART_TYPE"] == "What kind of part or drawing is being produced?"
|
||||
assert by_code["MISSING_DIMENSIONS"] == "What are the main production dimensions?"
|
||||
assert by_code["MISSING_TOLERANCE_POLICY"] == "Which tolerance policy should apply?"
|
||||
|
||||
|
||||
def test_preflight_conflict_message_is_english():
|
||||
requirements = _complete_requirements()
|
||||
requirements["constraints"] = [
|
||||
{"field": "dimensions.width", "value": 80},
|
||||
{"field": "dimensions.width", "value": 90},
|
||||
]
|
||||
|
||||
result = preflight_drawing("L bracket", requirements=requirements)
|
||||
|
||||
assert result.conflicts[0].message == ("Conflicting values were supplied for dimensions.width.")
|
||||
|
||||
|
||||
def test_preflight_user_facing_text_is_ascii():
|
||||
"""Guard against any future non-English string leaking back into the tool
|
||||
surface — codes and field names stay machine-readable, prose stays ASCII."""
|
||||
requirements = _complete_requirements()
|
||||
requirements["constraints"] = [
|
||||
{"field": "dimensions.width", "value": 80},
|
||||
{"field": "dimensions.width", "value": 90},
|
||||
]
|
||||
empty = preflight_drawing("L bracket", requirements={})
|
||||
conflicting = preflight_drawing("L bracket", requirements=requirements)
|
||||
|
||||
prose = [q.question for q in empty.questions]
|
||||
prose += [choice for q in empty.questions for choice in q.choices]
|
||||
prose += [c.message for c in conflicting.conflicts]
|
||||
|
||||
assert prose, "expected some user-facing text to check"
|
||||
for text in prose:
|
||||
assert text.isascii(), f"non-ASCII user-facing text: {text!r}"
|
||||
|
||||
|
||||
def test_preflight_detects_conflicting_constraints():
|
||||
requirements = _complete_requirements()
|
||||
requirements["constraints"] = [
|
||||
|
||||
@@ -2,20 +2,24 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from engineering.refiner import RepairRegistry, refine_drawing
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_transaction_stack_is_separate_from_drawing_undo(backend):
|
||||
async def test_transaction_stack_is_separate_from_drawing_undo(backend, monkeypatch):
|
||||
"""An undo must not consume a transaction checkpoint, or vice versa."""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
|
||||
await backend.drawing_new()
|
||||
await backend.entity_create_line(0, 0, 10, 0)
|
||||
await backend.transaction_begin()
|
||||
|
||||
undo = await backend.drawing_undo()
|
||||
status = await backend.system_status()
|
||||
|
||||
assert undo["ok"] is False
|
||||
assert status["transaction_depth"] == 1
|
||||
assert undo["ok"] is True, "undo walks its own history"
|
||||
assert status["transaction_depth"] == 1, "and leaves the checkpoint standing"
|
||||
await backend.transaction_rollback()
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,28 @@ none of them can drift again:
|
||||
|
||||
- pyproject version == CHANGELOG top release == README snapshot major.minor
|
||||
- version.py fallback parser returns the pyproject version
|
||||
- README snapshot tool/resource/prompt counts == live @mcp.* decorator counts
|
||||
- README snapshot tool/resource/prompt counts == the generated tool inventory
|
||||
- every ``SECTION n: ... (N tools)`` header matches the decorators below it
|
||||
- server.json version == pyproject, and the README carries the mcp-name marker
|
||||
- every wheel ``only-include`` entry is COPY'd into the Docker image
|
||||
|
||||
Two of these used to count the literal text ``@mcp.tool(`` in server.py, which
|
||||
made that string load-bearing in prose: an example in a docstring inflated the
|
||||
count. Neither does any more.
|
||||
|
||||
* The README snapshot counts now come from ``docs/tool-inventory.json``, which
|
||||
``tests/test_tool_inventory.py`` holds equal to the live component registry —
|
||||
so the chain README -> inventory -> registry is closed without this module
|
||||
having to import the server.
|
||||
* The section-header gate stays (a "SECTION n" is a fact about server.py's
|
||||
layout that no runtime registry can see) but counts *parsed* decorators
|
||||
instead of matched text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
@@ -90,31 +105,76 @@ def test_readme_snapshot_matches_pyproject_minor() -> None:
|
||||
|
||||
|
||||
def test_readme_snapshot_counts_match_registrations() -> None:
|
||||
src = _server_source()
|
||||
tools = len(re.findall(r"@mcp\.tool\(", src))
|
||||
resources = len(re.findall(r"@mcp\.resource\(", src))
|
||||
prompts = len(re.findall(r"@mcp\.prompt\(", src))
|
||||
"""README snapshot == the surface, via the generated inventory.
|
||||
|
||||
The counts used to be a regex tally of ``@mcp.tool(`` / ``@mcp.resource(`` /
|
||||
``@mcp.prompt(`` in server.py's text. They now come from
|
||||
``docs/tool-inventory.json``, which is built from the live component
|
||||
registry and gated against it by tests/test_tool_inventory.py — so this
|
||||
still fails when a registration is added without updating the README, but
|
||||
it can no longer be moved by prose that merely *mentions* a decorator.
|
||||
"""
|
||||
inventory = json.loads((ROOT / "docs" / "tool-inventory.json").read_text(encoding="utf-8"))
|
||||
totals = inventory["totals"]
|
||||
live = (totals["tools"], totals["resources"], totals["prompts"])
|
||||
_, readme_tools, readme_resources, readme_prompts = _readme_snapshot()
|
||||
assert (readme_tools, readme_resources, readme_prompts) == (tools, resources, prompts), (
|
||||
assert (readme_tools, readme_resources, readme_prompts) == live, (
|
||||
f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} "
|
||||
f"(tools/resources/prompts) but server.py registers {tools}/{resources}/{prompts}"
|
||||
f"(tools/resources/prompts) but the server registers {live[0]}/{live[1]}/{live[2]} "
|
||||
"(per docs/tool-inventory.json; regenerate it with "
|
||||
"`python scripts/generate_tool_inventory.py` if that is the stale one)"
|
||||
)
|
||||
|
||||
|
||||
def _tool_decorator_lines(src: str) -> list[int]:
|
||||
"""Line number of every ``mcp.tool`` decorator, parsed rather than matched.
|
||||
|
||||
Parsing is what keeps the *string* ``@mcp.tool(`` from being load-bearing:
|
||||
an occurrence in a docstring or comment is not a decorator node, so it
|
||||
cannot inflate a section's count.
|
||||
"""
|
||||
lines: list[int] = []
|
||||
for node in ast.walk(ast.parse(src)):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for decorator in node.decorator_list:
|
||||
# Both spellings count: `@mcp.tool` and `@mcp.tool(...)`.
|
||||
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||||
if (
|
||||
isinstance(target, ast.Attribute)
|
||||
and target.attr == "tool"
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id == "mcp"
|
||||
):
|
||||
lines.append(decorator.lineno)
|
||||
return sorted(lines)
|
||||
|
||||
|
||||
def test_section_header_counts_match_decorators() -> None:
|
||||
"""Kept, and not replaceable by the inventory: a section is a source-layout
|
||||
fact (which tools sit under which header comment), invisible to the live
|
||||
registry, and it does not line up with the tag-derived groups the inventory
|
||||
records — SECTION 8 alone spans analysis + batch + templates + validation."""
|
||||
src = _server_source()
|
||||
lines = src.splitlines()
|
||||
decorators = _tool_decorator_lines(src)
|
||||
# Segment boundaries: every SECTION header (counted or not) ends a segment.
|
||||
boundaries = [
|
||||
number
|
||||
for number, line in enumerate(lines, start=1)
|
||||
if re.search(r"#[^\n]*SECTION [\w]+:", line)
|
||||
]
|
||||
boundaries.append(len(lines) + 1)
|
||||
headers = [
|
||||
(m.start(), m.group(1), m.group(2))
|
||||
for m in re.finditer(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", src)
|
||||
(number, match.group(1), match.group(2))
|
||||
for number, line in enumerate(lines, start=1)
|
||||
if (match := re.search(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", line))
|
||||
]
|
||||
assert headers, "no counted SECTION headers found in server.py"
|
||||
# Segment boundaries: every SECTION header (counted or not) ends a segment.
|
||||
boundaries = sorted(m.start() for m in re.finditer(r"#[^\n]*SECTION [\w]+:", src))
|
||||
boundaries.append(len(src))
|
||||
mismatches: list[str] = []
|
||||
for start, section_id, declared in headers:
|
||||
end = min(b for b in boundaries if b > start)
|
||||
actual = len(re.findall(r"@mcp\.tool\(", src[start:end]))
|
||||
actual = sum(1 for line in decorators if start <= line < end)
|
||||
if actual != int(declared):
|
||||
mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}")
|
||||
assert not mismatches, "; ".join(mismatches)
|
||||
@@ -122,8 +182,6 @@ def test_section_header_counts_match_decorators() -> None:
|
||||
|
||||
def test_server_json_versions_match_pyproject() -> None:
|
||||
"""MCP registry manifest must ship the same version as the package."""
|
||||
import json
|
||||
|
||||
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
||||
expected = _pyproject_version()
|
||||
assert manifest["version"] == expected, (
|
||||
@@ -141,8 +199,6 @@ def test_server_json_versions_match_pyproject() -> None:
|
||||
def test_readme_contains_mcp_name_marker() -> None:
|
||||
"""PyPI ownership validation for the MCP registry needs this marker in the
|
||||
README (which ships to PyPI as the long description)."""
|
||||
import json
|
||||
|
||||
manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
assert f"mcp-name: {manifest['name']}" in readme
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Result shaping — field projection + compact rows on the collection tools.
|
||||
|
||||
The release made *discovery* cheap and left the other half of the token bill
|
||||
untouched: what the tools hand back. On a 300-entity drawing a single default
|
||||
``entity_list(limit=100)`` is ~24 kB of JSON the model has to read, and roughly
|
||||
a third of it is a ``properties.bounding_box`` nobody asked for.
|
||||
|
||||
ONE mechanism, two parameters, spelled identically on every collection tool
|
||||
(:data:`server.SHAPED_RESULT_TOOLS`)::
|
||||
|
||||
fields=["handle", "type", "layer"] # projection, order preserved
|
||||
compact=True # columnar envelope instead of dicts
|
||||
|
||||
What these tests guard is the mechanism, not the byte counts:
|
||||
|
||||
* **Backwards compatibility is the hard constraint.** Omitting both parameters
|
||||
must return exactly the 1.4.0 value. Every pre-existing test in the suite is
|
||||
the real gate; the test here only pins the intent.
|
||||
* **No per-tool dialect.** ``SHAPED_RESULT_TOOLS`` is checked against the live
|
||||
registry in *both* directions, and the two parameters' JSON Schemas are
|
||||
compared across every tool that carries them. A tool that grows a ``columns=``
|
||||
or a ``brief=`` of its own fails here.
|
||||
* **A typo is an error, never an empty column.** An unknown field name must come
|
||||
back naming the valid fields. This is the difference between a projection and
|
||||
a footgun: ``fields=["handel"]`` returning 100 rows of ``{"handel": null}``
|
||||
reads as "the drawing has no handles".
|
||||
* **Truncation honesty.** A silently truncated list read as complete is a
|
||||
correctness bug, not a token one. ``entity_list`` pages, and in 1.4.0 the
|
||||
return value carried no signal at all that more rows existed; the two
|
||||
``analysis_select_by_*`` tools capped at ``MAX_LIST_LIMIT`` and said so only
|
||||
in a log notification most clients never show the model. The compact envelope
|
||||
is the mode that can carry ``total``/``truncated``/``next_offset``, and
|
||||
``total`` is measured against the *unfiltered* matching set rather than the
|
||||
page, which is why the backends gained ``entity_count``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import config
|
||||
import server
|
||||
from backends.base import BlockInfo, EntityInfo, LayerInfo
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
ENTITY_FIELDS = ("handle", "type", "layer", "color", "linetype", "visible", "properties")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(monkeypatch):
|
||||
"""In-memory client on a fresh headless document.
|
||||
|
||||
The env var is what ``server._make_backend`` reads; setting
|
||||
``config.settings.backend`` would leave a Windows box with AutoCAD open
|
||||
talking to the operator's live drawing.
|
||||
"""
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
async with Client(server.mcp) as connected:
|
||||
status = (await connected.call_tool("system_status", {})).structured_content or {}
|
||||
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
|
||||
await connected.call_tool("drawing_new", {})
|
||||
yield connected
|
||||
|
||||
|
||||
async def _draw(client, circles: int = 30, lines: int = 30) -> None:
|
||||
"""A small mixed drawing: CIRCLEs carry properties LINEs do not."""
|
||||
steps = []
|
||||
for i in range(circles):
|
||||
steps.append(
|
||||
{
|
||||
"tool": "entity_create_circle",
|
||||
"args": {"cx": float(i), "cy": 0.0, "radius": 1.0 + i, "layer": "GEOMETRY"},
|
||||
}
|
||||
)
|
||||
for i in range(lines):
|
||||
steps.append(
|
||||
{
|
||||
"tool": "entity_create_line",
|
||||
"args": {
|
||||
"x1": 0.0,
|
||||
"y1": float(i),
|
||||
"x2": 10.0,
|
||||
"y2": float(i),
|
||||
"layer": "HIDDEN",
|
||||
},
|
||||
}
|
||||
)
|
||||
result = (await client.call_tool("cad_batch", {"steps": steps})).structured_content
|
||||
assert result["failed"] == 0, result
|
||||
|
||||
|
||||
def _rows(result) -> list | dict:
|
||||
"""Unwrap FastMCP's ``{"result": ...}`` output wrapper."""
|
||||
return (result.structured_content or {})["result"]
|
||||
|
||||
|
||||
def dumps(obj) -> str:
|
||||
"""Compact JSON — how a payload is actually charged for."""
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The mechanism is one mechanism
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_every_shaped_tool_carries_both_parameters():
|
||||
tools = {t.name: t for t in await server._registered_tools()}
|
||||
missing = [name for name in server.SHAPED_RESULT_TOOLS if name not in tools]
|
||||
assert not missing, f"SHAPED_RESULT_TOOLS names unregistered tools: {missing}"
|
||||
for name in server.SHAPED_RESULT_TOOLS:
|
||||
properties = (tools[name].parameters or {}).get("properties", {})
|
||||
for param in server.RESULT_SHAPE_PARAMS:
|
||||
assert param in properties, f"{name} is listed as shaped but has no {param!r} parameter"
|
||||
|
||||
|
||||
async def test_no_tool_carries_the_parameters_without_being_declared_shaped():
|
||||
"""The closed list is checked in both directions, so it cannot silently rot."""
|
||||
tools = await server._registered_tools()
|
||||
carriers = {
|
||||
tool.name
|
||||
for tool in tools
|
||||
if set(server.RESULT_SHAPE_PARAMS) & set((tool.parameters or {}).get("properties", {}))
|
||||
}
|
||||
assert carriers == set(server.SHAPED_RESULT_TOOLS)
|
||||
|
||||
|
||||
async def test_a_shaped_tool_never_carries_only_half_the_mechanism():
|
||||
for tool in await server._registered_tools():
|
||||
properties = set((tool.parameters or {}).get("properties", {}))
|
||||
present = [param for param in server.RESULT_SHAPE_PARAMS if param in properties]
|
||||
assert len(present) in (0, len(server.RESULT_SHAPE_PARAMS)), (
|
||||
f"{tool.name} declares {present} — the two parameters ship together or not at all"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_parameter_schemas_are_identical_across_every_shaped_tool():
|
||||
"""One spelling everywhere: no per-tool dialect of the same idea."""
|
||||
tools = {t.name: t for t in await server._registered_tools()}
|
||||
seen: dict[str, list[tuple[str, str]]] = {param: [] for param in server.RESULT_SHAPE_PARAMS}
|
||||
for name in server.SHAPED_RESULT_TOOLS:
|
||||
properties = (tools[name].parameters or {})["properties"]
|
||||
for param in server.RESULT_SHAPE_PARAMS:
|
||||
seen[param].append((name, dumps(properties[param])))
|
||||
for param, entries in seen.items():
|
||||
distinct = {schema for _, schema in entries}
|
||||
assert len(distinct) == 1, f"{param!r} is spelled {len(distinct)} different ways: {entries}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backwards compatibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_omitting_both_parameters_returns_the_untouched_record(client):
|
||||
await _draw(client, circles=3, lines=0)
|
||||
rows = _rows(await client.call_tool("entity_list", {}))
|
||||
assert isinstance(rows, list) and len(rows) == 3
|
||||
assert set(rows[0]) == set(ENTITY_FIELDS), "the default record must keep every field"
|
||||
assert rows[0]["properties"]["radius"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
async def test_explicit_defaults_are_the_same_value_as_omitting_them(client):
|
||||
await _draw(client, circles=4, lines=4)
|
||||
omitted = _rows(await client.call_tool("entity_list", {}))
|
||||
explicit = _rows(await client.call_tool("entity_list", {"fields": None, "compact": False}))
|
||||
assert omitted == explicit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool,args,spec",
|
||||
[
|
||||
("layer_list", {}, LayerInfo),
|
||||
("block_list", {}, BlockInfo),
|
||||
("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, EntityInfo),
|
||||
("analysis_select_by_type", {"entity_type": "CIRCLE"}, EntityInfo),
|
||||
("analysis_find_in_region", {"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6}, EntityInfo),
|
||||
("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, EntityInfo),
|
||||
],
|
||||
)
|
||||
async def test_default_shape_is_a_list_of_full_records(client, tool, args, spec):
|
||||
await _draw(client, circles=3, lines=3)
|
||||
rows = _rows(await client.call_tool(tool, args))
|
||||
assert isinstance(rows, list)
|
||||
for row in rows:
|
||||
assert set(row) == set(spec.__dataclass_fields__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_projection_keeps_only_the_named_fields_in_order(client):
|
||||
await _draw(client, circles=5, lines=0)
|
||||
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "type"]}))
|
||||
assert all(list(row) == ["handle", "type"] for row in rows)
|
||||
assert all(row["type"] == "CIRCLE" for row in rows)
|
||||
|
||||
|
||||
async def test_the_projection_order_is_the_callers_not_the_dataclasses(client):
|
||||
await _draw(client, circles=3, lines=0)
|
||||
rows = _rows(await client.call_tool("entity_list", {"fields": ["layer", "handle"]}))
|
||||
assert all(list(row) == ["layer", "handle"] for row in rows)
|
||||
|
||||
|
||||
async def test_a_repeated_field_is_projected_once(client):
|
||||
"""Repeats are a caller slip, not an error — but they must not double the column."""
|
||||
await _draw(client, circles=2, lines=0)
|
||||
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "handle", "type"]}))
|
||||
assert all(list(row) == ["handle", "type"] for row in rows)
|
||||
|
||||
|
||||
async def test_a_dict_valued_field_can_still_be_taken_whole(client):
|
||||
await _draw(client, circles=2, lines=0)
|
||||
rows = _rows(await client.call_tool("entity_list", {"fields": ["properties"]}))
|
||||
assert all(set(row) == {"properties"} for row in rows)
|
||||
assert rows[0]["properties"]["radius"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
async def test_projection_reaches_one_nested_property(client):
|
||||
await _draw(client, circles=3, lines=0)
|
||||
rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "properties.radius"]}))
|
||||
assert [row["properties.radius"] for row in rows] == [1.0, 2.0, 3.0]
|
||||
|
||||
|
||||
async def test_a_nested_key_absent_from_some_rows_is_null_not_an_error(client):
|
||||
"""LINEs have no radius; the column exists because CIRCLEs in the set do."""
|
||||
await _draw(client, circles=2, lines=2)
|
||||
rows = _rows(await client.call_tool("entity_list", {"fields": ["type", "properties.radius"]}))
|
||||
by_type = {row["type"]: row["properties.radius"] for row in rows}
|
||||
assert by_type["CIRCLE"] is not None
|
||||
assert by_type["LINE"] is None
|
||||
|
||||
|
||||
async def test_an_unknown_field_is_an_error_naming_the_valid_fields(client):
|
||||
await _draw(client, circles=2, lines=0)
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await client.call_tool("entity_list", {"fields": ["handel"]})
|
||||
message = str(excinfo.value)
|
||||
assert "handel" in message
|
||||
for field in ENTITY_FIELDS:
|
||||
assert field in message, f"the error must name {field!r} as a valid field"
|
||||
|
||||
|
||||
async def test_an_unknown_nested_key_is_an_error_naming_the_available_keys(client):
|
||||
await _draw(client, circles=2, lines=0)
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await client.call_tool("entity_list", {"fields": ["properties.diameter"]})
|
||||
message = str(excinfo.value)
|
||||
assert "diameter" in message
|
||||
assert "radius" in message, "the error must list the sub-keys this result actually has"
|
||||
|
||||
|
||||
async def test_an_empty_projection_is_an_error_not_an_empty_column(client):
|
||||
await _draw(client, circles=2, lines=0)
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await client.call_tool("entity_list", {"fields": []})
|
||||
assert "handle" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_the_valid_field_list_follows_the_row_dataclass_not_entityinfo(client):
|
||||
"""layer_list rows are LayerInfo, so its error must name LayerInfo's fields."""
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
await client.call_tool("layer_list", {"fields": ["handle"]})
|
||||
message = str(excinfo.value)
|
||||
assert "is_frozen" in message and "lineweight" in message
|
||||
assert "linetype" in message
|
||||
|
||||
|
||||
async def test_projection_works_on_every_shaped_collection_tool(client):
|
||||
await _draw(client, circles=3, lines=3)
|
||||
cases = [
|
||||
("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, ["handle", "type"]),
|
||||
("analysis_select_by_type", {"entity_type": "LINE"}, ["handle", "layer"]),
|
||||
(
|
||||
"analysis_find_in_region",
|
||||
{"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6},
|
||||
["handle", "type"],
|
||||
),
|
||||
("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, ["handle"]),
|
||||
("layer_list", {}, ["name", "color"]),
|
||||
("block_list", {}, ["name"]),
|
||||
]
|
||||
for tool, args, fields in cases:
|
||||
rows = _rows(await client.call_tool(tool, {**args, "fields": fields}))
|
||||
assert isinstance(rows, list), tool
|
||||
for row in rows:
|
||||
assert list(row) == fields, tool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compact envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_compact_returns_a_columnar_envelope(client):
|
||||
await _draw(client, circles=4, lines=0)
|
||||
envelope = _rows(await client.call_tool("entity_list", {"compact": True}))
|
||||
assert isinstance(envelope, dict)
|
||||
assert set(envelope) == {
|
||||
"fields",
|
||||
"rows",
|
||||
"count",
|
||||
"offset",
|
||||
"total",
|
||||
"truncated",
|
||||
"next_offset",
|
||||
}
|
||||
assert envelope["fields"] == list(ENTITY_FIELDS)
|
||||
assert envelope["count"] == 4
|
||||
assert all(len(row) == len(envelope["fields"]) for row in envelope["rows"])
|
||||
|
||||
|
||||
async def test_compact_rows_carry_the_same_values_as_the_dict_form(client):
|
||||
await _draw(client, circles=3, lines=2)
|
||||
fields = ["handle", "type", "layer"]
|
||||
dicts = _rows(await client.call_tool("entity_list", {"fields": fields}))
|
||||
envelope = _rows(await client.call_tool("entity_list", {"fields": fields, "compact": True}))
|
||||
assert envelope["fields"] == fields
|
||||
assert envelope["rows"] == [[row[name] for name in fields] for row in dicts]
|
||||
|
||||
|
||||
async def test_compact_is_much_cheaper_than_the_default_record(client):
|
||||
"""The point of the exercise, guarded as a ratio rather than a byte count."""
|
||||
await _draw(client, circles=60, lines=60)
|
||||
default = dumps(_rows(await client.call_tool("entity_list", {"limit": 120})))
|
||||
projected = dumps(
|
||||
_rows(
|
||||
await client.call_tool(
|
||||
"entity_list",
|
||||
{"limit": 120, "fields": ["handle", "type", "layer"], "compact": True},
|
||||
)
|
||||
)
|
||||
)
|
||||
assert len(projected) < len(default) / 5, (
|
||||
f"projected+compact was {len(projected)} chars against {len(default)} — "
|
||||
"the mechanism is not paying for its schema cost"
|
||||
)
|
||||
|
||||
|
||||
async def test_compact_on_a_shaped_tool_that_already_returns_a_dict(client):
|
||||
"""``selection_get``'s collection is nested, so its envelope lands under it."""
|
||||
result = (await client.call_tool("selection_get", {"compact": True})).structured_content
|
||||
assert isinstance(result["entities"], dict)
|
||||
assert result["entities"]["fields"] == list(ENTITY_FIELDS)
|
||||
assert result["entities"]["rows"] == []
|
||||
plain = (await client.call_tool("selection_get", {})).structured_content
|
||||
assert plain["entities"] == [], "the default must stay a plain list"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Truncation honesty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_paged_entity_list_reports_the_total_it_did_not_return(client):
|
||||
await _draw(client, circles=40, lines=40)
|
||||
envelope = _rows(
|
||||
await client.call_tool("entity_list", {"limit": 10, "compact": True, "fields": ["handle"]})
|
||||
)
|
||||
assert envelope["count"] == 10
|
||||
assert envelope["total"] == 80, "total counts the matching set, not the page"
|
||||
assert envelope["truncated"] is True
|
||||
assert envelope["next_offset"] == 10
|
||||
|
||||
|
||||
async def test_the_last_page_is_not_reported_as_truncated(client):
|
||||
await _draw(client, circles=10, lines=0)
|
||||
envelope = _rows(
|
||||
await client.call_tool(
|
||||
"entity_list", {"limit": 10, "offset": 5, "compact": True, "fields": ["handle"]}
|
||||
)
|
||||
)
|
||||
assert (envelope["count"], envelope["offset"], envelope["total"]) == (5, 5, 10)
|
||||
assert envelope["truncated"] is False
|
||||
assert envelope["next_offset"] is None
|
||||
|
||||
|
||||
async def test_total_honours_the_same_filters_as_the_page(client):
|
||||
await _draw(client, circles=20, lines=30)
|
||||
envelope = _rows(
|
||||
await client.call_tool(
|
||||
"entity_list",
|
||||
{"type_filter": "LINE", "limit": 5, "compact": True, "fields": ["handle"]},
|
||||
)
|
||||
)
|
||||
assert envelope["total"] == 30, "a filtered page must be measured against the filtered set"
|
||||
envelope = _rows(
|
||||
await client.call_tool(
|
||||
"entity_list",
|
||||
{"layer_filter": "GEOMETRY", "limit": 5, "compact": True, "fields": ["handle"]},
|
||||
)
|
||||
)
|
||||
assert envelope["total"] == 20
|
||||
|
||||
|
||||
async def test_the_max_list_limit_cap_is_reported_as_truncation(client, monkeypatch):
|
||||
"""``analysis_select_by_layer`` capped silently in the value before this."""
|
||||
monkeypatch.setattr(config.settings, "max_list_limit", 4)
|
||||
await _draw(client, circles=9, lines=0)
|
||||
envelope = _rows(
|
||||
await client.call_tool(
|
||||
"analysis_select_by_layer",
|
||||
{"layer_name": "GEOMETRY", "compact": True, "fields": ["handle"]},
|
||||
)
|
||||
)
|
||||
assert envelope["count"] == 4
|
||||
assert envelope["total"] == 9
|
||||
assert envelope["truncated"] is True
|
||||
assert envelope["next_offset"] == 4
|
||||
|
||||
|
||||
async def test_an_untruncated_selection_says_so(client):
|
||||
await _draw(client, circles=6, lines=0)
|
||||
envelope = _rows(
|
||||
await client.call_tool(
|
||||
"analysis_select_by_type",
|
||||
{"entity_type": "CIRCLE", "compact": True, "fields": ["handle"]},
|
||||
)
|
||||
)
|
||||
assert (envelope["count"], envelope["total"]) == (6, 6)
|
||||
assert envelope["truncated"] is False
|
||||
|
||||
|
||||
async def test_default_mode_still_truncates_the_way_it_always_did(client, monkeypatch):
|
||||
"""The compatibility contract cuts both ways: the old value, cap included."""
|
||||
monkeypatch.setattr(config.settings, "max_list_limit", 4)
|
||||
await _draw(client, circles=9, lines=0)
|
||||
rows = _rows(await client.call_tool("analysis_select_by_layer", {"layer_name": "GEOMETRY"}))
|
||||
assert isinstance(rows, list) and len(rows) == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# entity_count — the backend method the honest `total` needs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_entity_count_matches_a_full_listing(backend):
|
||||
for i in range(7):
|
||||
await backend.entity_create_circle(float(i), 0.0, 1.0, layer="GEOMETRY")
|
||||
for i in range(5):
|
||||
await backend.entity_create_line(0.0, float(i), 1.0, float(i), layer="HIDDEN")
|
||||
assert await backend.entity_count() == 12
|
||||
assert await backend.entity_count(type_filter="CIRCLE") == 7
|
||||
assert await backend.entity_count(layer_filter="HIDDEN") == 5
|
||||
assert await backend.entity_count(type_filter="LINE", layer_filter="GEOMETRY") == 0
|
||||
|
||||
|
||||
async def test_entity_count_filters_case_insensitively_like_entity_list(backend):
|
||||
await backend.entity_create_circle(0.0, 0.0, 1.0, layer="GEOMETRY")
|
||||
assert await backend.entity_count(type_filter="circle") == 1
|
||||
assert await backend.entity_count(layer_filter="geometry") == 1
|
||||
|
||||
|
||||
async def test_entity_count_is_on_the_backend_contract():
|
||||
from backends.base import AutoCADBackend
|
||||
|
||||
assert "entity_count" in AutoCADBackend.__abstractmethods__
|
||||
|
||||
|
||||
async def test_entity_count_never_materialises_the_rows(backend, monkeypatch):
|
||||
"""A count that paged through entity_list would defeat its own purpose."""
|
||||
|
||||
async def _forbidden(*args, **kwargs):
|
||||
raise AssertionError("entity_count must not call entity_list")
|
||||
|
||||
monkeypatch.setattr(backend, "entity_list", _forbidden)
|
||||
await backend.entity_create_circle(0.0, 0.0, 1.0)
|
||||
assert await backend.entity_count() == 1
|
||||
@@ -0,0 +1,94 @@
|
||||
"""M5 — handle grounding: connecting the picture to the handles.
|
||||
|
||||
A screenshot shows the model what the drawing looks like and nothing it can act
|
||||
on. Every modify tool takes a handle, and there was no way to tell which of the
|
||||
hex strings from `entity_list` is the circle in the top-left — so the model
|
||||
either guessed or re-derived the mapping from coordinates, which is exactly what
|
||||
CLAUDE.md's no-coordinate-guessing rule exists to prevent.
|
||||
|
||||
The overlay labels each entity with its handle at its own bounding-box centre,
|
||||
so "the circle at the top-left" and "handle 2F" become the same statement.
|
||||
|
||||
Off by default: labels are ink on the drawing, and on a busy sheet they would
|
||||
bury the geometry they are meant to explain. When there are more entities than
|
||||
labels allowed, the image says so rather than labelling a subset and letting it
|
||||
read as the whole picture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
||||
|
||||
|
||||
def _png_size(data: bytes) -> tuple[int, int]:
|
||||
"""Width/height straight out of the PNG IHDR — no Pillow needed."""
|
||||
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
|
||||
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
|
||||
|
||||
|
||||
async def test_a_plain_screenshot_is_unchanged(backend):
|
||||
await backend.entity_create_circle(10, 10, 5)
|
||||
png = await backend.view_screenshot()
|
||||
|
||||
assert png is not None
|
||||
assert _png_size(png)[0] > 0
|
||||
|
||||
|
||||
async def test_the_overlay_changes_the_image(backend):
|
||||
"""The labels have to actually be drawn, not merely computed."""
|
||||
await backend.entity_create_circle(10, 10, 5)
|
||||
await backend.entity_create_line(0, 0, 20, 0)
|
||||
|
||||
plain = await backend.view_screenshot()
|
||||
labelled = await backend.view_screenshot(overlay_handles=True)
|
||||
|
||||
assert labelled is not None
|
||||
assert labelled != plain, "the overlay produced a byte-identical image"
|
||||
|
||||
|
||||
async def test_the_overlay_reports_which_handles_it_drew(backend):
|
||||
circle = await backend.entity_create_circle(10, 10, 5)
|
||||
line = await backend.entity_create_line(0, 0, 20, 0)
|
||||
|
||||
report = await backend.view_screenshot_grounded()
|
||||
|
||||
assert set(report["labels"]) == {circle.handle, line.handle}
|
||||
assert report["labelled"] == 2
|
||||
assert report["total"] == 2
|
||||
assert report["truncated"] is False
|
||||
assert report["png"][:8] == b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
async def test_each_label_sits_on_its_own_entity(backend):
|
||||
"""A label in the wrong place is worse than no label — it grounds a lie."""
|
||||
left = await backend.entity_create_circle(-50, 0, 5)
|
||||
right = await backend.entity_create_circle(50, 0, 5)
|
||||
|
||||
report = await backend.view_screenshot_grounded()
|
||||
|
||||
assert report["labels"][left.handle][0] == pytest.approx(-50, abs=1.0)
|
||||
assert report["labels"][right.handle][0] == pytest.approx(50, abs=1.0)
|
||||
|
||||
|
||||
async def test_a_crowded_drawing_says_it_only_labelled_some(backend):
|
||||
for i in range(30):
|
||||
await backend.entity_create_circle(i * 10, 0, 2)
|
||||
|
||||
report = await backend.view_screenshot_grounded(max_labels=10)
|
||||
|
||||
assert report["labelled"] == 10
|
||||
assert report["total"] == 30
|
||||
assert report["truncated"] is True, (
|
||||
"labelling a subset silently would let the picture read as the whole drawing"
|
||||
)
|
||||
|
||||
|
||||
async def test_an_empty_drawing_grounds_nothing_without_failing(backend):
|
||||
report = await backend.view_screenshot_grounded()
|
||||
assert report["labels"] == {}
|
||||
assert report["total"] == 0
|
||||
assert report["png"] is not None
|
||||
+117
-1
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from security import sanitize_command, sanitize_lisp, validate_path
|
||||
from security import command_verbs, sanitize_command, sanitize_lisp, validate_path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path validation tests
|
||||
@@ -276,3 +278,117 @@ class TestSanitizerBypassVectors:
|
||||
|
||||
def test_lisp_legitimate_not_overblocked(self):
|
||||
assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))"
|
||||
|
||||
|
||||
# ── T0.8-A — what the guards actually guard ────────────────────────────────
|
||||
|
||||
|
||||
class TestNonLocalPathAnchors:
|
||||
r"""Windows accepts several spellings of the same file that compare unequal.
|
||||
|
||||
``_BLOCKED_PATH_PATTERNS`` rejects the backslash forms, but ``Path.resolve()``
|
||||
normalises the *forward-slash* spellings into them afterwards — so
|
||||
``//?/C:/Windows/win.ini`` slipped past the pattern check and then compared
|
||||
unequal to ``C:/Windows`` in the system-directory loop. Proven: it returned a
|
||||
handle that ``os.path.samefile``'d ``C:\Windows\win.ini``.
|
||||
"""
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
|
||||
def test_extended_length_prefix_is_rejected(self):
|
||||
with pytest.raises(ToolError, match="not a plain local drive path"):
|
||||
validate_path("//?/C:/Windows/win.ini")
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
|
||||
def test_unc_forward_slash_form_is_rejected(self):
|
||||
with pytest.raises(ToolError, match="not a plain local drive path"):
|
||||
validate_path("//localhost/C$/Windows/win.ini")
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
|
||||
def test_device_form_still_reaches_the_system_directory_check(self):
|
||||
"""``//./C:/...`` normalises to a plain drive path — it must be caught
|
||||
by the system-directory rule, not by the anchor rule, or the anchor
|
||||
check would be masking rather than adding a guard."""
|
||||
with pytest.raises(ToolError, match="system directory"):
|
||||
validate_path("//./C:/Windows/win.ini")
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept")
|
||||
def test_an_ordinary_path_is_unaffected(self, tmp_path):
|
||||
assert validate_path(str(tmp_path / "part.dxf"))
|
||||
|
||||
|
||||
class TestLispStringLiteralsAreData:
|
||||
"""A quoted literal is drawing text, not code.
|
||||
|
||||
Reading inside quotes made the server refuse its own domain vocabulary: an
|
||||
engineering CAD server that rejects ``"Max load 500 kg"`` because the word
|
||||
"load" appears in a note is broken for the job it exists to do.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
'(setq desc "Max load 500 kg")',
|
||||
'(entmakex (list (cons 0 "TEXT") (cons 1 "LOAD CASE 1")))',
|
||||
'(setq layer "C:CENTER")',
|
||||
'(setq note "Design load = 12 kN")',
|
||||
],
|
||||
)
|
||||
def test_engineering_text_is_not_mistaken_for_code(self, expression):
|
||||
assert sanitize_lisp(expression) == expression
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
'(load "malicious.lsp")',
|
||||
'(vl-catch-all-apply (quote command) (list "ERASE"))',
|
||||
'(startapp "cmd")',
|
||||
"(eval (read user-input))",
|
||||
],
|
||||
)
|
||||
def test_the_dangerous_vectors_still_block(self, expression):
|
||||
with pytest.raises(ToolError):
|
||||
sanitize_lisp(expression)
|
||||
|
||||
|
||||
class TestCommandVerbScoping:
|
||||
"""Match the command, not the characters.
|
||||
|
||||
``\b(ERASE|...)\b`` over the whole string refused ``MTEXT 0,0 10,10 Do not
|
||||
erase this part`` — annotation text, not a command — while missing spellings
|
||||
AutoCAD accepts. SendCommand takes a macro: segments split on newline or
|
||||
``;``, and only a segment's FIRST token is a verb.
|
||||
"""
|
||||
|
||||
def test_the_repos_own_safe_fixture_still_passes(self):
|
||||
assert sanitize_command("_ZOOM E") == "_ZOOM E"
|
||||
|
||||
def test_zoom_extents_as_a_macro_passes(self):
|
||||
"""``E`` here is the Extents *option*, not the ERASE alias. Blocking the
|
||||
canonical zoom-extents macro is why single-letter aliases stay out of
|
||||
the verb set."""
|
||||
assert sanitize_command("_ZOOM\nE\n") == "_ZOOM\nE\n"
|
||||
|
||||
def test_annotation_text_mentioning_a_verb_passes(self):
|
||||
text = "MTEXT 0,0 10,10 Do not erase this part"
|
||||
assert sanitize_command(text) == text
|
||||
|
||||
def test_a_linetype_load_macro_passes(self):
|
||||
macro = "_-LINETYPE _LOAD CENTER acad.lin\n\n"
|
||||
assert sanitize_command(macro) == macro
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
["ERASE ALL", "_ZOOM E\n_ERASE", "SAVE", "VBASTMT", "ARX", ".SHELL", "'SCRIPT x.scr"],
|
||||
)
|
||||
def test_dangerous_verbs_are_blocked_in_every_decoration(self, command):
|
||||
with pytest.raises(ToolError):
|
||||
sanitize_command(command)
|
||||
|
||||
def test_the_refusal_names_the_offending_verb(self):
|
||||
"""Without this a user whose annotation was refused cannot tell why."""
|
||||
with pytest.raises(ToolError, match="ERASE"):
|
||||
sanitize_command("_ZOOM E\n_ERASE")
|
||||
|
||||
def test_command_verbs_strips_every_decorator(self):
|
||||
assert command_verbs("_-PURGE") == ["PURGE"]
|
||||
assert command_verbs("'_ZOOM E;.REGEN") == ["ZOOM", "REGEN"]
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
"""F1 — selection filters: window, polygon and property selection.
|
||||
|
||||
Three tools that answer "which entities do you mean?" without the caller having
|
||||
to memorise handles. Each carries a failure mode that is silent unless it is
|
||||
pinned here:
|
||||
|
||||
* **window vs crossing** is the whole point of the family. AutoCAD's *window*
|
||||
returns only entities that are *fully contained*; *crossing* also returns the
|
||||
ones that merely straddle the edge. Under the hood these are
|
||||
``select.bbox_inside`` and ``select.bbox_overlap`` — one character apart in a
|
||||
call, and getting them backwards produces a plausible-looking handle list that
|
||||
is quietly the wrong set. Every mode test below builds one entity inside, one
|
||||
straddling and one outside, and pins exactly which of the three comes back.
|
||||
* **a zero-area box** (``x1 == x2`` or ``y1 == y2``) can never contain anything.
|
||||
``select.Window((0, 0), (0, 100))`` returns ``[]`` — measured — so a tool that
|
||||
passed it through would answer "nothing is there" to a question that was
|
||||
really "you asked for nothing". It is refused instead.
|
||||
* **a polygon is not its bounding box.** A triangle and a square with identical
|
||||
bounding boxes must not select the same entities; the triangle test uses
|
||||
exactly that pair so a lazy bbox implementation cannot pass.
|
||||
* **mirrored geometry** is where stored coordinates and real position diverge. A
|
||||
mirrored CIRCLE keeps ``center = (30, 20)`` on disk and carries
|
||||
``extrusion = (0, 0, -1)``; it is *drawn* at (-30, 20), which is what
|
||||
``entity_get`` reports. Selection must agree with the reported position, or
|
||||
the caller would have to window an entity where nothing is visible.
|
||||
* **``selection_filter`` takes named parameters, not a query string,** on
|
||||
purpose. ``layout.query("LINE[nosuchattr=='x']")`` returns ``[]`` silently —
|
||||
measured — so a typo is indistinguishable from "no matches". Named parameters
|
||||
make the typo a ``TypeError`` at the tool boundary. The response carries
|
||||
``filtered_by`` for the same reason: a caller must be able to tell "I filtered
|
||||
by layer and found none" from "I never filtered by layer at all".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
# The selection box every window/polygon test uses.
|
||||
BOX = (0.0, 0.0, 100.0, 100.0)
|
||||
SQUARE_POLY = [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]]
|
||||
# Same bounding box as SQUARE_POLY, half the area.
|
||||
TRIANGLE_POLY = [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0]]
|
||||
|
||||
|
||||
async def _three_positions(backend):
|
||||
"""One entity fully inside BOX, one straddling its edge, one fully outside.
|
||||
|
||||
Plus a second contained entity on another layer, so a layer filter has
|
||||
something to remove without emptying the result.
|
||||
"""
|
||||
contained = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY")
|
||||
contained_other_layer = await backend.entity_create_circle(20, 80, 5, layer="HIDDEN")
|
||||
straddling = await backend.entity_create_line(90, 50, 110, 50, layer="GEOMETRY")
|
||||
outside = await backend.entity_create_circle(200, 200, 5, layer="HIDDEN")
|
||||
return {
|
||||
"contained": contained.handle,
|
||||
"contained_other_layer": contained_other_layer.handle,
|
||||
"straddling": straddling.handle,
|
||||
"outside": outside.handle,
|
||||
}
|
||||
|
||||
|
||||
async def _mixed_properties(backend):
|
||||
"""Three entities that differ in type, layer, colour, linetype and area."""
|
||||
circle = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY", color=1)
|
||||
square = await backend.entity_create_polyline(
|
||||
[[0, 0], [20, 0], [20, 20], [0, 20]], closed=True, layer="GEOMETRY", color=3
|
||||
)
|
||||
line = await backend.entity_create_line(0, 0, 50, 0, layer="HIDDEN", color=0, linetype="HIDDEN")
|
||||
return {"circle": circle.handle, "square": square.handle, "line": line.handle}
|
||||
|
||||
|
||||
# ── selection_window: the mode contract ─────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_window_returns_only_fully_contained_entities(backend):
|
||||
"""The straddling line is the one that must NOT come back."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="window")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert set(result["handles"]) == {
|
||||
handles["contained"],
|
||||
handles["contained_other_layer"],
|
||||
}
|
||||
|
||||
|
||||
async def test_a_crossing_also_returns_entities_that_straddle_the_edge(backend):
|
||||
"""Crossing = window + the straddlers. Never fewer, never the outside one."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="crossing")
|
||||
|
||||
assert set(result["handles"]) == {
|
||||
handles["contained"],
|
||||
handles["contained_other_layer"],
|
||||
handles["straddling"],
|
||||
}
|
||||
|
||||
|
||||
async def test_neither_mode_reaches_an_entity_outside_the_box(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
window = await backend.selection_window(*BOX, mode="window")
|
||||
crossing = await backend.selection_window(*BOX, mode="crossing")
|
||||
|
||||
assert handles["outside"] not in window["handles"]
|
||||
assert handles["outside"] not in crossing["handles"]
|
||||
|
||||
|
||||
async def test_crossing_is_a_strict_superset_of_window(backend):
|
||||
"""If these two ever come back equal for this drawing, one of them is wrong."""
|
||||
await _three_positions(backend)
|
||||
|
||||
window = set((await backend.selection_window(*BOX, mode="window"))["handles"])
|
||||
crossing = set((await backend.selection_window(*BOX, mode="crossing"))["handles"])
|
||||
|
||||
assert window < crossing
|
||||
assert len(crossing) - len(window) == 1
|
||||
|
||||
|
||||
async def test_window_is_the_default_mode(backend):
|
||||
"""The safer of the two: contained-only never over-selects."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX)
|
||||
|
||||
assert result["mode"] == "window"
|
||||
assert handles["straddling"] not in result["handles"]
|
||||
|
||||
|
||||
async def test_the_reported_count_matches_the_handles(backend):
|
||||
await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="crossing")
|
||||
|
||||
assert result["count"] == len(result["handles"]) == 3
|
||||
assert result["mode"] == "crossing"
|
||||
|
||||
|
||||
async def test_the_corners_may_be_given_in_any_order(backend):
|
||||
"""(x2, y2) top-right is a convention, not a requirement of the geometry."""
|
||||
await _three_positions(backend)
|
||||
|
||||
normal = await backend.selection_window(0, 0, 100, 100, mode="crossing")
|
||||
flipped = await backend.selection_window(100, 100, 0, 0, mode="crossing")
|
||||
|
||||
assert set(flipped["handles"]) == set(normal["handles"])
|
||||
assert flipped["count"] == normal["count"] == 3
|
||||
|
||||
|
||||
# ── selection_window: refusals ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_mode", ["inside", "fence", "WINDOWS", "", "all"])
|
||||
async def test_an_unknown_window_mode_is_refused(backend, bad_mode):
|
||||
"""Falling back to a default mode would answer a question nobody asked."""
|
||||
await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode=bad_mode)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "mode" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"box",
|
||||
[
|
||||
(10.0, 0.0, 10.0, 100.0), # zero width
|
||||
(0.0, 40.0, 100.0, 40.0), # zero height
|
||||
(5.0, 5.0, 5.0, 5.0), # a point
|
||||
],
|
||||
)
|
||||
async def test_a_zero_area_window_is_refused(backend, box):
|
||||
"""It can never contain anything, so [] would read as "nothing is there"."""
|
||||
await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*box, mode="crossing")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "area" in result["error"] or "zero" in result["error"]
|
||||
|
||||
|
||||
# ── selection_window: filters compose with the mode ─────────────────────────
|
||||
|
||||
|
||||
async def test_an_entity_type_filter_narrows_the_crossing_set(backend):
|
||||
"""The type filter must cut the crossing set, not replace it."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="crossing", entity_type="CIRCLE")
|
||||
|
||||
assert set(result["handles"]) == {
|
||||
handles["contained"],
|
||||
handles["contained_other_layer"],
|
||||
}
|
||||
assert handles["straddling"] not in result["handles"]
|
||||
|
||||
|
||||
async def test_an_entity_type_filter_can_keep_the_straddler_alone(backend):
|
||||
"""Same box, same mode, LINE instead of CIRCLE: the complement comes back."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="crossing", entity_type="LINE")
|
||||
|
||||
assert set(result["handles"]) == {handles["straddling"]}
|
||||
|
||||
|
||||
async def test_a_layer_filter_composes_with_crossing(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="crossing", layer="GEOMETRY")
|
||||
|
||||
assert set(result["handles"]) == {handles["contained"], handles["straddling"]}
|
||||
|
||||
|
||||
async def test_a_layer_filter_composes_with_window(backend):
|
||||
"""Same layer, window mode: the straddler drops out and only one is left."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(*BOX, mode="window", layer="GEOMETRY")
|
||||
|
||||
assert set(result["handles"]) == {handles["contained"]}
|
||||
|
||||
|
||||
async def test_type_and_layer_filters_compose_with_each_other(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(
|
||||
*BOX, mode="crossing", entity_type="CIRCLE", layer="HIDDEN"
|
||||
)
|
||||
|
||||
assert set(result["handles"]) == {handles["contained_other_layer"]}
|
||||
|
||||
|
||||
async def test_an_empty_selection_is_ok_with_zero_handles(backend):
|
||||
"""Nothing there is an answer, not an error — unlike a zero-area box."""
|
||||
await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_window(500, 500, 600, 600, mode="crossing")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["handles"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
# ── selection_window: mirrored geometry ─────────────────────────────────────
|
||||
|
||||
|
||||
async def _mirrored_circle(backend):
|
||||
"""A CIRCLE stored at (30, 20) but drawn at (-30, 20).
|
||||
|
||||
Mirroring about the Y axis gives extrusion (0, 0, -1) and leaves the stored
|
||||
centre untouched — measured. entity_get resolves it to WCS (-30, 20).
|
||||
"""
|
||||
source = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY")
|
||||
return (await backend.entity_mirror(source.handle, 0, 0, 0, 10, delete_original=True)).handle
|
||||
|
||||
|
||||
async def test_a_mirrored_entity_is_selected_where_entity_get_reports_it(backend):
|
||||
handle = await _mirrored_circle(backend)
|
||||
reported = await backend.entity_get(handle)
|
||||
assert reported.properties["center"] == [-30.0, 20.0]
|
||||
|
||||
result = await backend.selection_window(-40, 10, -20, 30, mode="window")
|
||||
|
||||
assert set(result["handles"]) == {handle}
|
||||
|
||||
|
||||
async def test_a_mirrored_entity_is_not_selected_at_its_stored_coordinates(backend):
|
||||
"""Selecting at (30, 20) would select an entity nobody can see there."""
|
||||
handle = await _mirrored_circle(backend)
|
||||
|
||||
result = await backend.selection_window(20, 10, 40, 30, mode="crossing")
|
||||
|
||||
assert handle not in result["handles"]
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
async def test_the_mirrored_coordinates_really_do_diverge_on_disk(backend, tmp_path):
|
||||
"""Proof the divergence is stored, not an artefact of the in-memory copy."""
|
||||
import ezdxf
|
||||
|
||||
handle = await _mirrored_circle(backend)
|
||||
path = tmp_path / "mirrored.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
|
||||
reloaded = ezdxf.readfile(str(path)).entitydb.get(handle)
|
||||
assert tuple(reloaded.dxf.extrusion) == (0.0, 0.0, -1.0)
|
||||
assert tuple(reloaded.dxf.center)[:2] == (30.0, 20.0)
|
||||
|
||||
selected = await backend.selection_window(-40, 10, -20, 30, mode="window")
|
||||
assert selected["handles"] == [handle]
|
||||
|
||||
|
||||
# ── selection_polygon ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_polygon_window_returns_only_fully_contained_entities(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(SQUARE_POLY, mode="window")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert set(result["handles"]) == {
|
||||
handles["contained"],
|
||||
handles["contained_other_layer"],
|
||||
}
|
||||
|
||||
|
||||
async def test_a_polygon_crossing_also_returns_the_straddler(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(SQUARE_POLY, mode="crossing")
|
||||
|
||||
assert set(result["handles"]) == {
|
||||
handles["contained"],
|
||||
handles["contained_other_layer"],
|
||||
handles["straddling"],
|
||||
}
|
||||
assert result["mode"] == "crossing"
|
||||
|
||||
|
||||
async def test_a_polygon_is_not_its_bounding_box(backend):
|
||||
"""The triangle has exactly SQUARE_POLY's bounding box and half its area.
|
||||
|
||||
Measured: both contained circles sit inside the square but poke out of the
|
||||
triangle's hypotenuse, so a bbox-only implementation returns two here and
|
||||
fails.
|
||||
"""
|
||||
await _three_positions(backend)
|
||||
|
||||
square = await backend.selection_polygon(SQUARE_POLY, mode="window")
|
||||
triangle = await backend.selection_polygon(TRIANGLE_POLY, mode="window")
|
||||
|
||||
assert square["count"] == 2
|
||||
assert triangle["handles"] == []
|
||||
|
||||
|
||||
async def test_the_triangle_still_reaches_them_in_crossing_mode(backend):
|
||||
"""They poke out of it — which is exactly what crossing is for."""
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(TRIANGLE_POLY, mode="crossing")
|
||||
|
||||
assert set(result["handles"]) == {
|
||||
handles["contained"],
|
||||
handles["contained_other_layer"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[[], [[0.0, 0.0]], [[0.0, 0.0], [100.0, 100.0]]],
|
||||
)
|
||||
async def test_a_polygon_needs_at_least_three_points(backend, bad):
|
||||
"""Two points enclose no area; a zero-area lasso catches nothing."""
|
||||
await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(bad, mode="crossing")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "3" in result["error"] or "three" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_mode", ["inside", "fence", ""])
|
||||
async def test_an_unknown_polygon_mode_is_refused(backend, bad_mode):
|
||||
await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(SQUARE_POLY, mode=bad_mode)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "mode" in result["error"]
|
||||
|
||||
|
||||
async def test_a_polygon_filter_composes_with_the_mode(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(SQUARE_POLY, mode="crossing", entity_type="LINE")
|
||||
|
||||
assert set(result["handles"]) == {handles["straddling"]}
|
||||
|
||||
|
||||
async def test_a_polygon_layer_filter_composes_with_the_mode(backend):
|
||||
handles = await _three_positions(backend)
|
||||
|
||||
result = await backend.selection_polygon(SQUARE_POLY, mode="window", layer="HIDDEN")
|
||||
|
||||
assert set(result["handles"]) == {handles["contained_other_layer"]}
|
||||
|
||||
|
||||
# ── selection_filter: what was actually filtered ────────────────────────────
|
||||
|
||||
|
||||
async def test_no_filters_returns_everything_and_says_it_filtered_by_nothing(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter()
|
||||
|
||||
assert result["ok"] is True
|
||||
assert set(result["handles"]) == set(handles.values())
|
||||
assert result["count"] == 3
|
||||
assert list(result["filtered_by"]) == []
|
||||
|
||||
|
||||
async def test_filtering_by_layer_names_the_layer_filter_in_the_response(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(layer="GEOMETRY")
|
||||
|
||||
assert set(result["handles"]) == {handles["circle"], handles["square"]}
|
||||
assert set(result["filtered_by"]) == {"layer"}
|
||||
|
||||
|
||||
async def test_an_empty_result_is_distinguishable_from_an_unfiltered_one(backend):
|
||||
"""The honesty case: "found none" and "never looked" must not both be [].
|
||||
|
||||
DIM exists and holds nothing, so the handle list is empty either way; only
|
||||
``filtered_by`` tells the caller a layer filter ran at all.
|
||||
"""
|
||||
await _mixed_properties(backend)
|
||||
await backend.layer_create("DIM")
|
||||
|
||||
filtered = await backend.selection_filter(layer="DIM")
|
||||
unfiltered = await backend.selection_filter()
|
||||
|
||||
assert filtered["ok"] is True
|
||||
assert filtered["count"] == 0
|
||||
assert set(filtered["filtered_by"]) == {"layer"}
|
||||
assert list(unfiltered["filtered_by"]) == []
|
||||
assert unfiltered["count"] == 3
|
||||
|
||||
|
||||
async def test_a_typo_in_a_filter_name_is_a_type_error_not_an_empty_list(backend):
|
||||
"""Why this tool takes named parameters instead of a query string.
|
||||
|
||||
``query("LINE[nosuchattr=='x']")`` answers [] — measured — and the caller
|
||||
reads it as "no matches". A wrong keyword must fail loudly instead.
|
||||
"""
|
||||
await _mixed_properties(backend)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
await backend.selection_filter(layar="GEOMETRY")
|
||||
|
||||
|
||||
async def test_every_applied_filter_is_reported_not_just_the_first(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(layer="GEOMETRY", entity_type="CIRCLE")
|
||||
|
||||
assert set(result["handles"]) == {handles["circle"]}
|
||||
assert set(result["filtered_by"]) == {"layer", "entity_type"}
|
||||
|
||||
|
||||
# ── selection_filter: the individual filters ────────────────────────────────
|
||||
|
||||
|
||||
async def test_filtering_by_entity_type(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(entity_type="LWPOLYLINE")
|
||||
|
||||
assert set(result["handles"]) == {handles["square"]}
|
||||
assert set(result["filtered_by"]) == {"entity_type"}
|
||||
|
||||
|
||||
async def test_filtering_by_colour(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(color=3)
|
||||
|
||||
assert set(result["handles"]) == {handles["square"]}
|
||||
assert set(result["filtered_by"]) == {"color"}
|
||||
|
||||
|
||||
async def test_colour_zero_is_byblock_not_no_filter(backend):
|
||||
"""ACI 0 is ByBlock, a real colour. ``if color:`` would drop this filter.
|
||||
|
||||
The failure it guards is the worst kind: the caller asks for ByBlock and
|
||||
gets the entire drawing back, reported as a successful colour filter.
|
||||
"""
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(color=0)
|
||||
|
||||
assert set(result["handles"]) == {handles["line"]}
|
||||
assert set(result["filtered_by"]) == {"color"}
|
||||
|
||||
|
||||
async def test_filtering_by_linetype(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(linetype="HIDDEN")
|
||||
|
||||
assert set(result["handles"]) == {handles["line"]}
|
||||
assert set(result["filtered_by"]) == {"linetype"}
|
||||
|
||||
|
||||
async def test_min_area_keeps_only_the_shapes_that_are_big_enough(backend):
|
||||
"""Measured: the square encloses 400, the circle 314.16."""
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(min_area=350.0)
|
||||
|
||||
assert set(result["handles"]) == {handles["square"]}
|
||||
assert set(result["filtered_by"]) == {"min_area"}
|
||||
|
||||
|
||||
async def test_min_area_admits_both_shapes_when_the_threshold_is_low(backend):
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(min_area=100.0)
|
||||
|
||||
assert set(result["handles"]) == {handles["circle"], handles["square"]}
|
||||
|
||||
|
||||
async def test_min_area_excludes_entities_that_enclose_no_area(backend):
|
||||
"""A LINE has no area at all — it is not an entity of area zero to be kept
|
||||
by a zero threshold, it is an entity the question does not apply to."""
|
||||
handles = await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(min_area=0.0)
|
||||
|
||||
assert handles["line"] not in result["handles"]
|
||||
assert set(result["filtered_by"]) == {"min_area"}
|
||||
|
||||
|
||||
async def test_a_negative_min_area_is_refused(backend):
|
||||
"""No area is negative, so the threshold is either a typo or a unit error."""
|
||||
await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(min_area=-1.0)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "min_area" in result["error"]
|
||||
|
||||
|
||||
async def test_the_filter_count_matches_the_handles(backend):
|
||||
await _mixed_properties(backend)
|
||||
|
||||
result = await backend.selection_filter(layer="GEOMETRY")
|
||||
|
||||
assert result["count"] == len(result["handles"]) == 2
|
||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@@ -96,8 +98,13 @@ async def test_zoom_window_reports_not_applied(backend):
|
||||
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
|
||||
|
||||
|
||||
async def test_undo_empty_stack_returns_error(backend):
|
||||
async def test_undo_empty_stack_returns_error(backend, monkeypatch):
|
||||
"""With history on but nothing done yet, undo declines rather than raising."""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4)
|
||||
await backend.drawing_new()
|
||||
|
||||
res = await backend.drawing_undo()
|
||||
|
||||
assert res["ok"] is False
|
||||
assert "undo" in res["error"].lower()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user