Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca1c79fd01 | ||
|
|
6b9860e68e | ||
|
|
e24b46d6f0 | ||
|
|
25f8737132 | ||
|
|
776fe5bcf1 | ||
|
|
a7c8a7a0b8 | ||
|
|
8f405bddf3 |
+19
-2
@@ -37,8 +37,9 @@ CAD_PROGID=AutoCAD.Application
|
||||
# Example: ALLOWED_PATHS=C:\Drawings,D:\Projects\CAD
|
||||
ALLOWED_PATHS=
|
||||
|
||||
# Maximum undo stack size (number of snapshots to keep)
|
||||
MAX_UNDO_STACK=50
|
||||
# Maximum undo stack size (number of snapshots to keep). The code's default is
|
||||
# 5; this file used to say 50, which was a documentation-only disagreement.
|
||||
MAX_UNDO_STACK=5
|
||||
|
||||
# Logging level: DEBUG | INFO | WARNING | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
@@ -75,3 +76,19 @@ EZDXF_UNDO_DEPTH=0
|
||||
|
||||
# Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported)
|
||||
ENABLE_3D=false
|
||||
|
||||
# --- Limits and remote access -------------------------------------------------
|
||||
# These four are read by config.py but were missing from this file, which made
|
||||
# it look like the complete list. Two of them are the remote-HTTP controls.
|
||||
|
||||
# Reject DXF input larger than this many bytes. 0 disables the check.
|
||||
MAX_DXF_BYTES=52428800
|
||||
|
||||
# Upper bound on list/selection response sizes.
|
||||
MAX_LIST_LIMIT=5000
|
||||
|
||||
# Permit a non-loopback HTTP bind. Requires MCP_AUTH_TOKEN as well.
|
||||
ALLOW_REMOTE_HTTP=false
|
||||
|
||||
# Bearer token required for remote HTTP. No default; remote binds refuse without it.
|
||||
MCP_AUTH_TOKEN=
|
||||
|
||||
@@ -19,20 +19,38 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# The same three lanes as ci.yml, not one. This job was a single
|
||||
# ubuntu/3.11 run, so a Windows-only defect could reach PyPI without any
|
||||
# Windows job having executed -- which is not hypothetical: v1.5.0 shipped
|
||||
# with `except pywintypes.com_error` raising NameError wherever pywin32 is
|
||||
# absent, and only the Linux legs of ci.yml caught it.
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
extra: pdf
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
extra: pdf
|
||||
- os: windows-latest
|
||||
python-version: "3.11"
|
||||
extra: full
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
id: python
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
|
||||
- name: Install the locked environment
|
||||
# `--locked` refuses to re-resolve, so the artifact that goes to PyPI is
|
||||
# gated by the exact dependency set the suite was run against — not by
|
||||
# whatever the index happens to serve at tag time. ruff's version comes
|
||||
# from [dependency-groups] dev via the lock; it is not restated here.
|
||||
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
|
||||
run: uv sync --locked --extra ${{ matrix.extra }} --python "${{ steps.python.outputs.python-path }}"
|
||||
- run: uv run --no-sync ruff check .
|
||||
- run: uv run --no-sync ruff format --check .
|
||||
- run: uv run --no-sync pytest -q
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# The ruff version here MUST equal the `ruff==` pin in pyproject.toml's
|
||||
# [dependency-groups] dev. It was v0.8.6 against a project pin of 0.16.1, and
|
||||
# ruff 0.16 formats Python inside Markdown while 0.8 does not -- so anyone who
|
||||
# ran `pre-commit install` produced commits that failed CI's
|
||||
# `ruff format --check .`. tests/test_release_consistency.py pins them equal.
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.6
|
||||
rev: v0.16.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
|
||||
+71
-4
@@ -5,6 +5,53 @@ 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).
|
||||
|
||||
## [1.5.1] — 2026-08-06
|
||||
|
||||
A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the
|
||||
adversarial pass over the rewritten README. 1.5.0 is yanked: its diameter and
|
||||
radius callouts put wrong numbers on drawings at default settings, and that is
|
||||
not something to leave installable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The HTTP bind guard failed open on the launch path it was written for.**
|
||||
`_guarded_run_async` read the bind address from the `host` kwarg and treated
|
||||
an absent one as loopback. It is not: `fastmcp run` forwards that kwarg only
|
||||
when `--host` was passed, and otherwise fastmcp resolves the host from
|
||||
`fastmcp.settings.host`, which is environment-backed. So
|
||||
`FASTMCP_HOST=0.0.0.0 fastmcp run server.py:mcp --transport http` bound every
|
||||
interface — 149 tools including file open/save and AutoCAD command execution,
|
||||
with no token — while the guard inspected the literal string `"127.0.0.1"`
|
||||
and waved it through. v1.4.0's changelog claimed the guard "now fires on
|
||||
every launch path"; it fired, and then looked at the wrong host. It now
|
||||
resolves the effective host, and says so in a warning if it cannot.
|
||||
- **`[com]` and `[full]` could not be installed off Windows.** Both extras
|
||||
listed `pywin32>=306` with no environment marker, and pywin32 publishes
|
||||
Windows wheels and *no sdist* — so `pip install autocad-mcp-pro[full]` died
|
||||
at resolution on Linux and macOS, and `[full]` is the extra this project's
|
||||
own README recommended to contributors. CI never caught it because every
|
||||
Linux lane installs `[pdf]` or syncs from `uv.lock`, whose resolution markers
|
||||
hide the gap. Both entries now carry `; sys_platform == "win32"`, and a
|
||||
release-consistency test fails if a Windows-only distribution loses its
|
||||
marker again.
|
||||
- **`drawing_settings({"dimscale": ...})` reported success and changed nothing
|
||||
headlessly.** It writes the `$DIMSCALE` header variable, which is what
|
||||
AutoCAD consults when it draws a dimension — so the call worked on the live
|
||||
backend. ezdxf renders from the dimstyle table entry and never reads the
|
||||
header, so the same call produced two different drawings on the two engines
|
||||
while both said `ok`. The header value is now folded into the per-dimension
|
||||
override on all five dimension tools, including `dimension_aligned` and
|
||||
`dimension_angular`, which do not share the tolerance-override path and were
|
||||
missed by the obvious fix. DIMSCALE 1.0 writes no override.
|
||||
|
||||
### Changed
|
||||
|
||||
- README: the "What you get" table qualified every engine-conditional family
|
||||
(boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch edge
|
||||
paths, headless undo) instead of listing them as if both engines had them,
|
||||
and the checkout install line no longer recommends an extra that cannot
|
||||
resolve on the reader's platform.
|
||||
|
||||
## [1.5.0] — 2026-08-06
|
||||
|
||||
### Added
|
||||
@@ -355,10 +402,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
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.
|
||||
- Five checks added to `correctness_suite.py` (26, was 21), and the two kinds
|
||||
read differently in the A/B table. Three measurement checks are new
|
||||
capability — v1.4.0 misses them by not having the method (`miss → pass`) —
|
||||
and are there because all three broke during this release. Two dimension
|
||||
checks are repaired defects: v1.4.0 has the methods and gets them wrong
|
||||
(`fail → pass`). Result against v1.4.0: 21/26 → 26/26, five fixed, zero
|
||||
regressed.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -704,6 +754,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `release.yml` now runs `ruff format --check .` alongside `ruff check .`,
|
||||
matching the CI lint job — the release gate previously skipped formatting.
|
||||
|
||||
- **Every headless diameter and radius dimension reported the wrong number, at
|
||||
default settings.** `add_diameter_dim` / `add_radius_dim` measure to the point
|
||||
they are given; `leader_length` is a *text placement*. Passing
|
||||
`centre + (radius + leader)` as that point made the leader part of the
|
||||
measurement, so a true ⌀40 bore was dimensioned **60** with the default
|
||||
`leader_length=10`, and **108** at 34. Radius callouts were `leader_length`
|
||||
too large the same way. This is the worst class of defect for this project —
|
||||
a wrong number, silently, on the one part of a drawing that exists to carry
|
||||
numbers — and it shipped in v1.4.0 and earlier. Now the size comes from
|
||||
`radius` and `location` only places the text.
|
||||
The live COM backend was never affected: ActiveX's `AddDimDiametric` takes
|
||||
the two chord points and the leader length as separate arguments, so the two
|
||||
engines had been disagreeing by `2 × leader_length` on the same call.
|
||||
Found while building the sheet in the README's hero image, whose own bore
|
||||
callout was wrong. Two checks added to `correctness_suite.py` (26, was 24);
|
||||
both report `fail → pass` against v1.4.0 rather than `miss → pass`, which is
|
||||
what distinguishes a repaired defect from new capability in that lane.
|
||||
- **A HATCH could not be measured, and the refusal listed HATCH as
|
||||
measurable.** `entity_measure` had no HATCH branch, so a hatch fell through
|
||||
to the generic error — whose own text named HATCH among the measurable types.
|
||||
|
||||
@@ -101,7 +101,7 @@ Tools are organized into sections (counts are indicative — `system_about` is a
|
||||
|
||||
**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`.
|
||||
**Benchmark matrix:** `benchmarks/tasks_v3.py` is the current set (15 tasks; `--matrix v2` reproduces the v1.4 ten). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (26 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.4.0`.
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
<div align="center">
|
||||
|
||||
# AutoCAD MCP Pro
|
||||
|
||||
Production-grade AutoCAD automation for AI agents — live through COM on
|
||||
Windows, or headless through ezdxf on any platform.
|
||||
**Production-grade AutoCAD automation for AI agents.**
|
||||
Live through COM on Windows, or headless through ezdxf on any platform — one typed contract, two engines.
|
||||
|
||||
[](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](LICENSE)
|
||||
[](pyproject.toml)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](https://pypi.org/project/autocad-mcp-pro/)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/blob/main/pyproject.toml)
|
||||
[](https://github.com/jlowin/fastmcp)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
|
||||
[](https://github.com/U-C4N/Autocad-MCP/stargazers)
|
||||
|
||||
One typed MCP contract controls two execution engines. Build and edit drawings,
|
||||
query exact geometry, apply engineering standards (ISO 128/129/286/1101),
|
||||
refine quality issues inside transactions, and deliver hashed artifacts with
|
||||
validation evidence.
|
||||
[Install](https://github.com/U-C4N/Autocad-MCP#install-in-10-seconds) · [Evidence](https://github.com/U-C4N/Autocad-MCP#evidence) · [Two engines](https://github.com/U-C4N/Autocad-MCP#the-two-engines) · [Limits](https://github.com/U-C4N/Autocad-MCP#what-this-release-is-bad-at) · [Config](https://github.com/U-C4N/Autocad-MCP#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
|
||||
|
||||
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates ·
|
||||
> 1245 collected tests. Runtime discovery through `system_about` is authoritative.
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-showcase.png" alt="ISO A3 sheet: a module-6, 24-tooth spur gear with a keyed bore, its section A-A, ISO 129 dimensions with an H7 bore fit, and an ISO 7200 title block" width="698">
|
||||
|
||||
<sub><b>Not a mockup.</b> Every line on this sheet was drawn by the tools this server exposes — ISO layers, involute gear geometry, DIN 6885 keyway, section A-A, ISO 129 dimensions, an ISO 286 <code>H7</code> bore fit, ISO 7200 title block — then rendered headlessly by <code>view_screenshot</code>. <code>drawing_critique</code> returns <b>0 issues</b> on it. Rebuild it with <code>python scripts/render_readme_showcase.py</code>.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1369 collected tests.
|
||||
> 154 is the **registered** count. A default install **advertises 149** over `tools/list`, because
|
||||
> `ENABLE_3D` is unset and the five `solid_*` tools stay hidden. `system_about` is the runtime authority.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Two problems, both measured before a line was written.
|
||||
|
||||
**A big MCP server is expensive to be connected to.** The full catalog puts an estimated **40,305 tokens** on the wire before the client has asked for anything, of which about **25,143** are the part a client actually forwards to the model — 149 advertised tools at ~169 tokens of name, description and input schema each. Turning on discovery replaces that catalog with two tools and **356** wire tokens.
|
||||
|
||||
**A drafter searches for `WBLOCK`, not for `block_create_from_entities`.** `WBLOCK`, `PEDIT`, `OVERKILL`, `LAYTRANS` and `MATCHPROP` appeared in *no* tool name or description, so they scored `df = 0` against a stock text index — not badly ranked, *absent*. No amount of tuning finds a word that is not in the data, so the fix was data: an authored corpus of **158 distinct AutoCAD command names and 672 synonym phrases** covering all 154 tools, layered onto the index. A test holds that df=0 list honest in both directions — it fails if one of those commands becomes findable without the corpus, and it fails if the corpus stops covering one.
|
||||
|
||||
| Advertised surface | Tools a client sees | `tools/list` on the wire |
|
||||
|---|---:|---:|
|
||||
| `TOOL_PROFILE=full` (default) | 149 | 40,305 tokens |
|
||||
| `TOOL_PROFILE=lean` | 47 | 12,131 tokens |
|
||||
| `DISCOVERY_MODE=search` | 2 | **356 tokens** |
|
||||
|
||||
> [!NOTE]
|
||||
> Token figures are **estimates** from an offline ratio counter (`ratio/v1`), not tokenizer counts — characters are the measured unit and the report says so. Under prompt caching the idle term is largely amortised, so read **113×** as the *uncached* wire ratio, not a bill. `python -m benchmarks.token_suite --tokenizer anthropic` counts for real, but it needs the `anthropic` package and credentials, neither of which is in this repo's lock — it refuses rather than silently falling back.
|
||||
|
||||
## Install in 10 seconds
|
||||
|
||||
@@ -25,310 +57,26 @@ pip install autocad-mcp-pro # or: uvx autocad-mcp-pro
|
||||
autocad-mcp # stdio MCP server, backend auto-selected
|
||||
```
|
||||
|
||||
Headless (no AutoCAD required, any OS) or live AutoCAD on Windows:
|
||||
Headless anywhere, or live AutoCAD on Windows:
|
||||
|
||||
```bash
|
||||
AUTOCAD_MCP_BACKEND=ezdxf autocad-mcp # portable DXF engine
|
||||
AUTOCAD_MCP_BACKEND=ezdxf autocad-mcp # portable DXF engine, no AutoCAD needed
|
||||
AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (pip install "autocad-mcp-pro[com]")
|
||||
```
|
||||
|
||||
Extras: `[com]` live AutoCAD (pywin32 + Pillow), `[pdf]` PDF/screenshot
|
||||
rendering (matplotlib), `[full]` everything. Working from a checkout is the
|
||||
same commands with `pip install -e ".[full]"` and `python server.py`.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Every number below is generated by a script in [`benchmarks/`](benchmarks/)
|
||||
and rendered to SVG with Python (matplotlib). No hand-edited charts, no
|
||||
adjectives.
|
||||
|
||||
### The leaderboard — nine public AutoCAD MCP servers, one rubric
|
||||
|
||||

|
||||
|
||||
| # | Project | Score | Evidence |
|
||||
|--:|---|---:|:---:|
|
||||
| 1 | **[U-C4N/Autocad-MCP](https://github.com/U-C4N/Autocad-MCP)** (this repo, v1.4.0) | **95** | A |
|
||||
| 2 | [varavista/autocad-mcp](https://github.com/varavista/autocad-mcp) | 84 | A |
|
||||
| 3 | [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | 83 | A |
|
||||
| 4 | [LokmenoWer/best-cad-mcp](https://github.com/LokmenoWer/best-cad-mcp) | 74 | B |
|
||||
| 5 | [AnCode666/multiCAD-mcp](https://github.com/AnCode666/multiCAD-mcp) | 73 | A |
|
||||
| 6 | [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | 70 | A |
|
||||
| 7 | [NCO-1986/AutoCAD_mcp](https://github.com/NCO-1986/AutoCAD_mcp) | 68 | B |
|
||||
| 8 | [daobataotie/CAD-MCP](https://github.com/daobataotie/CAD-MCP) | 35 | B |
|
||||
| 9 | [zh19980811/Easy-MCP-AutoCad](https://github.com/zh19980811/Easy-MCP-AutoCad) | 30 | C |
|
||||
|
||||
Fixed public 100-point rubric — CAD breadth (25), correctness and delivery
|
||||
(20), backends/platforms (15), engineering production (15), tests and
|
||||
maintenance (15), security and operations (10) — applied to the public source
|
||||
and documentation of each project
|
||||
([`benchmarks/source_review.json`](benchmarks/source_review.json), dated).
|
||||
Stars and raw tool counts score nothing. Evidence grade **A** = source plus
|
||||
locally executed evidence, **B** = deep source review without a full live run,
|
||||
**C** = documented tool surface only. This is a source review, not a shared
|
||||
live run — the lanes below are the runtime evidence.
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
### 1 · Live-run scores — same harness, pinned competitors
|
||||
|
||||

|
||||
|
||||
| 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% |
|
||||
|
||||
**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 --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
|
||||
```
|
||||
|
||||
### 2 · Task matrix — where the score comes from
|
||||
|
||||

|
||||
|
||||
The single score hides *which* capabilities exist, so the matrix shows every
|
||||
task against every server: green = verified pass, amber = partial, gray = the
|
||||
server documents no equivalent (planning, refinement, TABLE/MLEADER, hashed
|
||||
delivery), red = attempted but failed verification, blank = never run against
|
||||
that server.
|
||||
|
||||
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 |
|
||||
| Extra | Pulls in | For |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| *(none)* | `fastmcp`, `ezdxf`, `pydantic` | Headless DXF on any OS — **no rendering** |
|
||||
| `[com]` | `pywin32`, `Pillow` | Live AutoCAD control + window capture |
|
||||
| `[pdf]` | `matplotlib` | PDF export and headless PNG rendering, any platform |
|
||||
| `[full]` | everything above | Live AutoCAD and rendering in one install |
|
||||
|
||||
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.
|
||||
> [!NOTE]
|
||||
> The bare install **cannot draw pixels**. `ezdxf.addons.drawing.frontend` does an unconditional top-level `import PIL.Image`, so Pillow is required by *every* ezdxf render path — PNG screenshot and PDF export alike — not just by the COM window capture it is listed under. Add `[pdf]` (or `[full]`) if you want images on Linux or macOS.
|
||||
|
||||
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.
|
||||
From a checkout: `pip install -e ".[full]"` on Windows, or `pip install -e ".[pdf]"` elsewhere, then `python server.py`. (`[com]` and `[full]` carry a `sys_platform == "win32"` marker on `pywin32`, so they resolve everywhere — they simply install nothing Windows-specific off Windows.)
|
||||
|
||||
```bash
|
||||
python -m benchmarks.render_matrix_chart
|
||||
```
|
||||
|
||||
### 3 · Headless performance — scale evidence
|
||||
|
||||

|
||||
|
||||
| Workload | Wall time | Throughput / result |
|
||||
|---|---:|---|
|
||||
| Create 2,000 lines (individual calls) | 0.32 s | ~6,300 entities/s |
|
||||
| 10,000 lines: build + DXF export + reopen | 2.16 s | ~4,600 entities/s end-to-end |
|
||||
| Region query over 10,000 entities | 0.89 s | 2,500 matched |
|
||||
| Premium pass (layers + part + dims + full critique) | 0.18 s | 0 issues |
|
||||
|
||||
These are roughly 2–6× the numbers the v1.4 README printed, and **almost none
|
||||
of that is this release**: the old report was recorded on CPython 3.14 and this
|
||||
one on 3.11.15. Interpreter, not code. Re-measuring v1.4.0 in a worktree on the
|
||||
*same* interpreter with the *same* suite file gives the honest comparison, and
|
||||
it does not flatter us — median of three runs each:
|
||||
|
||||
| Workload | v1.4.0 | v1.5.0 | Change |
|
||||
|---|---:|---:|---|
|
||||
| Create 2,000 lines | 245 ms | 315 ms | **28% slower** |
|
||||
| 10,000-line roundtrip | 1,765 ms | 2,146 ms | **22% slower** |
|
||||
| Region query over 10,000 | 1,090 ms | 1,039 ms | within run-to-run spread |
|
||||
| Premium pass | 375 ms | 44 ms | faster, but the v1.4 spread (279–772 ms) is too wide to put a number on |
|
||||
|
||||
The regression is fully attributed: setting `EZDXF_CALL_TIMEOUT=0` returns
|
||||
v1.5.0 to **244.8 ms** and **1,679 ms** — v1.4.0's numbers exactly. The whole
|
||||
cost is the per-call `asyncio.wait_for` this release wrapped around every
|
||||
headless call, so that one hung ezdxf call can no longer wedge a server whose
|
||||
document lock is a single `asyncio.Lock`. That is a deliberate trade of ~25% of
|
||||
creation throughput for a deadlock that used to be unrecoverable, and the knob
|
||||
to take it back is documented rather than hidden.
|
||||
|
||||
Workloads call the same backend methods the MCP tools call, so server-side
|
||||
overhead is included. Self-measurement only — competitor servers would pay an
|
||||
extra stdio serialization cost that in-process runs do not, so no cross-server
|
||||
timing claims are made. Numbers move with hardware; the machine fingerprint is
|
||||
recorded in the report.
|
||||
|
||||
```bash
|
||||
python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.json
|
||||
python -m benchmarks.render_perf_chart
|
||||
```
|
||||
|
||||
### 4 · Version A/B — every release re-proves correctness
|
||||
|
||||
`compare_versions.py` runs the same 24 deterministic, headless correctness
|
||||
checks against the previous release tag and the current tree — each check in
|
||||
its own subprocess, so a hard crash counts as a miss instead of killing the
|
||||
run.
|
||||
|
||||
**v1.5.0 release gate** (baseline `v1.4.0`, report:
|
||||
[`ab-v1.4.0-vs-v1.5.0.json`](benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)):
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---|---:|---:|---:|---:|
|
||||
| v1.4.0 (baseline) | 21 / 24 | 87.5 % | — | — |
|
||||
| **v1.5.0** (this release) | **24 / 24** | **100 %** | 3 | **0** |
|
||||
|
||||
The three the baseline misses are *new capability, not repaired regressions*:
|
||||
v1.4.0 has no `entity_measure` and no boundary tracing, so it misses them by
|
||||
not having the method. They are in the suite because all three broke during
|
||||
v1.5.0 development and nothing outside their own unit tests would have caught
|
||||
it. The 21 pre-existing checks are **unchanged and all still pass** — the whole
|
||||
of the discovery layer, the contract split, the layout/viewport family and Wave
|
||||
A landed with **zero correctness regressions**.
|
||||
|
||||
For contrast, the same suite caught the v1.0.0 → v1.1.0 jump:
|
||||
|
||||
| Version | Checks passing | Pass rate |
|
||||
|---|---:|---:|
|
||||
| v1.0.0 (first public release) | 8 / 21 | 38.1 % |
|
||||
| v1.1.0 | 21 / 21 | 100 % |
|
||||
|
||||
```bash
|
||||
python benchmarks/compare_versions.py v1.4.0 --json ab.json
|
||||
```
|
||||
|
||||
Full rubric, caveats, method and boundaries for every lane:
|
||||
[`benchmarks/README.md`](benchmarks/README.md).
|
||||
|
||||
## What ships in v1.5
|
||||
|
||||
| Area | Tools |
|
||||
|---|---|
|
||||
| Drawing lifecycle | create, open, save, export DXF/PDF, audit **(now repairs)**, purge, undo/redo |
|
||||
| Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits |
|
||||
| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) |
|
||||
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 titleblock |
|
||||
| **Discovery** *(new)* | `DISCOVERY_MODE=search` replaces the catalog with `search_tools` + `call_tool`, ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK` score nothing against a stock index because the words are simply not in the data |
|
||||
| **Batching** *(new)* | `cad_batch` runs a whole step list in one round trip; `fields=` projection trims result-heavy responses |
|
||||
| **Paper space** *(new)* | full tab lifecycle (`layout_list/create/set_current/delete/rename/copy`), viewports (`viewport_create/list/set_scale/lock/delete`), `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=...)` |
|
||||
| **Selection** *(new)* | `selection_window` / `selection_polygon` / `selection_filter` — window vs crossing stated back to the caller, and a polygon tested against its own shape rather than its bounding box |
|
||||
| **Boundaries** *(new)* | `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities` chain loose edges into one closed polyline, arcs kept as bulges |
|
||||
| **Measurement** *(new)* | `analysis_measure_entity` measures what is *in* the drawing by handle — reading vertices back and shoelacing them loses 28.2% of the area on a semicircular edge |
|
||||
| **Hatch depth** *(new)* | gradients, in-place edits, typed edge boundaries, island styles |
|
||||
| **Annotation objects** *(new)* | WIPEOUT, REVCLOUD, MTEXT background masks, text find/replace |
|
||||
| **3D solids** *(opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) |
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0-100 score) |
|
||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||
| Profiles | `TOOL_PROFILE=lean` (~47 curated tools) for clients with tight tool caps; `core` was removed — the discovery layer replaced it |
|
||||
|
||||
**Every coordinate in and out of a tool is WCS, on both engines.** DXF stores
|
||||
CIRCLE / ARC / LWPOLYLINE / TEXT / INSERT geometry in a per-entity frame, and
|
||||
before v1.5.0 the word `extrusion` did not appear anywhere in this codebase —
|
||||
so a mirrored or rotated entity reported coordinates that were quietly wrong.
|
||||
`backends/ocs.py` now translates at the backend boundary; an entity in a plane
|
||||
tilted out of WCS XY reports `plane_normal` and omits the fields xy cannot
|
||||
express, rather than answering with a number that looks fine.
|
||||
|
||||
The tool surface evolves — ask `system_about` for the live grouped inventory
|
||||
and `system_capabilities` for per-backend support modes instead of copying
|
||||
lists from this README.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[MCP host / AI agent] --> B[FastMCP server]
|
||||
B --> C[Error - audit - timing - logging middleware]
|
||||
C --> D[Typed tool contract + tool profiles]
|
||||
D --> E[COM backend]
|
||||
D --> F[ezdxf backend]
|
||||
E --> G[Live AutoCAD]
|
||||
F --> H[Headless DXF]
|
||||
E --> I[Engineering quality + delivery]
|
||||
F --> I
|
||||
```
|
||||
|
||||
- `server.py` — FastMCP surface, lifespan, middleware, resources, prompts.
|
||||
- `backends/contracts/` — the typed contract, one module per domain.
|
||||
- `backends/capability.py` — capability model, typed refusals, `@capability`.
|
||||
- `backends/base.py` — shared data models + the contract composition.
|
||||
- `backends/com_backend.py` — single-STA-thread COM executor with per-call
|
||||
timeouts.
|
||||
- `backends/ezdxf_backend.py` — `asyncio.to_thread`-wrapped DXF engine with
|
||||
snapshot transactions.
|
||||
- `engineering/` — standards-aware planning, generators, critique, scoring,
|
||||
fits, refinement, validation, delivery.
|
||||
- `security.py` — path validation and command/AutoLISP sanitization.
|
||||
|
||||
## Backend capabilities
|
||||
|
||||
| Capability | COM backend | ezdxf backend |
|
||||
|---|:---:|:---:|
|
||||
| Live AutoCAD document control | ✓ | — |
|
||||
| Headless DXF creation and editing | — | ✓ |
|
||||
| Cross-platform execution | — | ✓ |
|
||||
| Transactions and rollback | ✓ | ✓ |
|
||||
| Paper-space layouts + viewports | ✓ | ✓ |
|
||||
| Viewport model-content rendering | ✓ | ✓ (no viewport borders) |
|
||||
| CHSPACE (`entity_change_space`) | — (no ActiveX member) | ✓ |
|
||||
| WIPEOUT / REVCLOUD | — (no ActiveX member) | ✓ |
|
||||
| Boundary tracing (BPOLY) | — (no ActiveX member) | ✓ |
|
||||
| Selection window / crossing / polygon | ✓ | ✓ |
|
||||
| Entity area by handle | ActiveX `.Area` | Analytic, bulges included |
|
||||
| HATCH filled area (islands subtracted) | AutoCAD's own number | Loops walked, `hatch_style` reported |
|
||||
| REGION / 3DSOLID area | ✓ | — (ACIS is opaque to ezdxf) |
|
||||
| TABLE and MLEADER semantics | Native | Portable composite |
|
||||
| 3D solids (opt-in) | ✓ | — (no headless ACIS) |
|
||||
| Screenshots | AutoCAD window | Matplotlib render |
|
||||
| Raw AutoCAD commands / AutoLISP | Opt-in | — |
|
||||
|
||||
`system_capabilities` returns this machine-readably (`native`, `composite`,
|
||||
`rendered`, `snapshot`, `shared`, `unsupported`) — use it at runtime instead of
|
||||
assuming.
|
||||
|
||||
## A production drawing workflow
|
||||
|
||||
```text
|
||||
drawing_preflight → drawing_plan → drawing_apply_iso_layers
|
||||
→ deterministic create/edit tools → dimension_auto (+ fit="H7" callouts)
|
||||
→ drawing_critique → drawing_refine
|
||||
→ layout_create + viewport_create → drawing_finalize → drawing_deliver
|
||||
```
|
||||
|
||||
## Connect an MCP client
|
||||
|
||||
### Claude Desktop / Cursor / any stdio host
|
||||
### Wire it to a client
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -338,88 +86,294 @@ drawing_preflight → drawing_plan → drawing_apply_iso_layers
|
||||
"env": {
|
||||
"AUTOCAD_MCP_BACKEND": "auto",
|
||||
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD",
|
||||
"TOOL_PROFILE": "full"
|
||||
"TOOL_PROFILE": "full",
|
||||
"DISCOVERY_MODE": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After `pip install autocad-mcp-pro` the `autocad-mcp` command is on PATH; from
|
||||
a checkout use `"command": "python"`, `"args": ["path/to/server.py"]`. The
|
||||
model vendor is not part of the server contract.
|
||||
Works with Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transport http --port 8000` — loopback only unless remote HTTP is explicitly enabled **and** a bearer token is set. The model vendor is not part of the server contract.
|
||||
|
||||
### Local HTTP
|
||||
## What you get
|
||||
|
||||
```bash
|
||||
autocad-mcp --transport http --port 8000
|
||||
```
|
||||
<table>
|
||||
<tr><th align="left">Area</th><th align="left">What it does</th></tr>
|
||||
<tr><td><b>Drawing lifecycle</b></td><td>create, open, save, export DXF/PDF, audit <i>(now repairs)</i>, purge; undo/redo live on COM and opt-in headless via <code>EZDXF_UNDO_DEPTH</code></td></tr>
|
||||
<tr><td><b>Geometry</b></td><td>lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits</td></tr>
|
||||
<tr><td><b>Annotation</b></td><td>ISO 129 toleranced dimensions, ISO 286 fits (<code>fit="H7"</code>), TABLE, MLEADER, GD&T frames and datums (ISO 1101)</td></tr>
|
||||
<tr><td><b>Engineering generators</b></td><td>involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block</td></tr>
|
||||
<tr><td><b>Discovery</b> ✨</td><td><code>search_tools</code> ranked over an AutoCAD command and synonym corpus — <code>FILLET</code>, <code>BPOLY</code>, <code>QSELECT</code>, <code>WBLOCK</code>, <code>OVERKILL</code>, <code>CHSPACE</code> each rank <b>#1</b> of the 154-tool registry</td></tr>
|
||||
<tr><td><b>Batching</b> ✨</td><td><code>cad_batch</code> runs a whole step list in one round trip; <code>fields=</code> projects the response on 11 result-heavy tools (a closed, gated list)</td></tr>
|
||||
<tr><td><b>Paper space</b> ✨</td><td>8 of the 12 layout tools are new — <code>layout_delete/rename/copy</code>, <code>viewport_list/set_scale/lock/delete</code>, <code>entity_change_space</code> (CHSPACE, headless only); layout listing, creation and activation, <code>viewport_create</code> and <code>drawing_export_pdf(layout=...)</code> shipped in v1.4.0, which wrongly declared headless viewport rendering impossible</td></tr>
|
||||
<tr><td><b>Selection</b> ✨</td><td>window vs crossing stated back to the caller; the polygon is tested as a shape, not as its bounding box — the entity side is still <i>its</i> bounding box, on both engines</td></tr>
|
||||
<tr><td><b>Boundaries</b> ✨</td><td><code>boundary_trace</code> (BOUNDARY/BPOLY) chains loose edges into one closed polyline; ARC edges keep their exact bulges, while a bulged polyline segment or any other curved edge is chained as its chord — <i>headless engine only</i></td></tr>
|
||||
<tr><td><b>Measurement</b> ✨</td><td><code>analysis_measure_entity</code> measures what is <i>in</i> the drawing, by handle</td></tr>
|
||||
<tr><td><b>Hatch depth</b> ✨</td><td>gradients, in-place edits and island styles on both; typed edge boundaries headless only</td></tr>
|
||||
<tr><td><b>Annotation objects</b> ✨</td><td>WIPEOUT, REVCLOUD, MTEXT background masks — <i>headless only, ActiveX exposes no member</i> — plus text find/replace on both</td></tr>
|
||||
<tr><td><b>3D solids</b></td><td><code>solid_box/cylinder/extrude/revolve/boolean</code> on live AutoCAD (<code>ENABLE_3D=true</code>) — <i>unverified against a live seat: they shipped in v1.4.0, and the v1.5.0 live run covered only what that release added</i></td></tr>
|
||||
<tr><td><b>Quality loop</b></td><td><code>drawing_preflight</code> → <code>drawing_plan</code> → <code>drawing_critique</code> → <code>drawing_refine</code> → <code>drawing_finalize</code> (0–100 score)</td></tr>
|
||||
<tr><td><b>Delivery</b></td><td><code>drawing_deliver</code>: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks</td></tr>
|
||||
</table>
|
||||
|
||||
Loopback only by default; a non-loopback bind is refused unless remote HTTP is
|
||||
explicitly enabled **and** a bearer token is configured.
|
||||
<sub>✨ = new in v1.5.0. 154 tools across 19 groups — <code>entity_creation</code> 18, <code>entity_modification</code> 15, <code>layers</code> 14, <code>analysis</code> 12, <code>layouts</code> 12, <code>premium</code> 12, <code>drawing</code> 11, <code>engineering</code> 10, <code>blocks</code> 8, <code>entity_query</code> 8, <code>system</code> 7, <code>dimensions</code> 5, <code>solids</code> 5, <code>corner_ops</code> 4, <code>view</code> 4, <code>batch</code> 3, <code>transactions</code> 3, <code>templates</code> 2, <code>validation</code> 1.</sub>
|
||||
|
||||
Alongside the tools, **6 MCP resources** cost nothing in the tool budget and are browsable at any time — `autocad://drawing/info`, `autocad://layers`, `autocad://blocks`, `autocad://entities/stats`, `autocad://entities/{layer_name}`, `autocad://system/status` — plus **5 prompt templates** (`prompt_mechanical_drawing`, `prompt_floor_plan`, `prompt_electrical_schematic`, `prompt_pid_diagram`, `prompt_quick_drawing`).
|
||||
|
||||
### Two rules worth knowing before you draw
|
||||
|
||||
**Coordinates come out in WCS, and the exceptions are declared rather than hidden.** DXF stores CIRCLE / ARC / LWPOLYLINE / TEXT / INSERT geometry in a per-entity frame, and before v1.5.0 the word `extrusion` appeared nowhere in this codebase, so a mirrored or rotated entity reported coordinates that were quietly wrong. An entity in a plane tilted out of WCS XY now reports `plane_normal` and omits the fields xy cannot express, rather than answering with a number that looks fine.
|
||||
|
||||
Three things stay in the entity frame on purpose, and `system_capabilities`' `ocs_normalized` reason string is the live list: **TEXT `rotation`** (a mirrored TEXT is mirror-imaged, and no single scalar angle expresses that), **z components**, and **HATCH boundary geometry**. On the live backend that same reason string also records `2d_polyline_coordinates_not_verified_against_live_autocad` — read it at runtime rather than trusting this paragraph.
|
||||
|
||||
**Never read vertices back and shoelace them.** That loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`.
|
||||
|
||||
## The two engines
|
||||
|
||||
One contract, split across 19 modules in `backends/contracts/`. Most are ABCs; `gdt.py` and `settings.py` are concrete shared mixins rather than interfaces. Optional methods are declarative — `@capability(key, reason=...)` supplies a default that *refuses* — and a test holds the two backends' capability key sets equal, so a key cannot exist on one engine and be forgotten on the other.
|
||||
|
||||
There are **27 capability keys**. `system_capabilities` returns the live map, and you should read it at runtime rather than trusting the table below, because five of the 27 depend on how the machine is set up:
|
||||
|
||||
| Capability | COM (live AutoCAD) | ezdxf (headless) |
|
||||
|---|:---:|:---:|
|
||||
| Live document control | ✅ | — |
|
||||
| Cross-platform, no AutoCAD | — | ✅ |
|
||||
| Transactions and rollback | ✅ *undo mark; rollback sends `_UNDO B` and AutoCAD never confirms it landed — best-effort, not atomic* | ✅ *full DXF snapshot, restored synchronously* |
|
||||
| Paper-space layouts + viewports | ✅ | ✅ |
|
||||
| Viewport model-content rendering | ✅ | ✅ ᵐ *(no viewport borders)* |
|
||||
| Selection window / crossing / polygon | ✅ | ✅ |
|
||||
| Entity area by handle | ActiveX `.Area` | Analytic, bulges included |
|
||||
| HATCH filled area (islands subtracted) | AutoCAD's own number — *measured: a r20 hatch with an r10 island returns 942.478, so the island is subtracted* | Loops walked, `hatch_style` reported |
|
||||
| REGION area | ✅ *`.Area` + `.Perimeter`, verified on a seat* | — *ACIS is opaque to ezdxf* |
|
||||
| 3DSOLID **surface** area | — *ActiveX exposes `.Volume` only; use MASSPROP* | — |
|
||||
| 3D solids | ✅ *only with `ENABLE_3D=true`* | — *no headless ACIS* |
|
||||
| DWG write | ✅ | — *no DWG writer* |
|
||||
| WIPEOUT | — *`AddWipeout` verified absent, AutoCAD 2026* | ✅ |
|
||||
| MTEXT background colour | — *`BackgroundFillColor` verified absent, AutoCAD 2026* | ✅ |
|
||||
| REVCLOUD, BPOLY / boundary tracing | — *no ActiveX member; command-line only* | ✅ |
|
||||
| CHSPACE (`entity_change_space`) | — *unverified against a live seat* | ✅ ᶜ |
|
||||
| Undo history | ✅ | opt-in — `EZDXF_UNDO_DEPTH` defaults to `0` |
|
||||
| TABLE and MLEADER | Native | Portable composite |
|
||||
| Raw commands / AutoLISP | ✅ *on by default, denylist-sanitized — `DANGEROUS_COMMANDS_ENABLED=true` removes the denylist* | — *no live command line* |
|
||||
| Screenshots and PDF export | AutoCAD window capture | Matplotlib render ᵐ |
|
||||
|
||||
<sub>ᵐ Needs matplotlib — the `[pdf]` or `[full]` extra. Without it `png`, `pdf`, `viewport_render` and `handle_overlay` all report unsupported on the headless engine.<br>
|
||||
ᶜ The headless CHSPACE carries four named restrictions in its own capability reason: top-view untwisted viewports only, dimensions refused unless frozen, ACIS/proxy/table refused, and viewport clipping reported rather than applied.</sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The COM paths added in v1.5.0 were executed against a **live AutoCAD 2026** before release — two documented exceptions aside: the `AcDbLWPolyline` OCS→WCS translation in `entity_get` and the COM half of `block_create_from_entities` ship unit-tested against a fake ActiveX object, not hardware-verified. The live run was 25 calls: 18 succeeded, 1 refused a specific argument, and 6 refused with the capability keys they declare. That run found four defects, three of which no amount of reading would have caught (the fourth was `analysis_list_properties` calling a helper that does not exist on `ComBackend`) — the largest being that `AcadPViewport` has no `ViewCenter` member, so the line setting it had been silently swallowed by a bare `except` since v1.4.
|
||||
|
||||
## Evidence
|
||||
|
||||
Every chart below is rendered to SVG by Python from committed data — none is hand-edited. The numbers are not all script output, and the two exceptions are marked where they appear: **section 4** quotes timings collected by hand across separate runs, and **section 5** is a hand-assigned source review whose nine scores are literals in [`source_review.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/source_review.json) that the renderer validates and draws but does not compute.
|
||||
|
||||
### 1 · Live-run scores
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-livebench.svg" alt="Live-run score bars: autocad-mcp-pro 100 on the v3 matrix, beiming183 50 and puran-water 45 on v2" width="820">
|
||||
|
||||
| Server | Matrix | Pinned | Score | Pass |
|
||||
|---|---|---|---:|---:|
|
||||
| **autocad-mcp-pro** *(this repo)* | v3 · 15 tasks | working tree | **100.0** | 15/15 |
|
||||
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | v2 · 10 tasks | `11f7c47` | 50.0 | 5/10 |
|
||||
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | v2 · 10 tasks | `95476a3` | 45.0 | 4/10 |
|
||||
|
||||
> [!WARNING]
|
||||
> **Read the matrix column before the score column.** The five v3 tasks are new in v1.5.0; the competitor runs are from 2026-07-23 against pinned competitor commits, and were never put to them. Their scores are over the ten tasks they actually ran, and the five new rows render blank rather than as zeros — an invented zero is indistinguishable from a measured one. These bars do not share a denominator, which is why each one carries its matrix.
|
||||
|
||||
Competitors are driven **black-box over MCP stdio** against the tool contracts read from their pinned source, at **50% coverage on both**: five of the ten tasks per competitor are recorded `unsupported` — the operation is absent from that checkout, or refused by it — and those rows score zero against a denominator of ten, so 50.0 and 45.0 are coverage-weighted, not ten measured attempts. Over the tasks actually driven, beiming scores 100.0 and puran-water 90.0. Where a task does run, every geometry claim the competitor makes is re-derived by re-opening the exported DXF with ezdxf inside the harness — their own tool output is never the source of truth. This repo's row is not run that way: it runs in-process against the backend, and 13 of its 15 tasks assert on backend return values rather than on a re-opened DXF (only `dxf_roundtrip` and `auditable_delivery` write a file at all). What is independent there is the expected value — a closed-form area, entities placed on purpose, a token ceiling fixed before the measurement — not the observation. A capability refusal is recorded `unsupported`, not `fail` — "cannot reach this" and "got this wrong" are different findings. That classification is written into the adapter from the competitor's pinned source; a refusal that only appears at runtime becomes `unsupported` where the adapter recognises it (beiming's `E_UNSUPPORTED` on `transaction begin` is the one that did) and lands as `fail` otherwise.
|
||||
|
||||
### 2 · Task matrix — where the score comes from
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-taskmatrix.svg" alt="Task matrix heatmap: 15 tasks against three servers" width="820">
|
||||
|
||||
**v2 scored this repo 10/10, and that number carried no information**: every task in it exercised something the server was built around. v3 adds five tasks chosen because they *can* fail — and two of them did, while being written: `hatch_islands` (a HATCH could not be measured at all) and `measure_from_handle` (the boundary tools and the measurement tool disagreed about the same shape). A third measurement defect fixed in the same release — a circle stored as two bulged vertices measured 0.0 — is carried by `two_vertex_circle_has_area` in the correctness suite, not by a benchmark task.
|
||||
|
||||
| v3 task | Verified against |
|
||||
|---|---|
|
||||
| `tool_discovery` | six AutoCAD command names, each ranking **#1** of the 154-tool registry the harness ranks (pass bar is top-3) |
|
||||
| `token_budget` | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
|
||||
| `hatch_islands` | **300** filled with the island, 400 ignoring it |
|
||||
| `selection_filter` | window 1, crossing 2, bounding box 3, polygon 1 |
|
||||
| `measure_from_handle` | **139.2699** against the 100.0 a vertex shoelace gives |
|
||||
|
||||
### 3 · Correctness A/B — every release re-proves itself
|
||||
|
||||
26 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run.
|
||||
|
||||
**v1.5.1 release gate**, baseline `v1.5.0` ([report](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json)):
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---|---:|---:|---:|---:|
|
||||
| v1.5.0 *(baseline)* | 24 / 26 | 92.3 % | — | — |
|
||||
| **v1.5.1** *(this release)* | **26 / 26** | **100 %** | 2 | **0** |
|
||||
|
||||
**v1.5.0 fails two of these**, and they are the reason it is yanked:
|
||||
`diameter_dim_measures_the_diameter` and `radius_dim_ignores_the_leader_length`.
|
||||
It has both methods and gets both wrong — `leader_length`, a *text placement*,
|
||||
was being measured as geometry, so at default settings every diameter callout
|
||||
came out **2 × leader_length too large** (60 on a true ⌀40). v1.4.0 shipped the
|
||||
same defect. The live COM backend was never affected.
|
||||
|
||||
Against the older `v1.4.0` baseline the same suite reports **21 / 26** ᵃ with
|
||||
five fixed ([report](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)) — three of those
|
||||
five are new capability rather than repairs: v1.4.0 has no `entity_measure` and
|
||||
no boundary tracing, so it misses them by not having the method.
|
||||
|
||||
<sub>ᵃ Against the 26-check current suite. v1.4.0 passed 21/21 of the checks that existed when it shipped.</sub>
|
||||
|
||||
The 21 pre-existing checks are unchanged and all still pass: the discovery layer, `cad_batch`, the 19-module contract split, layouts/viewports and Wave A landed with **zero correctness regressions**.
|
||||
|
||||
### 4 · Headless performance
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-perf.svg" alt="Performance chart across four headless workloads" width="820">
|
||||
|
||||
| Workload | Wall time | Result |
|
||||
|---|---:|---|
|
||||
| Create 2,000 lines (individual calls) | 0.35 s | ~5,716 entities/s |
|
||||
| 10,000 lines: build + DXF export + reopen | 2.18 s | ~4,591 entities/s end-to-end |
|
||||
| Region query over 10,000 entities | 2.47 s | 2,500 matched |
|
||||
| Premium pass (layers + part + dims + full critique) | 0.26 s | 0 issues |
|
||||
|
||||
<sub>One run of each workload — `perf_suite` executes each exactly once per invocation, and these are the figures in the committed [`perf-ezdxf.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/perf-ezdxf.json). Run-to-run spread on this machine is wide enough to matter: treat them as an order of magnitude, not a stopwatch.</sub>
|
||||
|
||||
Workloads call the same backend methods the MCP tools call, so the backend's own per-call overhead — worker-thread hand-off, document lock, the `EZDXF_CALL_TIMEOUT` guard — is included. The FastMCP layer above it is **not**: `perf_suite.py` never builds the server, so tool dispatch, argument validation, the five middleware and result serialization all sit outside the timer, and in-process they cost roughly an order of magnitude more than the backend call itself. Read these as backend-engine numbers, not per-tool-call latency. **Self-measurement only** — competitor servers pay an extra stdio serialization cost that in-process runs do not, so no cross-server timing claims are made.
|
||||
|
||||
> [!CAUTION]
|
||||
> These are **not** an improvement over v1.4.0, and they are not the engine's floor. The per-call timeout guard costs about **21%** of entity-creation throughput — see [what this release is bad at](https://github.com/U-C4N/Autocad-MCP#what-this-release-is-bad-at) for the paired measurement and why the guard is kept.
|
||||
|
||||
### 5 · Source-review leaderboard
|
||||
|
||||
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-benchmark.svg" alt="Capability leaderboard across nine public AutoCAD MCP servers" width="820">
|
||||
|
||||
Nine public AutoCAD MCP projects against a fixed 100-point rubric — CAD breadth (25), correctness and delivery (20), backends/platforms (15), engineering production (15), tests and maintenance (15), security and operations (10). Stars and raw tool counts score nothing. Data and dates in [`benchmarks/source_review.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/source_review.json).
|
||||
|
||||
**This table is not re-scored per release** — no row is, including the other eight. This repository's row is still the one written against **v1.4.0**. Raising our own number for v1.5.0 would mean re-reviewing eight other projects the same day to keep it fair, and that has not been done — so the score stays where the evidence is.
|
||||
|
||||
Method, caveats and boundaries for every lane: [`benchmarks/README.md`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/README.md).
|
||||
|
||||
## What this release is bad at
|
||||
|
||||
A page that only lists strengths is a page that has not been measured.
|
||||
|
||||
**Entity creation is ~21% slower than it would be without the call timeout.**
|
||||
The timeout is what stops one hung ezdxf call wedging a server whose document
|
||||
lock is a single `asyncio.Lock`, and it is not free. Measured on one
|
||||
interpreter, three runs of each configuration with the median taken by hand
|
||||
(`perf_suite` runs each workload once per invocation — it cannot produce a
|
||||
median itself):
|
||||
|
||||
| Workload | `EZDXF_CALL_TIMEOUT=0` | default (120 s) | Cost |
|
||||
|---|---:|---:|---:|
|
||||
| 2,000 lines | 243.7 ms | 294.3 ms | +20.8% |
|
||||
| 10,000-line roundtrip | 1,709.6 ms | 2,049.2 ms | +19.9% |
|
||||
|
||||
v1.5.0 paid **28.6%** on the first workload; v1.5.1 rebuilt the wrapper around a
|
||||
single `asyncio.timeout_at` instead of two `asyncio.wait_for`s — `wait_for`
|
||||
wraps its awaitable in a Task, so the old shape created two extra Tasks and two
|
||||
timer handles on *every* call. That recovered about a third of the overhead and
|
||||
fixed a latent double-release, but it did not close the gap: the remaining cost
|
||||
is the Task the abandon-on-timeout design genuinely needs. The knob is
|
||||
documented rather than hidden, and the item stays open.
|
||||
|
||||
> These figures are hand-collected across separate single-shot runs and have no
|
||||
> published artifact; `benchmarks/results/published/perf-ezdxf.json` records one
|
||||
> default-configuration run only.
|
||||
|
||||
> The README on `main` through the v1.4 era printed *slower* numbers than the table above — 0.64 s and 13.9 s for the same two workloads. That looks like a 2–6× win and is not one: the old report was recorded on CPython 3.14 and this one on 3.11.15. Almost entirely the interpreter, not code — run on one interpreter, this release is *slower* than v1.4.0 on both workloads, not faster. Comparing the two published files directly is the mistake this paragraph exists to stop.
|
||||
|
||||
**Wave A shipped 13 tools of a planned ~39.** Every cut is backed by a measurement. The REGION and 2D-boolean family went because `add_region()` produces a REGION with **zero ACIS bytes**, and the substitute, `greiner_hormann`, takes and returns flat vertex lists, so a bulged edge goes in as its chord: **28.2%** of the area on a square with one semicircular edge — shipping it would have re-created the exact class of silent error this release's honesty pass exists to delete. Of the planned measurement family only `analysis_measure_entity` shipped — measuring a drawn entity by handle, which neither backend could do — and the centroid and moment-of-inertia tools were cut with nothing replacing them. Of the eight `analysis_*` tools that already existed, the two tagged `measure` both measured points the caller typed in.
|
||||
|
||||
**`system_run_command` / `system_run_lisp` are a guardrail, not a security boundary** — the rejection message clients receive says so in those words. A 36-verb command denylist and a 24-pattern AutoLISP denylist refuse the obviously destructive cases, but AutoCAD accepts hundreds of commands and any loaded ARX/LISP adds more, so the list cannot be complete, and `DANGEROUS_COMMANDS_ENABLED=true` switches both off entirely.
|
||||
|
||||
**Path validation is per-tool, and unscoped until you scope it.** Nine tools call `validate_path`; nothing in the middleware enforces it server-wide. With `ALLOWED_PATHS` empty — the default — the only positive bound is a ten-entry system-directory denylist that does not include `C:/Users`, `/home`, `/root` or `/var`. **Set `ALLOWED_PATHS`.** It is the setting that actually scopes the filesystem.
|
||||
|
||||
**Non-AutoCAD ProgIDs are unverified.** `CAD_PROGID` changes which COM application the backend attaches to — the configured ProgID is used at both `GetActiveObject` and `Dispatch`, and an unrecognised one fails loudly rather than falling back to AutoCAD. Nothing has been tested against BricsCAD, ZWCAD or GstarCAD, the connection included: no running seat of any of them was ever attached to. They are listed in `.env.example` commented out and marked UNVERIFIED.
|
||||
|
||||
## Configuration
|
||||
|
||||
Nothing here loads a `.env` file — there is no dotenv dependency. Export these, or set them in your MCP client's `env` block. `.env.example` is documentation, and it is not the full list.
|
||||
|
||||
<details>
|
||||
<summary><b>All 16 environment variables</b></summary>
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
|
||||
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to (see below) |
|
||||
| `TOOL_PROFILE` | `full` | `lean` (~47 curated tools) or `full` |
|
||||
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to (unverified for clones) |
|
||||
| `TOOL_PROFILE` | `full` | `lean` (47 curated tools) or `full` |
|
||||
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
|
||||
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM backend) |
|
||||
| `LOG_LEVEL` | `INFO` | Python logging level |
|
||||
| `ALLOWED_PATHS` | empty | Comma-separated absolute paths the server may access |
|
||||
| `ALLOWED_PATHS` | *(empty)* | Comma-separated absolute paths the server may access |
|
||||
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
|
||||
| `EZDXF_UNDO_DEPTH` | `0` | Headless undo history depth; `0` disables it |
|
||||
| `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables |
|
||||
| `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes |
|
||||
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds); `0` disables |
|
||||
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless ezdxf timeout (seconds); `0` disables |
|
||||
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless timeout (seconds); `0` disables |
|
||||
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP; reported as unsafe mode |
|
||||
| `ALLOW_REMOTE_HTTP` | `false` | Permit a non-loopback HTTP bind |
|
||||
| `MCP_AUTH_TOKEN` | empty | Bearer token required for remote HTTP |
|
||||
| `MCP_AUTH_TOKEN` | *(empty)* | Bearer token required for remote HTTP |
|
||||
|
||||
`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.
|
||||
</details>
|
||||
|
||||
## Security model
|
||||
## Architecture
|
||||
|
||||
Tool input is treated as untrusted:
|
||||
The request path, as a Mermaid diagram — GitHub draws it; on PyPI it is the same graph in source form:
|
||||
|
||||
- every path passes traversal + allowed-root validation, and non-local anchors
|
||||
(`\\?\`, UNC, `\\localhost\C$`) are rejected in every spelling — the
|
||||
forward-slash forms used to normalise past the pattern check and then compare
|
||||
unequal to the system-directory list;
|
||||
- `system_run_command` / `system_run_lisp` are free-text escape hatches into the
|
||||
live AutoCAD command line. A **verb** denylist (the first token of each macro
|
||||
segment, decorators stripped) refuses the obviously destructive ones by
|
||||
default, and quoted AutoLISP string literals are treated as data so drawing
|
||||
text is not mistaken for code. Treat this as a guardrail against an agent
|
||||
accidentally issuing `ERASE ALL` — **not** as a boundary against a hostile
|
||||
client. AutoCAD accepts hundreds of commands and any loaded ARX/LISP adds
|
||||
more, so the list cannot be complete. The real boundaries are `ALLOWED_PATHS`,
|
||||
not exposing the server over the network, and preferring the typed tools
|
||||
(which validate every argument) over raw commands;
|
||||
- remote HTTP requires explicit opt-in plus a bearer token;
|
||||
- DXF size, response sizes, undo history and COM call duration are bounded;
|
||||
- audit middleware records tool timing and failures;
|
||||
- `system_status` / `system_about` expose `unsafe_mode` when dangerous
|
||||
operations are enabled.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[MCP host / AI agent] --> B[FastMCP 3 server]
|
||||
B --> C[Error · audit · timing · logging middleware]
|
||||
C --> D[Typed contract · 19 modules · 27 capabilities]
|
||||
D --> E[COM backend — single-STA thread]
|
||||
D --> F[ezdxf backend — asyncio.to_thread]
|
||||
E --> G[Live AutoCAD]
|
||||
F --> H[Headless DXF]
|
||||
E --> I[Engineering · critique · scoring · delivery]
|
||||
F --> I
|
||||
```
|
||||
|
||||
Please report vulnerabilities privately via [GitHub](https://github.com/U-C4N)
|
||||
rather than public issues.
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `server.py` | FastMCP surface, lifespan, middleware, resources, prompts |
|
||||
| `backends/contracts/` | The typed contract, one module per domain |
|
||||
| `backends/capability.py` | Capability model, typed refusals, `@capability` |
|
||||
| `backends/ocs.py` | OCS ↔ WCS translation at the backend boundary |
|
||||
| `backends/com_backend.py` | Single-STA-thread COM executor with per-call timeouts |
|
||||
| `backends/ezdxf_backend.py` | `asyncio.to_thread`-wrapped DXF engine, snapshot transactions |
|
||||
| `discovery/` | Alias corpus, ranking transform, compact serializer |
|
||||
| `engineering/` | Standards, generators, critique, fits, scoring, delivery |
|
||||
| `security.py` | Path validation, command and AutoLISP sanitization |
|
||||
|
||||
### The drawing at the top of this page, as tool calls
|
||||
|
||||
Not pseudocode — this is the sequence in [`scripts/render_readme_showcase.py`](https://github.com/U-C4N/Autocad-MCP/blob/main/scripts/render_readme_showcase.py), which produced the sheet in the hero image:
|
||||
|
||||
```text
|
||||
drawing_new() fresh document, ISO linetypes + layers bootstrapped
|
||||
drawing_apply_iso_layers("mech") ISO 128 lineweights per layer
|
||||
drawing_settings({units: "mm", linear_precision: 2})
|
||||
|
||||
gear_draw_spur_front_view( involute flanks, not a decorated circle
|
||||
module=6, teeth=24, center=[135, 172],
|
||||
bore_diameter=40, keyway_width=12, keyway_depth=3.3)
|
||||
gear_draw_section_aa(x_offset=300, face_width=46)
|
||||
|
||||
dimension_linear(...) ISO 129
|
||||
dimension_diameter(..., fit="H7") deviations from authored ISO 286 tables
|
||||
titleblock_apply_iso_a3(title="SPUR GEAR m6 z24", material="C45E", ...)
|
||||
|
||||
drawing_critique(focus=None) -> [] must be empty before finalize
|
||||
view_screenshot() -> PNG
|
||||
```
|
||||
|
||||
The full production loop adds `drawing_preflight` and `drawing_plan` at the front (the plan is held by the backend and replayed by `drawing_critique` at finalize time), `drawing_refine` after the critique, and `layout_create` + `viewport_create` + `drawing_deliver` at the end for a hashed, reopen-verified handoff.
|
||||
|
||||
## Development
|
||||
|
||||
`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:
|
||||
`uv.lock` pins the whole transitive graph — runtime *and* dev tools — so this reproduces CI exactly rather than approximately:
|
||||
|
||||
```bash
|
||||
uv sync --locked --all-extras
|
||||
@@ -427,82 +381,40 @@ uv run pytest
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
```
|
||||
|
||||
Without uv, from the declared dependency ranges (needs pip ≥ 25.1 for
|
||||
`--group`; resolves fresh, so it can differ from CI):
|
||||
|
||||
```bash
|
||||
pip install -e ".[full]"
|
||||
pip install --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:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
CI runs on Linux (3.11 / 3.12), Windows (mocked-COM suite), plus package, Docker and MCP-registry-schema jobs. The package job deliberately stays *unlocked* — it is the one that proves a plain `pip install autocad-mcp-pro` still resolves. A scheduled `deps-floating` workflow resolves the ranges fresh and re-runs the suite, so a locked CI does not become blindness to upstream drift. Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
|
||||
## Roadmap (1.6)
|
||||
|
||||
**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.
|
||||
Give back the 25% creation throughput the per-call timeout costs — arm it only for calls that can actually block. ISO 286 interference **shafts** r/s/t/u (they need sub-stepped table data nothing in the module can derive; 1.5 refuses them by name instead of guessing). HATCH boundary *geometry* in `entity_get` — 1.5 measures a hatch's filled area but still does not hand back its loops. REGION/3DSOLID area headlessly, which needs a modelling kernel ezdxf does not have. An *allowlist* of permitted AutoLISP heads, replacing the denylist — enumerating dangerous symbols does not terminate. `ezdxf.recover` as a fallback on `drawing_open`.
|
||||
|
||||
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).
|
||||
Features are published when their contracts and limitations are testable — not when they make a longer checklist.
|
||||
|
||||
## Star history
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://star-history.com/#U-C4N/Autocad-MCP&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=U-C4N/Autocad-MCP&type=Date&theme=dark" alt="Star history chart for U-C4N/Autocad-MCP" width="720">
|
||||
</a>
|
||||
|
||||
If this saved you a day of drafting, a star helps other engineers find it.
|
||||
|
||||
</div>
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome — especially reproducible competitor adapters,
|
||||
mocked/live COM coverage, engineering standards, and backend parity. For a
|
||||
large change, open an issue first so the tool contract and evidence plan can
|
||||
be agreed before implementation.
|
||||
Pull requests are welcome — especially reproducible competitor adapters, mocked/live COM coverage, engineering standards, and backend parity. For a large change, open an issue first so the tool contract and evidence plan can be agreed before implementation.
|
||||
|
||||
## Author
|
||||
|
||||
**Umutcan Edizsalan** · Mechanical engineering work at **Anka-Makine** ·
|
||||
GitHub [@U-C4N](https://github.com/U-C4N)
|
||||
**Umutcan Edizsalan** · Mechanical engineering work at **Anka-Makine** · GitHub [@U-C4N](https://github.com/U-C4N)
|
||||
|
||||
Built from production drawing work, then made model-agnostic through MCP.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
[MIT](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
|
||||
|
||||
<sub>The line below is the MCP registry's ownership marker. It must equal the `name` in `server.json`, and it is deliberately plain text so it survives PyPI's HTML stripping.</sub>
|
||||
|
||||
<!-- MCP registry ownership marker; must equal the "name" in server.json. -->
|
||||
mcp-name: io.github.u-c4n/autocad-mcp
|
||||
|
||||
@@ -21,7 +21,7 @@ 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
|
||||
* The refusal is *declared*: `test_capability_contract.py` fails if a key used here is
|
||||
missing from either backend's `CapabilityMap`, so `system_capabilities` can
|
||||
never disagree with what a call actually does. This is the part that keeps
|
||||
the escape hatch honest — without it, `@capability` would just be a way to
|
||||
|
||||
+102
-19
@@ -19,6 +19,7 @@ from typing import Any
|
||||
|
||||
import config
|
||||
from engineering.measure import is_self_intersecting, polygon_area_perimeter
|
||||
from security import sanitize_macro_argument, sanitize_symbol_name, validate_path
|
||||
|
||||
from . import ocs
|
||||
from .base import (
|
||||
@@ -203,6 +204,9 @@ def _ensure_linetype_loaded(name: str) -> None:
|
||||
"""
|
||||
if not name or name.lower() in _BUILTIN_LINETYPES:
|
||||
return
|
||||
# Before any COM call: this name is about to be interpolated into a
|
||||
# SendCommand macro, where a newline or ';' would start a second command.
|
||||
name = sanitize_symbol_name(name, kind="linetype")
|
||||
doc = _acad_doc()
|
||||
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
|
||||
if name.lower() in existing:
|
||||
@@ -293,6 +297,10 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri
|
||||
|
||||
#: ActiveX object names that bound no area at all. Not a capability gap — no
|
||||
#: engine can give a LINE an area — so these raise a plain error.
|
||||
#: Solids carry a volume but no surface area over ActiveX. Verified on a live
|
||||
#: AutoCAD 2026 (2026-08-06): AcDb3dSolid answers `.Volume` and raises on `.Area`.
|
||||
_COM_NO_SURFACE_AREA_TYPES = frozenset({"AcDb3dSolid", "AcDbBody"})
|
||||
|
||||
_COM_NO_AREA_TYPES = frozenset(
|
||||
{
|
||||
"AcDbLine",
|
||||
@@ -652,6 +660,14 @@ class ComBackend(AutoCADBackend):
|
||||
looking identical to an expired deadline. ``future.cancelled()`` tells
|
||||
them apart — ``wait_for`` cancels the future it is waiting on, an
|
||||
ordinary failure completes it — mirroring ``EzdxfBackend._async``.
|
||||
|
||||
R32 deliberately stops short of the ezdxf document quarantine here. There
|
||||
is no ``self._doc`` to replace: the drawing belongs to the operator, lives
|
||||
in AutoCAD's process, and AutoCAD's own single-threaded command processor
|
||||
arbitrates the abandoned call — not us. The only "reopen" available would
|
||||
close the operator's drawing and discard unsaved work the server never
|
||||
created. What COM is exposed to is idempotency, so the message says the
|
||||
call may still land.
|
||||
"""
|
||||
if self._executor is None:
|
||||
raise RuntimeError("ComBackend not connected. Call connect() first.")
|
||||
@@ -691,8 +707,11 @@ class ComBackend(AutoCADBackend):
|
||||
raise RuntimeError(
|
||||
f"AutoCAD did not respond within {timeout:.0f}s. The application "
|
||||
"may be showing a modal dialog or have an active command prompt "
|
||||
"(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long "
|
||||
"operation is expected."
|
||||
"(press ESC in AutoCAD). The abandoned call may still complete in "
|
||||
"AutoCAD after this error - the worker is blocked inside an "
|
||||
"uncancellable COM call, not stopped - so verify the drawing before "
|
||||
"retrying: a retry can double-apply the operation. Increase "
|
||||
"COM_CALL_TIMEOUT if a long operation is expected."
|
||||
) from e
|
||||
except _COM_ERROR as e:
|
||||
hr = e.args[0] if e.args else 0
|
||||
@@ -2903,15 +2922,27 @@ class ComBackend(AutoCADBackend):
|
||||
# dialog deadlocks SendCommand; (2) ISO/metric drawings need acadiso.lin,
|
||||
# imperial drawings need acad.lin. We pick automatically from MEASUREMENT
|
||||
# if the caller didn't specify a file.
|
||||
#
|
||||
# Both arguments end up inside a SendCommand macro, so both are checked
|
||||
# before the COM thread is entered: the name against the DXF
|
||||
# symbol-table rule, the path against the same allowlist every other
|
||||
# file-taking tool uses. This was the one path-taking tool that reached
|
||||
# the filesystem with neither.
|
||||
safe_name = sanitize_symbol_name(name, kind="linetype")
|
||||
safe_file = None
|
||||
if file is not None:
|
||||
resolved = validate_path(str(file))
|
||||
safe_file = sanitize_macro_argument(str(resolved), kind="linetype file")
|
||||
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
doc = _acad_doc()
|
||||
|
||||
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
|
||||
if name.lower() in existing:
|
||||
return {"ok": True, "name": name, "already_loaded": True}
|
||||
if safe_name.lower() in existing:
|
||||
return {"ok": True, "name": safe_name, "already_loaded": True}
|
||||
|
||||
if file is None:
|
||||
if safe_file is None:
|
||||
try:
|
||||
measurement = int(app.GetVariable("MEASUREMENT"))
|
||||
except Exception as exc:
|
||||
@@ -2919,7 +2950,7 @@ class ComBackend(AutoCADBackend):
|
||||
measurement = 1
|
||||
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
|
||||
else:
|
||||
lin_file = file
|
||||
lin_file = safe_file
|
||||
|
||||
try:
|
||||
old_filedia = int(app.GetVariable("FILEDIA"))
|
||||
@@ -2930,7 +2961,7 @@ class ComBackend(AutoCADBackend):
|
||||
app.SetVariable("FILEDIA", 0)
|
||||
# Double newline: first ends the file name, second exits
|
||||
# -LINETYPE's [?/Create/Load/Set] option menu.
|
||||
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
|
||||
doc.SendCommand(f"_-LINETYPE _LOAD {safe_name} {lin_file}\n\n")
|
||||
finally:
|
||||
try:
|
||||
app.SetVariable("FILEDIA", old_filedia)
|
||||
@@ -2938,13 +2969,13 @@ class ComBackend(AutoCADBackend):
|
||||
log.debug("FILEDIA restore failed: %s", exc)
|
||||
|
||||
after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
|
||||
if name.lower() not in after:
|
||||
if safe_name.lower() not in after:
|
||||
raise RuntimeError(
|
||||
f"Failed to load linetype '{name}' from '{lin_file}'. "
|
||||
f"Failed to load linetype '{safe_name}' from '{lin_file}'. "
|
||||
"Check the linetype name spelling and that the .lin file is "
|
||||
"on AutoCAD's support path."
|
||||
)
|
||||
return {"ok": True, "name": name, "file": lin_file}
|
||||
return {"ok": True, "name": safe_name, "file": lin_file}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
@@ -3189,15 +3220,28 @@ class ComBackend(AutoCADBackend):
|
||||
area = float(ent.Area)
|
||||
except Exception as exc:
|
||||
log.debug("ActiveX .Area unavailable for %s: %s", obj_name, exc)
|
||||
# Measured on a live AutoCAD 2026 (2026-08-06): the perimeter lives
|
||||
# under a different member per type, and on some types nowhere.
|
||||
# AcDbPolyline -> .Length, AcDbCircle -> .Circumference,
|
||||
# AcDbRegion -> .Perimeter, AcDbHatch -> none of them.
|
||||
for member in ("Length", "Circumference", "Perimeter"):
|
||||
try:
|
||||
perimeter = float(ent.Length)
|
||||
perimeter = float(getattr(ent, member))
|
||||
break
|
||||
except Exception as exc:
|
||||
log.debug("ActiveX .Length unavailable for %s: %s", obj_name, exc)
|
||||
log.debug("ActiveX .%s unavailable for %s: %s", member, obj_name, exc)
|
||||
|
||||
method = "activex_area"
|
||||
closed = True
|
||||
self_intersecting = None
|
||||
if area is None or perimeter is None:
|
||||
# Only the AREA decides whether we need the fallback. This used to
|
||||
# read `area is None or perimeter is None`, which coupled two
|
||||
# independent property reads: HATCH, REGION and 3DSOLID expose
|
||||
# `.Area` but no `.Length`, so a perfectly good area was thrown
|
||||
# away, the fallback demanded a `.Coordinates` only the polyline
|
||||
# types carry, and the call died claiming AutoCAD "reported neither
|
||||
# an area nor usable vertices" — while holding the area.
|
||||
if area is None:
|
||||
# Rebuild from the polyline's own vertices and run the SAME
|
||||
# shared maths the headless engine uses, so a missing ActiveX
|
||||
# property can never make this backend the less accurate one.
|
||||
@@ -3208,21 +3252,50 @@ class ComBackend(AutoCADBackend):
|
||||
for i in range(0, len(coords) - 1, 2)
|
||||
]
|
||||
if not vertices:
|
||||
if obj_name in _COM_NO_SURFACE_AREA_TYPES:
|
||||
# Verified on a live AutoCAD 2026 (2026-08-06): AcDb3dSolid
|
||||
# exposes `.Volume` and no `.Area` whatsoever. The generic
|
||||
# message would send the caller hunting for geometry that
|
||||
# was never the problem.
|
||||
raise RuntimeError(
|
||||
f"entity_measure: handle {handle} is {obj_name}, and ActiveX "
|
||||
"exposes no surface-area member for it — only .Volume. "
|
||||
"AutoCAD's own MASSPROP reports the surface area; this server "
|
||||
"does not wrap it."
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"entity_measure: AutoCAD reported neither an area nor usable "
|
||||
f"vertices for handle {handle} ({obj_name})."
|
||||
)
|
||||
closed = bool(getattr(ent, "Closed", True))
|
||||
fallback_area, fallback_perimeter = polygon_area_perimeter(vertices, closed)
|
||||
area = fallback_area if area is None else area
|
||||
area = fallback_area
|
||||
perimeter = fallback_perimeter if perimeter is None else perimeter
|
||||
self_intersecting = is_self_intersecting([(v[0], v[1]) for v in vertices])
|
||||
|
||||
elif self_intersecting is None:
|
||||
# The bowtie warning was only ever computed inside the fallback,
|
||||
# so a live LWPOLYLINE bowtie — which has both `.Area` and
|
||||
# `.Length` and therefore never reaches it — came back
|
||||
# `area: 0.0, self_intersecting: null`. That is precisely the
|
||||
# confident-zero this field exists to flag, going out unflagged.
|
||||
coords = list(getattr(ent, "Coordinates", ()) or ())
|
||||
if len(coords) >= 6:
|
||||
self_intersecting = is_self_intersecting(
|
||||
[
|
||||
(float(coords[i]), float(coords[i + 1]))
|
||||
for i in range(0, len(coords) - 1, 2)
|
||||
]
|
||||
)
|
||||
|
||||
# A perimeter we do not have is reported as absent, not invented.
|
||||
# `round(None, 6)` was also a TypeError waiting for the first type
|
||||
# that answers `.Area` and nothing else.
|
||||
return {
|
||||
"handle": str(handle),
|
||||
"type": obj_name,
|
||||
"area": round(float(area), 6),
|
||||
"perimeter": round(float(perimeter), 6),
|
||||
"perimeter": None if perimeter is None else round(float(perimeter), 6),
|
||||
"closed": closed,
|
||||
"assumed_closed": not closed,
|
||||
"method": method,
|
||||
@@ -3230,7 +3303,7 @@ class ComBackend(AutoCADBackend):
|
||||
"flatten_tolerance": None,
|
||||
"backend": "com",
|
||||
"self_intersecting": self_intersecting,
|
||||
"perimeter_exact": True,
|
||||
"perimeter_exact": perimeter is not None,
|
||||
"loop_count": 1,
|
||||
}
|
||||
|
||||
@@ -3411,12 +3484,22 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
# R32: record the mark from inside the worker, immediately before
|
||||
# setting it. `_transaction_active = True` used to run only after
|
||||
# `_run` returned, so a timed-out begin left StartUndoMark open in
|
||||
# AutoCAD while the server recorded no transaction, and the next
|
||||
# transaction_begin nested a second mark nobody could see — the
|
||||
# exact inverse of the R16 fix transaction_commit already carries.
|
||||
# The abandoned STA thread cannot be cancelled, so the flag has to
|
||||
# be written where the call happens; and it is written *before*
|
||||
# StartUndoMark because a call that hangs inside it may still land.
|
||||
# A `_acad_doc()` that fails leaves the flag alone, which is right:
|
||||
# nothing reached AutoCAD.
|
||||
self._transaction_active = True
|
||||
doc.StartUndoMark()
|
||||
return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"}
|
||||
|
||||
result = await self._run(_sync)
|
||||
self._transaction_active = True
|
||||
return result
|
||||
return await self._run(_sync)
|
||||
|
||||
async def transaction_commit(self) -> dict:
|
||||
def _sync():
|
||||
|
||||
@@ -117,8 +117,19 @@ class AnalysisContract(ABC):
|
||||
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.
|
||||
it reads ActiveX ``.Area``, which **does** apply the island style —
|
||||
verified on a live AutoCAD 2026 (2026-08-06): a hatch of r20 with an
|
||||
r10 island returns 942.478, which is pi*(400-100).
|
||||
|
||||
``perimeter`` is ``None`` where ActiveX exposes no length member for the
|
||||
type, and ``perimeter_exact`` is then False. The member differs per
|
||||
type and was measured rather than assumed: LWPOLYLINE ``.Length``,
|
||||
CIRCLE ``.Circumference``, REGION ``.Perimeter``, HATCH none. A
|
||||
3DSOLID has no ``.Area`` at all — only ``.Volume`` — and is refused by
|
||||
name.
|
||||
A missing perimeter is not a reason to discard an area that was read
|
||||
successfully — doing exactly that is what made this method raise on
|
||||
every ACIS type it advertised.
|
||||
|
||||
``exact`` and
|
||||
``flatten_tolerance`` are the accuracy disclosure: when one engine can
|
||||
|
||||
@@ -11,6 +11,8 @@ import math
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from backends.quarantine import DocumentQuarantineError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import EntityInfo
|
||||
from engineering.plan_spec import (
|
||||
@@ -579,6 +581,11 @@ class PremiumContract(ABC):
|
||||
layer = layer or self._role_layer("construction")
|
||||
try:
|
||||
ents = await self.entity_list(layer_filter=layer, limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
# "deleted: 0" here would read as "there was nothing to clear" on a
|
||||
# document the backend has declared untrusted -- and this is step 9
|
||||
# of the standard workflow, immediately before finalize.
|
||||
raise
|
||||
except Exception:
|
||||
ents = []
|
||||
deleted = 0
|
||||
|
||||
@@ -22,8 +22,13 @@ class SettingsContract:
|
||||
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)."""
|
||||
dimscale, dim_text_height, dim_arrow_size, dim_decimals,
|
||||
decimal_separator, zero_suppression, text_size, point_mode, point_size,
|
||||
osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the
|
||||
INSUNITS code) and `decimal_separator` accepts "." or ",".
|
||||
|
||||
Note that a no-argument snapshot is one ``system_get_variable`` per key.
|
||||
Free headless; a COM round trip each on a live seat."""
|
||||
if not settings:
|
||||
snapshot: dict = {}
|
||||
for key, (var, kind) in _SETTING_MAP.items():
|
||||
@@ -68,6 +73,16 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
|
||||
"angular_precision": ("AUPREC", "int"),
|
||||
"ltscale": ("LTSCALE", "float"),
|
||||
"dimscale": ("DIMSCALE", "float"),
|
||||
# The dimension variables had no friendly name until v1.5.2, so the only
|
||||
# way to reach dimension lettering was a raw `system_set_variable` — which
|
||||
# is precisely the path that silently did nothing on the headless engine.
|
||||
# `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
|
||||
# never had anything to do with the numbers on a dimension.
|
||||
"dim_text_height": ("DIMTXT", "float"),
|
||||
"dim_arrow_size": ("DIMASZ", "float"),
|
||||
"dim_decimals": ("DIMDEC", "int"),
|
||||
"decimal_separator": ("DIMDSEP", "char"),
|
||||
"zero_suppression": ("DIMZIN", "int"),
|
||||
"text_size": ("TEXTSIZE", "float"),
|
||||
"point_mode": ("PDMODE", "int"),
|
||||
"point_size": ("PDSIZE", "float"),
|
||||
@@ -96,6 +111,13 @@ _UNIT_TO_CODE: dict[str, int] = {
|
||||
}
|
||||
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
|
||||
|
||||
#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
|
||||
#: on a string in the header — so the friendly key is where the translation
|
||||
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
|
||||
#: of continental Europe drafts with. Anything else lands an arbitrary glyph in
|
||||
#: the middle of every number on the sheet, so it is refused rather than stored.
|
||||
_DECIMAL_MARKERS = (".", ",")
|
||||
|
||||
|
||||
def _encode_setting(key: str, kind: str, value: Any) -> Any:
|
||||
"""Coerce a friendly value into the raw system-variable value."""
|
||||
@@ -109,10 +131,62 @@ def _encode_setting(key: str, kind: str, value: Any) -> Any:
|
||||
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
|
||||
)
|
||||
return code
|
||||
if kind == "char":
|
||||
if isinstance(value, bool):
|
||||
marker = ""
|
||||
elif isinstance(value, (int, float)):
|
||||
code = int(value)
|
||||
marker = chr(code) if 0 < code < 0x110000 else ""
|
||||
else:
|
||||
marker = str(value).strip()
|
||||
if marker not in _DECIMAL_MARKERS:
|
||||
raise ValueError(
|
||||
f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
|
||||
)
|
||||
return ord(marker)
|
||||
if kind == "int":
|
||||
return int(float(value))
|
||||
return _in_range(key, int(float(value)))
|
||||
if kind == "float":
|
||||
return float(value)
|
||||
return _in_range(key, float(value))
|
||||
return value
|
||||
|
||||
|
||||
#: AutoCAD's own ranges for the settings that now reach the renderer.
|
||||
#:
|
||||
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
|
||||
#: header. Folding it into the per-dimension override is what gave it teeth:
|
||||
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
|
||||
#: with `ValueError: Format specifier missing precision` raised from inside
|
||||
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
|
||||
#: printed forty digits of float noise onto the sheet; and a non-positive
|
||||
#: length was reported applied and silently discarded. `applied` has to mean
|
||||
#: applied.
|
||||
_SETTING_RANGES: dict[str, tuple[float, float]] = {
|
||||
"dim_decimals": (0, 8), # DIMDEC
|
||||
"zero_suppression": (0, 15), # DIMZIN is a bitmask
|
||||
"linear_precision": (0, 8), # LUPREC
|
||||
"angular_precision": (0, 8), # AUPREC
|
||||
"dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive
|
||||
"dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive
|
||||
"text_size": (1e-9, 1e6), # TEXTSIZE
|
||||
"dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal
|
||||
"ltscale": (1e-9, 1e6),
|
||||
"point_size": (-100, 1e6), # negative is a percentage of the screen
|
||||
"fillet_radius": (0, 1e6),
|
||||
}
|
||||
|
||||
|
||||
def _in_range(key: str, value: float) -> float:
|
||||
"""Refuse a value the renderer cannot use, naming the range."""
|
||||
bounds = _SETTING_RANGES.get(key)
|
||||
if bounds is None:
|
||||
return value
|
||||
low, high = bounds
|
||||
if not low <= value <= high:
|
||||
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
|
||||
raise ValueError(
|
||||
f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@@ -126,6 +200,14 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
|
||||
if kind == "char":
|
||||
try:
|
||||
code = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
# ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
|
||||
# so report the marker that would actually be drawn.
|
||||
return "," if code == 0 else chr(code)
|
||||
if kind == "int":
|
||||
try:
|
||||
return int(raw)
|
||||
|
||||
+505
-52
@@ -39,8 +39,25 @@ from .base import (
|
||||
normalize_lineweight,
|
||||
shoelace_area,
|
||||
)
|
||||
from .quarantine import (
|
||||
AbandonedCall,
|
||||
DocumentQuarantineError,
|
||||
QuarantineRecord,
|
||||
quarantine_refusal,
|
||||
real_call_name,
|
||||
)
|
||||
|
||||
__all__ = ["EzdxfBackend", "UnsupportedCapabilityError", "_unsupported"]
|
||||
__all__ = [
|
||||
"DocumentQuarantineError",
|
||||
"EzdxfBackend",
|
||||
"UnsupportedCapabilityError",
|
||||
"_unsupported",
|
||||
]
|
||||
|
||||
#: How many abandoned calls ``system_status`` keeps. The interesting one is
|
||||
#: always the most recent; older entries exist so a support transcript shows a
|
||||
#: document that has been abandoned on repeatedly rather than once.
|
||||
_MAX_ABANDONED_RECORDS = 10
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -337,6 +354,64 @@ def _flatten_measure(ent, tolerance: float, result: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
#: ISO-conformant dimension style for a new headless document, in mm.
|
||||
#:
|
||||
#: ezdxf's bare `Standard` dimstyle leaves every one of these unset, and the
|
||||
#: renderer then falls back to its own defaults rather than to the DXF schema
|
||||
#: defaults -- 1.0 mm text with a *comma* decimal marker. ISO 3098 puts the
|
||||
#: minimum lettering height at 2.5 mm and ISO 129 wants a point, so every
|
||||
#: headless drawing this server made was non-conformant on both counts while
|
||||
#: the document header claimed `$DIMTXT 2.5`.
|
||||
#:
|
||||
#: `ezdxf.new(setup=True)` is not the fix: measured, it renders a 100 mm
|
||||
#: dimension as "10000" at 0.25 mm text, because its metric styles carry a
|
||||
#: length factor. Setting the attributes explicitly is what reaches the
|
||||
#: renderer, because only a *stored* value does.
|
||||
_ISO_DIMSTYLE = {
|
||||
"dimtxt": 2.5, # ISO 3098 minimum lettering height
|
||||
"dimasz": 2.5, # arrowhead, matched to the text
|
||||
"dimdsep": ord("."), # ISO 129 decimal marker; ezdxf defaults to a comma
|
||||
"dimdec": 2,
|
||||
"dimexe": 1.25, # extension line beyond the dimension line
|
||||
"dimexo": 0.625, # offset from the measured feature
|
||||
"dimgap": 0.625, # gap around the text
|
||||
}
|
||||
|
||||
|
||||
def _normalise_dimstyle_rounding(doc) -> None:
|
||||
"""Discard a stored ``dimrnd`` of 0.0, which AutoCAD means as *no* rounding.
|
||||
|
||||
ezdxf applies ``xround(value, dimrnd)`` whenever the attribute is present at
|
||||
all, and ``xround(33.333, 0.0)`` is ``33``. Saving a drawing writes the
|
||||
attribute out, so every reopened file — including one this server saved
|
||||
itself — silently rounded every dimension to a whole unit *before* DIMDEC
|
||||
got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6.
|
||||
|
||||
Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm
|
||||
rounding keeps it.
|
||||
"""
|
||||
try:
|
||||
style = doc.dimstyles.get("Standard")
|
||||
except Exception:
|
||||
return
|
||||
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
|
||||
style.dxf.discard("dimrnd")
|
||||
|
||||
|
||||
def _apply_iso_dimstyle(doc) -> None:
|
||||
"""Store ISO defaults on the document's `Standard` dimension style."""
|
||||
try:
|
||||
style = doc.dimstyles.get("Standard")
|
||||
except Exception: # a template without a Standard style is the caller's own
|
||||
return
|
||||
for attribute, value in _ISO_DIMSTYLE.items():
|
||||
style.dxf.set(attribute, value)
|
||||
doc.header["$DIMTXT"] = _ISO_DIMSTYLE["dimtxt"]
|
||||
doc.header["$DIMASZ"] = _ISO_DIMSTYLE["dimasz"]
|
||||
doc.header["$DIMDSEP"] = _ISO_DIMSTYLE["dimdsep"]
|
||||
doc.header["$DIMDEC"] = _ISO_DIMSTYLE["dimdec"]
|
||||
|
||||
|
||||
#: AutoCAD's HPISLANDDETECTION, by the code DXF stores in group 75.
|
||||
_HATCH_STYLE_NAMES = {0: "normal", 1: "outer", 2: "ignore"}
|
||||
|
||||
@@ -443,6 +518,150 @@ def _measure_hatch(ent, tolerance: float, result: dict) -> dict:
|
||||
return _round_measure(result)
|
||||
|
||||
|
||||
#: The dimension style every headless dimension is rendered from.
|
||||
#:
|
||||
#: `msp.add_*_dim` defaults to `dimstyle="EZDXF"` (`"EZ_RADIUS"` for the radial
|
||||
#: ones) and ezdxf silently substitutes `Standard` when that entry is missing —
|
||||
#: which it is in every document this server creates, so the five call sites got
|
||||
#: `Standard` by accident rather than by name. Naming it keeps three things
|
||||
#: pointed at one style: `_apply_iso_dimstyle`, which writes it; the `iso128`
|
||||
#: critique, which grades it; and the header fold below, which compares against
|
||||
#: it. A file authored by `ezdxf.new(setup=True)` *does* carry an `EZDXF` entry,
|
||||
#: and its metric styles carry a length factor — measured, that renders a 100 mm
|
||||
#: dimension as "10000" — so on an opened file the substitution would have
|
||||
#: pointed the dimension at a style nobody here configured and left both the
|
||||
#: fold and the ISO defaults inert.
|
||||
_DIMSTYLE_NAME = "Standard"
|
||||
|
||||
|
||||
#: DIM* header variables folded onto the dimension being created, as
|
||||
#: ``dimstyle attribute -> (header variable, renderer fallback, coercion)``.
|
||||
#:
|
||||
#: The third column is what ezdxf's *renderer* resolves the attribute to when
|
||||
#: the dimstyle stores nothing — `DimStyleOverride.get(attr, default)` with the
|
||||
#: defaults spelled out in `render/dim_base.py` and `get_decimal_separator`.
|
||||
#: Deliberately NOT the DXF schema default, which differs for three of the six:
|
||||
#: dimtxt 1.0 vs 2.5, dimasz 0.25 vs 2.5, dimdsep 0 (a comma) vs 44. Keying the
|
||||
#: "header already agrees, nothing to fold" test on the schema default is the
|
||||
#: invisible way to get this wrong — it switches the fold off for exactly the
|
||||
#: opened-file case it exists for, and switches it *on* for a fresh drawing,
|
||||
#: where folding dimdsep would put ezdxf's comma back onto a sheet ISO 129 wants
|
||||
#: a point on.
|
||||
#:
|
||||
#: This is an allowlist, not "every DIM* variable". $DIMADEC and $DIMAZIN sit at
|
||||
#: 0 in a fresh header while the renderer falls back to 2 and 2, so completing
|
||||
#: the table would silently reformat every angular dimension this server has
|
||||
#: ever drawn (measured: 239.04° becomes 239°). $DIMGAP is the other one that
|
||||
#: looks safe and is not: `build_dim_override` sets dimgap=-1.0 for
|
||||
#: `tol_mode="basic"` to draw the theoretically-exact box, and a fold would be
|
||||
#: competing with it for the same key.
|
||||
_HEADER_DIMVARS: dict[str, tuple[str, Any, Any]] = {
|
||||
"dimscale": ("$DIMSCALE", 1.0, float),
|
||||
"dimtxt": ("$DIMTXT", 1.0, float),
|
||||
"dimasz": ("$DIMASZ", 0.25, float),
|
||||
"dimdec": ("$DIMDEC", 2, int),
|
||||
"dimdsep": ("$DIMDSEP", 0, int), # a character code; 0 renders as a comma
|
||||
"dimzin": ("$DIMZIN", 8, int),
|
||||
}
|
||||
|
||||
|
||||
def _coerce_dimvar(value, coerce):
|
||||
"""The value as the override dict needs it, or None if it is junk.
|
||||
|
||||
ezdxf validates the *header* (a string in `$DIMDSEP` raises DXFValueError)
|
||||
but not the override dict, so a float 46.0 or a stray string reaching
|
||||
`dimdsep` renders garbage instead of raising. Coercing here is what keeps an
|
||||
opened file's odd header out of the drawing.
|
||||
"""
|
||||
try:
|
||||
return coerce(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _header_dimvar(doc, header_var: str, coerce):
|
||||
"""The header's value for a DIM* variable, or None if it has none usable."""
|
||||
try:
|
||||
raw = doc.header.get(header_var, None)
|
||||
except Exception: # an opened document may not have a readable header
|
||||
return None
|
||||
return None if raw is None else _coerce_dimvar(raw, coerce)
|
||||
|
||||
|
||||
def _style_dimvar(doc, attribute: str, fallback, coerce):
|
||||
"""What the renderer would use with no header and no override in play."""
|
||||
try:
|
||||
style = doc.dimstyles.get(_DIMSTYLE_NAME)
|
||||
if style.dxf.hasattr(attribute):
|
||||
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
|
||||
if stored is not None:
|
||||
return stored
|
||||
except Exception: # a document without a Standard style is the caller's own
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
def effective_dimvar(doc, attribute: str):
|
||||
"""The value the next dimension will actually be rendered with.
|
||||
|
||||
Header first, then the stored dimstyle attribute, then ezdxf's renderer
|
||||
fallback — the order `_with_header_dimvars` produces below, and the order a
|
||||
live seat uses (the header DIMVARS *are* the current settings; the dimstyle
|
||||
table entry is only the base underneath them). `drawing_critique` has to
|
||||
resolve the same value, or it reports a defect the sheet does not have and
|
||||
clears one it does.
|
||||
"""
|
||||
header_var, fallback, coerce = _HEADER_DIMVARS[attribute]
|
||||
from_header = _header_dimvar(doc, header_var, coerce)
|
||||
if from_header is not None:
|
||||
return from_header
|
||||
return _style_dimvar(doc, attribute, fallback, coerce)
|
||||
|
||||
|
||||
def _with_header_dimvars(doc, override: dict | None) -> dict | None:
|
||||
"""Carry the DIM* header variables onto the dimension being created.
|
||||
|
||||
`system_set_variable("DIMTXT", 5.0)` writes the header variable, which is
|
||||
what AutoCAD consults when it builds a dimension — so the call worked on the
|
||||
live backend. ezdxf renders from the dimstyle table entry plus the
|
||||
per-dimension override and never reads the header, so headlessly the same
|
||||
call reported success and changed nothing. Measured on v1.5.1: after
|
||||
DIMTXT=5.0, DIMASZ=6.0, DIMDEC=1, DIMDSEP=44 and DIMZIN=0 the header read
|
||||
all five back, and the next dimension was byte-identical to the one before
|
||||
them — still 2.5 mm text, 2.5 arrows, "33.33". Folding them into the
|
||||
override makes one call mean one thing on both engines.
|
||||
|
||||
v1.5.1 folded `$DIMSCALE` alone, which is why this is a table rather than a
|
||||
fifth copy of the same six lines.
|
||||
|
||||
A variable whose header value already matches what the renderer would use is
|
||||
skipped, so an untouched `drawing_new` drawing gains no override at all —
|
||||
header and style agree on all six there. `setdefault` is what keeps the fold
|
||||
below `build_dim_override`: an explicit tolerance/limit/basic key, or any
|
||||
value a caller passed, always outranks the header.
|
||||
"""
|
||||
merged = override
|
||||
for attribute, (header_var, fallback, coerce) in _HEADER_DIMVARS.items():
|
||||
from_header = _header_dimvar(doc, header_var, coerce)
|
||||
if from_header is None:
|
||||
continue
|
||||
if attribute == "dimscale" and not from_header:
|
||||
# DIMSCALE 0 means "scale to the paper-space viewport". There is no
|
||||
# viewport to scale to here, and folding a literal 0 would render
|
||||
# the dimension to nothing; 1.0 is what the renderer already uses.
|
||||
continue
|
||||
stored = _style_dimvar(doc, attribute, fallback, coerce)
|
||||
if isinstance(from_header, float):
|
||||
if abs(from_header - float(stored)) < 1e-9:
|
||||
continue
|
||||
elif from_header == stored:
|
||||
continue
|
||||
if merged is override: # only allocate once something is actually folded
|
||||
merged = dict(override or {})
|
||||
merged.setdefault(attribute, from_header)
|
||||
return merged
|
||||
|
||||
|
||||
def _round_measure(result: dict) -> dict:
|
||||
"""Six decimal places, matching analysis_measure_distance."""
|
||||
result["area"] = round(float(result["area"]), 6)
|
||||
@@ -720,6 +939,10 @@ class EzdxfBackend(AutoCADBackend):
|
||||
self._transaction_stack: list[Path] = [] # isolated transaction snapshots
|
||||
self._connected = False
|
||||
self._lock = asyncio.Lock()
|
||||
# R32: the document a timed-out call was abandoned on is untrusted until
|
||||
# it is replaced. See backends/quarantine.py for what that buys.
|
||||
self._quarantine: QuarantineRecord | None = None
|
||||
self._abandoned_calls: list[QuarantineRecord] = []
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -813,6 +1036,9 @@ class EzdxfBackend(AutoCADBackend):
|
||||
async def disconnect(self) -> None:
|
||||
self._cleanup_undo_stack()
|
||||
self._reset_document_state()
|
||||
# The document goes away with the backend, so nothing is left to protect.
|
||||
# The record stays in _abandoned_calls for anyone reading the transcript.
|
||||
self._clear_quarantine("disconnect")
|
||||
self._connected = False
|
||||
|
||||
def _cleanup_undo_stack(self):
|
||||
@@ -863,17 +1089,128 @@ class EzdxfBackend(AutoCADBackend):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _timeout_error(func, timeout: float) -> RuntimeError:
|
||||
name = getattr(func, "__name__", "call")
|
||||
def _timeout_error(call: str, timeout: float) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
f"ezdxf call '{name}' timed out after {timeout:g}s. asyncio.to_thread "
|
||||
"cannot be cancelled, so the worker thread was abandoned and the drawing "
|
||||
"may be left mid-write - reopen it before trusting its contents. Raise "
|
||||
"EZDXF_CALL_TIMEOUT (seconds; 0 disables the timeout entirely) if this "
|
||||
"operation is legitimately slow."
|
||||
f"ezdxf call '{call}' timed out after {timeout:g}s. asyncio.to_thread "
|
||||
"cannot be cancelled, so the worker thread was abandoned and may still be "
|
||||
"writing to the drawing. Every later call on this document is refused - "
|
||||
"reads included - until drawing_open(path), drawing_new() or "
|
||||
"drawing_close() rebinds it, or until the abandoned call is seen to finish "
|
||||
"cleanly, which lifts the refusal by itself. system_status() stays "
|
||||
"answerable throughout. Raise EZDXF_CALL_TIMEOUT (seconds; 0 disables the "
|
||||
"timeout entirely) if this operation is legitimately slow."
|
||||
)
|
||||
|
||||
async def _async(self, func, *args, **kwargs):
|
||||
# ── document quarantine (R32) ─────────────────────────────────────────────
|
||||
|
||||
def _reap_quarantine(self) -> None:
|
||||
"""The free exit: an abandoned call that finished cleanly is no corruption.
|
||||
|
||||
The common real trigger for the deadline is a legitimately slow
|
||||
``drawing_audit`` or a big ``drawing_export_pdf`` against the 120s
|
||||
default, not a hang. If the ``_sync`` body ran to completion the document
|
||||
holds exactly the state that call intended, and forcing a reopen for that
|
||||
would be its own defect.
|
||||
|
||||
This inference is sound *only* because every other caller was refused in
|
||||
the meantime, so "the call finished" really does mean "the document is
|
||||
what that call intended". Add one mutating exemption to the gate and the
|
||||
lift becomes a lie.
|
||||
|
||||
The lift is not silent: the caller never received the return value, so a
|
||||
late-completing ``entity_create_*`` leaves an entity whose handle nobody
|
||||
holds. That fact survives in the record.
|
||||
"""
|
||||
record = self._quarantine
|
||||
if record is None or not record.watcher.finished:
|
||||
return
|
||||
if record.watcher.error is not None:
|
||||
if record.outcome == "running":
|
||||
error = record.watcher.error
|
||||
record.outcome = "failed_after_deadline"
|
||||
record.detail = f"{type(error).__name__}: {error}"
|
||||
log.error(
|
||||
"abandoned ezdxf call %r raised %s after its deadline; the document "
|
||||
"stays quarantined - it was left mid-write",
|
||||
record.call,
|
||||
record.detail,
|
||||
)
|
||||
return
|
||||
record.outcome = "completed_after_deadline"
|
||||
record.capture_lost_result()
|
||||
self._quarantine = None
|
||||
log.warning(
|
||||
"abandoned ezdxf call %r completed %.1fs after its deadline; lifting the "
|
||||
"document quarantine. Its return value never reached the caller (%s), so "
|
||||
"anything it created is in the drawing with no handle reported",
|
||||
record.call,
|
||||
record.age(),
|
||||
record.result_repr,
|
||||
)
|
||||
|
||||
def _clear_quarantine(self, released_by: str, new_path: str | None = None):
|
||||
"""Release the quarantine because ``self._doc`` was just rebound.
|
||||
|
||||
Only the three calls that construct a *fresh* ``Drawing`` may do this:
|
||||
the runaway keeps the object it captured, so its remaining writes land on
|
||||
an orphan nobody reads. That holds for a ``_sync`` which captured
|
||||
doc/msp once at entry — the shape of nearly every closure in this file.
|
||||
One that re-reads ``self._msp()`` inside its own loop would follow the
|
||||
rebind into the fresh document, and ``_async`` cannot prevent that, which
|
||||
is why a still-live runaway is said out loud rather than implied away.
|
||||
"""
|
||||
record = self._quarantine
|
||||
if record is None:
|
||||
return None
|
||||
self._quarantine = None
|
||||
record.outcome = "released"
|
||||
record.released_by = released_by
|
||||
if record.thread_alive():
|
||||
record.detail = "the abandoned worker was still running at recovery time"
|
||||
log.warning(
|
||||
"%s cleared the document quarantine while the abandoned %r worker is "
|
||||
"STILL RUNNING; its writes go to the replaced Drawing unless its _sync "
|
||||
"re-reads self._msp() inside its own loop",
|
||||
released_by,
|
||||
record.call,
|
||||
)
|
||||
if new_path and record.document_path:
|
||||
try:
|
||||
same = os.path.abspath(new_path) == os.path.abspath(record.document_path)
|
||||
except (OSError, ValueError): # a path this OS cannot normalise
|
||||
same = new_path == record.document_path
|
||||
if same:
|
||||
log.warning(
|
||||
"%s is reopening %s, the same path the abandoned %r call was working "
|
||||
"against; if that call is inside doc.saveas the file is being written "
|
||||
"right now and this read may be of a truncated document",
|
||||
released_by,
|
||||
new_path,
|
||||
record.call,
|
||||
)
|
||||
return record
|
||||
|
||||
def _quarantine_document(self, func, watcher, timeout: float) -> str:
|
||||
"""Record an abandoned call and refuse the document until it is replaced.
|
||||
|
||||
``func`` is the caller's closure (it carries the qualname the message
|
||||
needs); ``watcher`` is the wrapper the worker thread is actually running,
|
||||
and the only thing that can report whether it ever finished.
|
||||
"""
|
||||
call = real_call_name(func)
|
||||
record = QuarantineRecord(
|
||||
call,
|
||||
timeout,
|
||||
watcher,
|
||||
document_path=self._doc_path,
|
||||
document_id=id(self._doc),
|
||||
)
|
||||
self._quarantine = record
|
||||
self._abandoned_calls.append(record)
|
||||
del self._abandoned_calls[:-_MAX_ABANDONED_RECORDS]
|
||||
return call
|
||||
|
||||
async def _async(self, func, *args, quarantine_exit: bool = False, **kwargs):
|
||||
"""Run a blocking ezdxf call in a worker thread, serialised and timed out.
|
||||
|
||||
The ezdxf ``Drawing`` is not thread-safe, so every backend method funnels
|
||||
@@ -898,41 +1235,90 @@ class EzdxfBackend(AutoCADBackend):
|
||||
for the lock does not abandon it: that lock is held by a live call which
|
||||
will release it normally. Outside cancellation (client disconnect) also
|
||||
releases normally, as it did before the timeout existed.
|
||||
|
||||
R32 — swapping the lock is only half the answer, and on its own it is the
|
||||
more dangerous half: it removes the mutual exclusion the lock existed
|
||||
for, so the *next* call runs against the same ``Drawing`` the runaway is
|
||||
still mutating. Measured, that returned a fresh circle handle, an entity
|
||||
count for a modelspace 50 entities further on, and a valid DXF of a state
|
||||
that never existed. So the abandonment quarantines the document, and
|
||||
every later call is refused **before** ``lock.acquire()`` — after the
|
||||
acquire the refusal would queue behind an unrelated in-flight call and
|
||||
the caller would wait the full timeout to be told the document is
|
||||
untrusted. Reads are refused too: a read of a container another thread is
|
||||
appending to does not tear loudly, it runs unbounded — ``drawing_info``
|
||||
blew its own 2.0s deadline at 2.03s and abandoned a *second* worker.
|
||||
|
||||
``quarantine_exit=True`` is for the three calls that rebind ``self._doc``
|
||||
to a freshly constructed object. They still take the lock, because two
|
||||
concurrent recoveries would otherwise both rebind it and one fresh
|
||||
document would be silently discarded with its caller's work in it.
|
||||
"""
|
||||
# The gate, before any await: a refusal must not queue.
|
||||
if self._quarantine is not None:
|
||||
self._reap_quarantine()
|
||||
if self._quarantine is not None and not quarantine_exit:
|
||||
raise quarantine_refusal(self._quarantine)
|
||||
|
||||
timeout = config.settings.ezdxf_call_timeout
|
||||
if timeout <= 0:
|
||||
# No deadline means no abandon branch, so nothing here can quarantine.
|
||||
async with self._lock:
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
lock = self._lock
|
||||
# One `asyncio.timeout_at` for both phases rather than two
|
||||
# `asyncio.wait_for`s. `wait_for` wraps its awaitable in a Task, so the
|
||||
# old shape cost two extra Tasks and two timer handles on *every* call
|
||||
# — 4,000 of each to draw 2,000 lines, which is where the throughput
|
||||
# this release lost was going. `acquired` keeps the two failure modes
|
||||
# distinguishable, which is the whole reason they were separate.
|
||||
acquired = False
|
||||
call: asyncio.Future | None = None
|
||||
# The only place an abandoned thread's outcome is observable is inside
|
||||
# the thread: `call` is CANCELLED by the deadline, so its done-callback
|
||||
# reports the await ending and never the worker. See AbandonedCall.
|
||||
watched = AbandonedCall(func)
|
||||
try:
|
||||
await asyncio.wait_for(lock.acquire(), timeout)
|
||||
async with asyncio.timeout_at(deadline):
|
||||
await lock.acquire()
|
||||
acquired = True
|
||||
call = asyncio.ensure_future(asyncio.to_thread(watched, *args, **kwargs))
|
||||
result = await call
|
||||
except TimeoutError as exc:
|
||||
if not acquired:
|
||||
# Timed out queueing behind someone else's live call. That lock
|
||||
# is held by a call that will release it normally; do not
|
||||
# abandon it.
|
||||
raise self._busy_error(timeout) from exc
|
||||
|
||||
call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs))
|
||||
try:
|
||||
result = await asyncio.wait_for(call, max(deadline - loop.time(), 0.0))
|
||||
except TimeoutError as exc:
|
||||
if not call.cancelled():
|
||||
# The call itself raised TimeoutError (an OSError subclass, and
|
||||
# the same type wait_for raises) rather than overrunning the
|
||||
# deadline. That is an ordinary failure: release the lock and
|
||||
# let it through unchanged instead of blaming the deadline.
|
||||
if call is None or not call.cancelled():
|
||||
# The call itself raised TimeoutError (an OSError subclass,
|
||||
# and the same type the deadline raises) rather than overrunning
|
||||
# the deadline. That is an ordinary failure: release the lock
|
||||
# and let it through unchanged instead of blaming the deadline.
|
||||
lock.release()
|
||||
raise
|
||||
if self._lock is lock:
|
||||
self._lock = asyncio.Lock()
|
||||
# Same handler, same branch as the lock swap: the two other exits
|
||||
# above touched nothing, and quarantining on either would refuse the
|
||||
# whole document for a failure that never reached it.
|
||||
name = self._quarantine_document(func, watched, timeout)
|
||||
log.error(
|
||||
"ezdxf call %r exceeded EZDXF_CALL_TIMEOUT=%gs; abandoning the worker "
|
||||
"thread and the lock it holds",
|
||||
getattr(func, "__name__", func),
|
||||
"thread and the lock it holds, and quarantining the document it may "
|
||||
"still be writing to",
|
||||
name,
|
||||
timeout,
|
||||
)
|
||||
raise self._timeout_error(func, timeout) from exc
|
||||
raise self._timeout_error(name, timeout) from exc
|
||||
except BaseException:
|
||||
# Only ours to release if we ever took it — cancellation can land
|
||||
# while still queueing, and releasing someone else's lock there
|
||||
# would hand the document to two callers at once.
|
||||
if acquired:
|
||||
lock.release()
|
||||
raise
|
||||
lock.release()
|
||||
@@ -986,17 +1372,26 @@ class EzdxfBackend(AutoCADBackend):
|
||||
def _sync():
|
||||
if template and Path(template).exists():
|
||||
self._doc = ezdxf.readfile(template)
|
||||
_normalise_dimstyle_rounding(self._doc)
|
||||
else:
|
||||
self._doc = ezdxf.new(dxfversion="R2010")
|
||||
_apply_iso_dimstyle(self._doc)
|
||||
# R32: the rebind above is the release — the runaway keeps the
|
||||
# Drawing it captured, so its remaining writes land on an orphan.
|
||||
# Clear before _reset_history_baseline, which snapshots the new doc.
|
||||
released = self._clear_quarantine("drawing_new")
|
||||
self._doc_path = None
|
||||
self._dirty = False
|
||||
self._current_layer = "0"
|
||||
self._reset_document_state()
|
||||
self._current_space = "Model"
|
||||
self._reset_history_baseline()
|
||||
return {"ok": True, "name": "untitled.dxf"}
|
||||
result = {"ok": True, "name": "untitled.dxf"}
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
return result
|
||||
|
||||
return await self._async(_sync)
|
||||
return await self._async(_sync, quarantine_exit=True)
|
||||
|
||||
async def drawing_open(self, path: str) -> dict:
|
||||
def _sync():
|
||||
@@ -1012,6 +1407,12 @@ class EzdxfBackend(AutoCADBackend):
|
||||
f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override."
|
||||
)
|
||||
self._doc = ezdxf.readfile(path)
|
||||
_normalise_dimstyle_rounding(self._doc)
|
||||
# R32: only reached if the read succeeded. Reopening the very path a
|
||||
# runaway doc.saveas is writing either fails loudly here (acceptable)
|
||||
# or hands back a truncated document (not) — _clear_quarantine
|
||||
# compares the two paths and says so.
|
||||
released = self._clear_quarantine("drawing_open", path)
|
||||
self._doc_path = path
|
||||
self._dirty = False
|
||||
try:
|
||||
@@ -1022,9 +1423,12 @@ class EzdxfBackend(AutoCADBackend):
|
||||
self._reset_document_state()
|
||||
self._current_space = "Model"
|
||||
self._reset_history_baseline()
|
||||
return {"ok": True, "name": Path(path).name, "path": path}
|
||||
result = {"ok": True, "name": Path(path).name, "path": path}
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
return result
|
||||
|
||||
return await self._async(_sync)
|
||||
return await self._async(_sync, quarantine_exit=True)
|
||||
|
||||
async def drawing_save(self, path: str | None = None) -> dict:
|
||||
save_path = path or self._doc_path
|
||||
@@ -2003,16 +2407,32 @@ class EzdxfBackend(AutoCADBackend):
|
||||
|
||||
async def drawing_close(self, save: bool = True) -> dict:
|
||||
def _sync():
|
||||
if save and self._dirty and self._doc_path:
|
||||
# R32: closing is a way out of a quarantine, but saving on the way is
|
||||
# not. That save is the exact defect measured — a valid DXF holding
|
||||
# 15200 of the 60000 entities the runaway went on to write, a
|
||||
# considered document of a state that never existed. Say what was
|
||||
# dropped instead of writing it.
|
||||
quarantined = self._quarantine is not None
|
||||
if save and self._dirty and self._doc_path and not quarantined:
|
||||
self._doc.saveas(self._doc_path)
|
||||
self._cleanup_undo_stack()
|
||||
self._doc = None
|
||||
released = self._clear_quarantine("drawing_close")
|
||||
self._doc_path = None
|
||||
self._dirty = False
|
||||
self._reset_document_state()
|
||||
return {"ok": True}
|
||||
result = {"ok": True}
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
result["saved"] = False
|
||||
result["warning"] = (
|
||||
"the document was quarantined after a call was abandoned mid-write, "
|
||||
"so unsaved changes were discarded rather than serialised out of a "
|
||||
"drawing another thread may still have been mutating"
|
||||
)
|
||||
return result
|
||||
|
||||
return await self._async(_sync)
|
||||
return await self._async(_sync, quarantine_exit=True)
|
||||
|
||||
async def drawing_undo(self) -> dict:
|
||||
self._require_undo_history("drawing_undo")
|
||||
@@ -2140,6 +2560,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
|
||||
def _restore_snapshot(self, path: Path) -> None:
|
||||
self._doc = ezdxf.readfile(str(path))
|
||||
_normalise_dimstyle_rounding(self._doc)
|
||||
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
|
||||
self._dirty = True
|
||||
|
||||
@@ -2870,6 +3291,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
from engineering.tolerances import build_dim_override
|
||||
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
override = _with_header_dimvars(self._require_doc(), override)
|
||||
msp = self._msp()
|
||||
dim = msp.add_linear_dim(
|
||||
base=(float(dim_x), float(dim_y)),
|
||||
@@ -2877,6 +3299,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
p2=(float(x2), float(y2)),
|
||||
angle=float(rotation),
|
||||
text=text if text is not None else "<>",
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
@@ -2906,6 +3329,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
distance=math.sqrt(
|
||||
(float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2
|
||||
),
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
override=_with_header_dimvars(self._require_doc(), None),
|
||||
)
|
||||
dim.render()
|
||||
ent = dim.dimension
|
||||
@@ -2936,6 +3361,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
line1=((vxf, vyf), (float(x1), float(y1))),
|
||||
line2=((vxf, vyf), (float(x2), float(y2))),
|
||||
location=(float(tx), float(ty)),
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
override=_with_header_dimvars(self._require_doc(), None),
|
||||
)
|
||||
dim.render()
|
||||
ent = dim.dimension
|
||||
@@ -2963,19 +3390,23 @@ class EzdxfBackend(AutoCADBackend):
|
||||
from engineering.tolerances import build_dim_override
|
||||
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
override = _with_header_dimvars(self._require_doc(), override)
|
||||
msp = self._msp()
|
||||
cxf, cyf = float(cx), float(cy)
|
||||
radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2)
|
||||
angle_rad = math.atan2(chord_y - cyf, chord_x - cxf)
|
||||
leader = float(leader_length)
|
||||
mpoint = (
|
||||
cxf + (radius + leader) * math.cos(angle_rad),
|
||||
cyf + (radius + leader) * math.sin(angle_rad),
|
||||
)
|
||||
# Same correction as dimension_diameter: the size comes from
|
||||
# `radius`, and `location` only places the text.
|
||||
dim = msp.add_radius_dim(
|
||||
center=(cxf, cyf),
|
||||
mpoint=mpoint,
|
||||
radius=radius,
|
||||
location=(
|
||||
cxf + (radius + leader) * math.cos(angle_rad),
|
||||
cyf + (radius + leader) * math.sin(angle_rad),
|
||||
),
|
||||
text=text if text is not None else "<>",
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
@@ -3004,6 +3435,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
from engineering.tolerances import build_dim_override
|
||||
|
||||
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
|
||||
override = _with_header_dimvars(self._require_doc(), override)
|
||||
msp = self._msp()
|
||||
x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2)
|
||||
cx = (x1f + x2f) / 2
|
||||
@@ -3011,14 +3443,20 @@ class EzdxfBackend(AutoCADBackend):
|
||||
radius = math.sqrt((x2f - x1f) ** 2 + (y2f - y1f) ** 2) / 2
|
||||
angle_rad = math.atan2(y2f - y1f, x2f - x1f)
|
||||
leader = float(leader_length)
|
||||
mpoint = (
|
||||
cx + (radius + leader) * math.cos(angle_rad),
|
||||
cy + (radius + leader) * math.sin(angle_rad),
|
||||
)
|
||||
# The measured size comes from `radius`; `location` only places the
|
||||
# text. This used to pass centre + (radius + leader) as `mpoint`,
|
||||
# which ezdxf measures to — so every diameter came out
|
||||
# 2 x leader_length too large. 60 on a true 40, at default
|
||||
# settings, with nothing on the drawing to show for it.
|
||||
dim = msp.add_diameter_dim(
|
||||
center=(cx, cy),
|
||||
mpoint=mpoint,
|
||||
radius=radius,
|
||||
location=(
|
||||
cx + (radius + leader) * math.cos(angle_rad),
|
||||
cy + (radius + leader) * math.sin(angle_rad),
|
||||
),
|
||||
text=text if text is not None else "<>",
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
@@ -3667,7 +4105,6 @@ class EzdxfBackend(AutoCADBackend):
|
||||
min_area: float | None = None,
|
||||
) -> dict:
|
||||
def _sync():
|
||||
from engineering.measure import polygon_area_perimeter
|
||||
|
||||
if min_area is not None and float(min_area) < 0:
|
||||
return {"ok": False, "error": "min_area cannot be negative; no area is negative"}
|
||||
@@ -3688,18 +4125,25 @@ class EzdxfBackend(AutoCADBackend):
|
||||
]
|
||||
|
||||
def _area(entity) -> float | None:
|
||||
"""None means "this shape has no area", not "its area is zero"."""
|
||||
dxftype = entity.dxftype()
|
||||
if dxftype == "CIRCLE":
|
||||
return math.pi * float(entity.dxf.radius) ** 2
|
||||
if dxftype == "LWPOLYLINE" and entity.closed:
|
||||
points = [[p[0], p[1], p[4]] for p in entity.get_points()]
|
||||
return polygon_area_perimeter(points, closed=True)[0]
|
||||
if dxftype in ("SOLID", "TRACE", "HATCH"):
|
||||
"""None means "this shape has no area", not "its area is zero".
|
||||
|
||||
Answered by the same measurement `entity_measure` uses, so the
|
||||
two cannot disagree about the same entity. They did: this used
|
||||
to read ``entity.dxf.get("area", 0.0)`` for SOLID, TRACE and
|
||||
HATCH, none of which carry an ``area`` DXF attribute, so every
|
||||
one of them measured 0.0 and any positive threshold silently
|
||||
excluded every hatch in the drawing while ``filtered_by``
|
||||
reported that the filter had run.
|
||||
|
||||
The two refusals stay distinguishable. A type that bounds no
|
||||
area on any engine raises RuntimeError and is *excluded*; a
|
||||
type this engine cannot evaluate (ACIS) refuses with a
|
||||
capability and is also excluded, because "I could not measure
|
||||
it" must never read as "it is big enough".
|
||||
"""
|
||||
try:
|
||||
return float(entity.dxf.get("area", 0.0) or 0.0)
|
||||
except Exception:
|
||||
return None
|
||||
return float(_measure_dxf_entity(entity, 0.001)["area"])
|
||||
except (UnsupportedCapabilityError, RuntimeError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
handles = []
|
||||
@@ -4795,6 +5239,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
p = self._transaction_stack.pop()
|
||||
try:
|
||||
self._doc = ezdxf.readfile(str(p))
|
||||
_normalise_dimstyle_rounding(self._doc)
|
||||
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
|
||||
self._dirty = True
|
||||
finally:
|
||||
@@ -4809,6 +5254,12 @@ class EzdxfBackend(AutoCADBackend):
|
||||
# ── system ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def system_status(self) -> dict:
|
||||
# R32: deliberately NOT routed through _async. It reads self._doc
|
||||
# directly, which is what keeps it answerable while the document is
|
||||
# quarantined; putting it behind the lock would make the diagnostic
|
||||
# channel the first thing the quarantine closes, leaving the user with a
|
||||
# refusal and no way to ask what happened.
|
||||
self._reap_quarantine()
|
||||
has_doc = self._doc is not None
|
||||
return {
|
||||
"backend": "ezdxf",
|
||||
@@ -4817,6 +5268,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"document_path": self._doc_path,
|
||||
"unsaved_changes": self._dirty,
|
||||
"transaction_depth": len(self._transaction_stack),
|
||||
"quarantine": self._quarantine.to_dict() if self._quarantine else None,
|
||||
"abandoned_calls": [record.to_dict() for record in self._abandoned_calls],
|
||||
"capabilities": [
|
||||
"file_read_write",
|
||||
"dxf_export",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Document quarantine — the vocabulary for a call that was abandoned mid-write.
|
||||
|
||||
``EzdxfBackend._async`` cannot cancel an ``asyncio.to_thread`` worker. When a
|
||||
call overruns ``EZDXF_CALL_TIMEOUT`` the handler swaps in a fresh
|
||||
``asyncio.Lock`` so later callers do not deadlock behind the orphaned one — and
|
||||
that swap is precisely what removes the mutual exclusion the lock existed for.
|
||||
The runaway thread keeps a live reference to the one shared ``ezdxf.Drawing``,
|
||||
which is explicitly not thread-safe, through the closure it was handed.
|
||||
|
||||
Measured, with a runaway ``_sync`` that really appends LINEs to ``_doc``:
|
||||
``entity_create_circle`` after the abandonment returned handle ``A1``;
|
||||
``drawing_info`` reported 116 entities for a modelspace observed at 302 and
|
||||
climbing; ``drawing_save`` returned ``{"ok": True}`` and wrote a valid DXF of
|
||||
15200 of the 60000 entities the runaway would go on to write — a considered
|
||||
document of a state that never existed. Only the timed-out caller was warned.
|
||||
|
||||
So the abandonment quarantines the DOCUMENT, not the backend. The document stays
|
||||
untrusted until it is REPLACED by an object the abandoned thread has no
|
||||
reference to (``drawing_open`` / ``drawing_new`` / ``drawing_close``), or until
|
||||
the abandoned call is seen to finish cleanly — see ``AbandonedCall``.
|
||||
|
||||
This module deliberately has no ezdxf import: ``server.py`` needs the exception
|
||||
type to classify a refusal on a box where a backend's dependencies are missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"AbandonedCall",
|
||||
"DocumentQuarantineError",
|
||||
"QuarantineRecord",
|
||||
"quarantine_refusal",
|
||||
"real_call_name",
|
||||
]
|
||||
|
||||
#: Longest ``repr`` kept for a return value nobody received. A late
|
||||
#: ``entity_list`` can return thousands of entities; the diagnostic is that the
|
||||
#: value was lost, not what was in it.
|
||||
_MAX_RESULT_REPR = 200
|
||||
|
||||
|
||||
def real_call_name(func: Any) -> str:
|
||||
"""The backend method ``func`` belongs to, not the closure's own name.
|
||||
|
||||
Every real backend method hands ``_async`` a closure literally named
|
||||
``_sync`` — measured: ``__name__='_sync'``,
|
||||
``__qualname__='EzdxfBackend.drawing_save.<locals>._sync'`` — so
|
||||
``__name__`` made every production timeout read ``ezdxf call '_sync' timed
|
||||
out``. The qualname carries the name the user typed and was being discarded.
|
||||
"""
|
||||
qualname = getattr(func, "__qualname__", "") or ""
|
||||
parts = qualname.split(".")
|
||||
if "<locals>" in parts:
|
||||
index = parts.index("<locals>")
|
||||
if index:
|
||||
return parts[index - 1]
|
||||
return getattr(func, "__name__", None) or "call"
|
||||
|
||||
|
||||
class AbandonedCall:
|
||||
"""Observation post for a worker thread ``_async`` can no longer await.
|
||||
|
||||
The obvious hook does not work, and this was checked before being relied on:
|
||||
``asyncio.timeout_at`` **cancels** the Future ``_async`` awaits, so by the
|
||||
time the abandon branch runs that Future is already done and
|
||||
``add_done_callback`` fires immediately with ``cancelled()`` True. It reports
|
||||
the *await* ending, never the thread — which ``asyncio.to_thread`` cannot
|
||||
cancel. The only place the thread's outcome is observable is inside the
|
||||
thread, so ``_async`` wraps every timed call in one of these.
|
||||
|
||||
Deliberately no ``threading.Event``: this is allocated on every ``_async``
|
||||
call and an Event carries a Condition and a Lock. ``finished`` is a plain
|
||||
bool written once by the worker and read from the event loop; a bool store is
|
||||
atomic under the GIL and nothing here ever waits on it.
|
||||
"""
|
||||
|
||||
__slots__ = ("func", "finished", "result", "error")
|
||||
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
self.finished = False
|
||||
self.result: Any = None
|
||||
self.error: BaseException | None = None
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
try:
|
||||
self.result = self.func(*args, **kwargs)
|
||||
return self.result
|
||||
except BaseException as exc:
|
||||
self.error = exc
|
||||
raise
|
||||
finally:
|
||||
self.finished = True
|
||||
|
||||
|
||||
class QuarantineRecord:
|
||||
"""One abandoned call, and what became of it.
|
||||
|
||||
Holds the identity of the ``Drawing`` that was live at abandon time as well
|
||||
as its path: rebinding ``self._doc`` isolates a ``_sync`` that captured
|
||||
doc/msp once at entry (the shape of nearly every closure in the backend), but
|
||||
one that re-reads ``self._msp()`` inside its own loop would follow the rebind
|
||||
into the fresh document. ``_async`` cannot prevent that, so recovery says
|
||||
when the runaway was still alive instead of implying it was fenced off.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
call: str,
|
||||
timeout: float,
|
||||
watcher: Any,
|
||||
*,
|
||||
document_path: str | None = None,
|
||||
document_id: int | None = None,
|
||||
):
|
||||
self.call = call
|
||||
self.timeout = float(timeout)
|
||||
self.watcher = watcher
|
||||
self.document_path = document_path
|
||||
self.document_id = document_id
|
||||
self.stamp = time.monotonic()
|
||||
self.outcome = "running"
|
||||
self.detail = ""
|
||||
self.released_by: str | None = None
|
||||
self.result_lost = False
|
||||
self.result_repr: str | None = None
|
||||
|
||||
def age(self) -> float:
|
||||
return time.monotonic() - self.stamp
|
||||
|
||||
def thread_alive(self) -> bool:
|
||||
return not self.watcher.finished
|
||||
|
||||
def capture_lost_result(self) -> None:
|
||||
"""Record that the return value never reached the caller.
|
||||
|
||||
A late-completing ``entity_create_*`` leaves an entity in the document
|
||||
whose handle nobody holds. That is not recoverable, so it is at least
|
||||
reported rather than dropped.
|
||||
"""
|
||||
self.result_lost = True
|
||||
try:
|
||||
text = repr(self.watcher.result)
|
||||
except Exception: # a __repr__ that raises must not break the report
|
||||
text = f"<unreprable {type(self.watcher.result).__name__}>"
|
||||
if len(text) > _MAX_RESULT_REPR:
|
||||
text = text[: _MAX_RESULT_REPR - 3] + "..."
|
||||
self.result_repr = text
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"call": self.call,
|
||||
"timeout": self.timeout,
|
||||
"age_seconds": round(self.age(), 3),
|
||||
"thread_alive": self.thread_alive(),
|
||||
"outcome": self.outcome,
|
||||
"detail": self.detail,
|
||||
"released_by": self.released_by,
|
||||
"result_lost": self.result_lost,
|
||||
"lost_return_value": self.result_repr,
|
||||
"document_path": self.document_path,
|
||||
}
|
||||
|
||||
|
||||
def quarantine_refusal(record: QuarantineRecord) -> DocumentQuarantineError:
|
||||
"""The refusal every later call on a quarantined document gets."""
|
||||
thread = "still running" if record.thread_alive() else "has since stopped"
|
||||
return DocumentQuarantineError(
|
||||
record,
|
||||
f"ezdxf document quarantined: '{record.call}' overran "
|
||||
f"EZDXF_CALL_TIMEOUT={record.timeout:g}s {record.age():.1f}s ago and its worker "
|
||||
f"thread ({thread}) cannot be cancelled, so it may still be mutating this "
|
||||
"drawing. Every call on it is refused, reads included - a read of a container "
|
||||
"another thread is appending to does not fail loudly, it never finishes. "
|
||||
"Recover with drawing_open(path), drawing_new() or drawing_close(): those "
|
||||
"rebind the document to a freshly constructed object the abandoned thread "
|
||||
"cannot reach. Undo and rollback are not a way back - their snapshots may "
|
||||
"themselves have been serialised mid-write. system_status() stays answerable "
|
||||
"and reports the abandoned call.",
|
||||
)
|
||||
|
||||
|
||||
class DocumentQuarantineError(RuntimeError):
|
||||
"""The open document is untrusted because a call was abandoned on it.
|
||||
|
||||
Carries **no** ``capability`` attribute, on purpose.
|
||||
``server._batch_capability_of`` classifies refusals structurally — any
|
||||
exception with a non-empty ``capability`` str plus a callable ``to_dict()``
|
||||
is reported to the client as ``kind: "unsupported"`` and filed by
|
||||
``cad_batch`` as a capability step row. Borrowing one here would tell the
|
||||
client the ezdxf engine cannot draw a circle. The engine is fine; the
|
||||
document is not. This needs its own kind, not a borrowed one.
|
||||
"""
|
||||
|
||||
def __init__(self, record: QuarantineRecord, message: str):
|
||||
super().__init__(message)
|
||||
self.quarantine = record.to_dict()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"ok": False,
|
||||
"kind": "quarantined",
|
||||
"error": str(self),
|
||||
"quarantine": self.quarantine,
|
||||
}
|
||||
+68
-35
@@ -60,7 +60,7 @@ written:
|
||||
|
||||
| Task | Category | Verified against |
|
||||
|---|---|---|
|
||||
| `tool_discovery` | discovery | six AutoCAD command names, each ranking #1 of 154 tools |
|
||||
| `tool_discovery` | discovery | six AutoCAD command names, each found in the top 3 of the 154-tool registry the harness ranks — all six came back #1 in the published run |
|
||||
| `token_budget` | efficiency | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
|
||||
| `hatch_islands` | hatch | 300 filled with the island, 400 ignoring it |
|
||||
| `selection_filter` | selection | window 1, crossing 2, bounding box 3, polygon 1 |
|
||||
@@ -153,34 +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
|
||||
### What the call-timeout guard costs
|
||||
|
||||
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:
|
||||
The published v1.4 report was recorded on CPython 3.14 and the later ones on
|
||||
3.11.15, so comparing those two files directly shows an apparent 2–6× speedup
|
||||
that is almost entirely the interpreter. The comparison that means something is
|
||||
the guard against no guard, on one interpreter, back to back. Three runs of
|
||||
each configuration, median taken by hand — `perf_suite` executes each workload
|
||||
once per invocation and cannot produce a median itself:
|
||||
|
||||
| Workload | 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 |
|
||||
| Workload | `EZDXF_CALL_TIMEOUT=0` | default (120 s) | Cost |
|
||||
|---|---:|---:|---:|
|
||||
| `create_lines_2k` | 243.7 ms | 294.3 ms | **+20.8%** |
|
||||
| `roundtrip_10k` | 1,709.6 ms | 2,049.2 ms | **+19.9%** |
|
||||
|
||||
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:
|
||||
v1.5.0 paid 28.6% on the first workload; v1.5.1 rebuilt the wrapper around a
|
||||
single `asyncio.timeout_at` instead of two `asyncio.wait_for`s — `wait_for`
|
||||
wraps its awaitable in a Task, so the old shape created two extra Tasks and two
|
||||
timer handles per call — which recovered about a third of the overhead. The
|
||||
rest is the Task the abandon-on-timeout design genuinely needs. The guard is
|
||||
what stops one hung ezdxf call wedging a server whose document lock is a single
|
||||
`asyncio.Lock`, so it stays; the knob is documented rather than hidden.
|
||||
|
||||
These figures are hand-collected and have **no published artifact** —
|
||||
`perf-ezdxf.json` records one default-configuration run only. Reproduce:
|
||||
|
||||
```bash
|
||||
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
|
||||
for i in 1 2 3; do
|
||||
python -m benchmarks.perf_suite --out /tmp/on-$i.json
|
||||
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/off-$i.json
|
||||
done
|
||||
```
|
||||
|
||||
## Correctness A/B suite
|
||||
@@ -199,22 +201,53 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
|
||||
python benchmarks/compare_versions.py --json results.json
|
||||
```
|
||||
|
||||
### Result — v1.5.0 vs v1.4.0 (release gate)
|
||||
### Result — v1.5.1 vs v1.5.0 (release gate)
|
||||
|
||||
24 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
[`results/published/ab-v1.4.0-vs-v1.5.0.json`](results/published/ab-v1.4.0-vs-v1.5.0.json).
|
||||
26 checks, ezdxf backend, one subprocess per check. Machine-readable report:
|
||||
[`results/published/ab-v1.5.0-vs-v1.5.1.json`](results/published/ab-v1.5.0-vs-v1.5.1.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.4.0** (baseline) | 21 / 24 | 87.5 % | — | — |
|
||||
| **v1.5.0** (this release) | 24 / 24 | 100 % | 3 | **0** |
|
||||
| **v1.5.0** (baseline) | 24 / 26 | 92.3 % | — | — |
|
||||
| **v1.5.1** (this release) | 26 / 26 | 100 % | 2 | **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.
|
||||
v1.5.0 fails `diameter_dim_measures_the_diameter` and
|
||||
`radius_dim_ignores_the_leader_length`: it has both methods and gets both
|
||||
wrong. That is the defect 1.5.1 exists to fix and the reason 1.5.0 is yanked.
|
||||
|
||||
### Result — this release vs v1.4.0
|
||||
|
||||
Same suite, older baseline:
|
||||
[`results/published/ab-v1.4.0-vs-v1.5.1.json`](results/published/ab-v1.4.0-vs-v1.5.1.json).
|
||||
|
||||
| Version | Checks passing | Pass rate | Fixed | Regressed |
|
||||
|---------|----------------|-----------|-------|-----------|
|
||||
| **v1.4.0** (baseline) | 21 / 26 | 80.8 % | — | — |
|
||||
| **v1.5.1** (this release) | 26 / 26 | 100 % | 5 | **0** |
|
||||
|
||||
> This row used to read `v1.5.0 | 24 / 26 | 92.3 % | 5`, which was impossible on
|
||||
> its face — 21 passing plus 5 fixed is 26, not 24 — and it contradicted the
|
||||
> report it linked. The cause is worth recording: the file named
|
||||
> `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that already carried
|
||||
> the dimension fixes, so it never measured the v1.5.0 *tag*. The v1.5.0 tag
|
||||
> really does score 24/26, which is what the v1.5.0-baseline table above
|
||||
> reports. This table now names the tree it actually measured.
|
||||
|
||||
The pass rate is against the 26-check current suite; v1.4.0 passed 21/21 of the
|
||||
checks that existed when it shipped. The five splits into two kinds, and the
|
||||
difference is the whole reason this lane distinguishes `miss` from `fail`:
|
||||
|
||||
* **Three are new capability.** v1.4.0 has no `entity_measure` and no boundary
|
||||
tracing, so it misses `hatch_area_subtracts_its_island`,
|
||||
`boundary_area_agrees_with_measure` and `two_vertex_circle_has_area` by not
|
||||
having the method (`miss → pass`). They are in the suite because all three
|
||||
broke during v1.5.0 development and nothing outside their own unit tests
|
||||
would have caught it.
|
||||
* **Two are repaired defects.** v1.4.0 *and v1.5.0* both have
|
||||
`dimension_diameter` and `dimension_radius` and both get them wrong
|
||||
(`fail → pass`): `leader_length`, a text placement, was being measured as
|
||||
geometry, so at default settings every diameter callout came out
|
||||
2 × `leader_length` too large. Only 1.5.1 passes them.
|
||||
|
||||
The 21 pre-existing checks are unchanged and all still pass: the discovery
|
||||
layer, `cad_batch`, the 19-module contract split, the layout/viewport family
|
||||
|
||||
@@ -269,6 +269,30 @@ async def two_vertex_circle_has_area():
|
||||
return abs(area - math.pi * 100.0) < 1e-9
|
||||
|
||||
|
||||
async def diameter_dim_measures_the_diameter():
|
||||
"""The number on the callout is the whole point of the callout.
|
||||
|
||||
`add_diameter_dim` measures to `mpoint`; the leader is a text placement.
|
||||
Feeding it centre + (radius + leader) made every diameter come back
|
||||
2 x leader_length too large at DEFAULT settings - 60 on a true 40.
|
||||
"""
|
||||
b = await _b()
|
||||
info = await b.dimension_diameter(-20, 0, 20, 0)
|
||||
dim = b._doc.entitydb.get(info.handle)
|
||||
return abs(dim.get_measurement() - 40.0) < 1e-6
|
||||
|
||||
|
||||
async def radius_dim_ignores_the_leader_length():
|
||||
b = await _b()
|
||||
near = await b.dimension_radius(0, 0, 20, 0, leader_length=5)
|
||||
far = await b.dimension_radius(0, 0, 20, 0, leader_length=40)
|
||||
values = [
|
||||
b._doc.entitydb.get(near.handle).get_measurement(),
|
||||
b._doc.entitydb.get(far.handle).get_measurement(),
|
||||
]
|
||||
return all(abs(v - 20.0) < 1e-6 for v in values)
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"core_line_length": (core_line_length, "Core"),
|
||||
"core_circle_radius": (core_circle_radius, "Core"),
|
||||
@@ -294,6 +318,8 @@ CHECKS = {
|
||||
"hatch_area_subtracts_its_island": (hatch_area_subtracts_its_island, "Measurement"),
|
||||
"boundary_area_agrees_with_measure": (boundary_area_agrees_with_measure, "Measurement"),
|
||||
"two_vertex_circle_has_area": (two_vertex_circle_has_area, "Measurement"),
|
||||
"diameter_dim_measures_the_diameter": (diameter_dim_measures_the_diameter, "Dimensions"),
|
||||
"radius_dim_ignores_the_leader_length": (radius_dim_ignores_the_leader_length, "Dimensions"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
coverage = [item["summary"]["coverage_percent"] for item in reports]
|
||||
passed = [item["summary"]["passed"] for item in reports]
|
||||
attempted = [item["summary"]["attempted"] for item in reports]
|
||||
# Which task set each server actually ran. Not decoration: since v1.5.0
|
||||
# the reference adapter runs the 15-task v3 matrix while the pinned
|
||||
# competitor reports are v2 runs from v1.4, so the bars do not share a
|
||||
# denominator and a chart that implies they do is the lie this lane
|
||||
# exists to avoid.
|
||||
matrices = [item.get("matrix", "v2") for item in reports]
|
||||
colors = ["#58a6ff" if label == "autocad-mcp-pro" else "#484f58" for label in labels]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 4.8), constrained_layout=True)
|
||||
@@ -60,7 +66,9 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
|
||||
ax.set_xlim(0, 100)
|
||||
ax.set_xlabel(
|
||||
"Weighted live-run score / 100 (fixed 10-task matrix)", color="#e6edf3", labelpad=10
|
||||
"Weighted live-run score / 100, within each server's own task matrix",
|
||||
color="#e6edf3",
|
||||
labelpad=10,
|
||||
)
|
||||
ax.set_title(
|
||||
"Live-run benchmark - headless ezdxf lane",
|
||||
@@ -73,17 +81,20 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
ax.text(
|
||||
0,
|
||||
1.02,
|
||||
"Same 10 tasks, same harness, pinned competitor commits; artifacts verified by re-opening the DXF",
|
||||
"Same harness, pinned competitor commits; artifacts verified by re-opening the DXF. "
|
||||
"Matrices differ - read the per-bar label.",
|
||||
transform=ax.transAxes,
|
||||
color="#8b949e",
|
||||
fontsize=10,
|
||||
)
|
||||
|
||||
for bar, score, cov, ok, total in zip(bars, scores, coverage, passed, attempted, strict=True):
|
||||
for bar, score, cov, ok, total, matrix in zip(
|
||||
bars, scores, coverage, passed, attempted, matrices, strict=True
|
||||
):
|
||||
ax.text(
|
||||
min(score + 1.2, 82),
|
||||
min(score + 1.2, 76),
|
||||
bar.get_y() + bar.get_height() / 2,
|
||||
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage)",
|
||||
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage, matrix {matrix})",
|
||||
va="center",
|
||||
color="#e6edf3",
|
||||
fontweight="bold",
|
||||
@@ -92,7 +103,9 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
|
||||
ax.text(
|
||||
0,
|
||||
-0.22,
|
||||
"Unsupported tasks score 0 in the fixed matrix; per-task statuses and reasons are in benchmarks/results/published/.",
|
||||
"Unsupported tasks score 0 within a server's own matrix. The five v3 tasks were "
|
||||
"added after the competitor runs and were never put to them; per-task statuses are "
|
||||
"in benchmarks/results/published/.",
|
||||
transform=ax.transAxes,
|
||||
color="#8b949e",
|
||||
fontsize=9,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.4.0",
|
||||
"total": 24,
|
||||
"total": 26,
|
||||
"old_pass": 21,
|
||||
"new_pass": 24,
|
||||
"old_pct": 87.5,
|
||||
"new_pass": 26,
|
||||
"old_pct": 80.8,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 3,
|
||||
"fixed": 5,
|
||||
"regressed": 0
|
||||
},
|
||||
"rows": [
|
||||
@@ -153,6 +153,18 @@
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "diameter_dim_measures_the_diameter",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "radius_dim_ignores_the_leader_length",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.4.0",
|
||||
"total": 26,
|
||||
"old_pass": 21,
|
||||
"new_pass": 26,
|
||||
"old_pct": 80.8,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 5,
|
||||
"regressed": 0
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"check": "core_line_length",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_circle_radius",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_layer_create",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_polyline_closed",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_linear_dim",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_save_dxf_roundtrip",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_aligned_no_error",
|
||||
"category": "Dimensions",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_angular_no_error",
|
||||
"category": "Dimensions",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "array_polar_360_distinct",
|
||||
"category": "Modify",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "point_intersection_line_line",
|
||||
"category": "Geometry",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "point_tangent_external",
|
||||
"category": "Geometry",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "arc_has_length",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "arc_select_by_length",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "ezdxf_bounding_box",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "mtext_rotation_roundtrip",
|
||||
"category": "Entities",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "screenshot_png",
|
||||
"category": "Render",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_overlap_critique_fires",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "iso13567_dim_layer",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "construction_left_iso_caught",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "gear_no_self_overlap",
|
||||
"category": "Engineering",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "center_linetype_applied",
|
||||
"category": "Entities",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "hatch_area_subtracts_its_island",
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "boundary_area_agrees_with_measure",
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "two_vertex_circle_has_area",
|
||||
"category": "Measurement",
|
||||
"old": "miss",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "diameter_dim_measures_the_diameter",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "radius_dim_ignores_the_leader_length",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"summary": {
|
||||
"baseline_ref": "v1.5.0",
|
||||
"total": 26,
|
||||
"old_pass": 24,
|
||||
"new_pass": 26,
|
||||
"old_pct": 92.3,
|
||||
"new_pct": 100.0,
|
||||
"fixed": 2,
|
||||
"regressed": 0
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"check": "core_line_length",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_circle_radius",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_layer_create",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_polyline_closed",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_linear_dim",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "core_save_dxf_roundtrip",
|
||||
"category": "Core",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_aligned_no_error",
|
||||
"category": "Dimensions",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_angular_no_error",
|
||||
"category": "Dimensions",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "array_polar_360_distinct",
|
||||
"category": "Modify",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "point_intersection_line_line",
|
||||
"category": "Geometry",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "point_tangent_external",
|
||||
"category": "Geometry",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "arc_has_length",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "arc_select_by_length",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "ezdxf_bounding_box",
|
||||
"category": "Query",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "mtext_rotation_roundtrip",
|
||||
"category": "Entities",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "screenshot_png",
|
||||
"category": "Render",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "dim_overlap_critique_fires",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "iso13567_dim_layer",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "construction_left_iso_caught",
|
||||
"category": "Quality gate",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "gear_no_self_overlap",
|
||||
"category": "Engineering",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "center_linetype_applied",
|
||||
"category": "Entities",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "hatch_area_subtracts_its_island",
|
||||
"category": "Measurement",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "boundary_area_agrees_with_measure",
|
||||
"category": "Measurement",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "two_vertex_circle_has_area",
|
||||
"category": "Measurement",
|
||||
"old": "pass",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "diameter_dim_measures_the_diameter",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
},
|
||||
{
|
||||
"check": "radius_dim_ignores_the_leader_length",
|
||||
"category": "Dimensions",
|
||||
"old": "fail",
|
||||
"new": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,22 +14,22 @@
|
||||
"entity_count": 2000
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 6343.1,
|
||||
"wall_ms": 315.3,
|
||||
"metric_value": 5715.8,
|
||||
"wall_ms": 349.9,
|
||||
"workload_id": "create_lines_2k"
|
||||
},
|
||||
{
|
||||
"description": "Create 10,000 lines, export DXF, reopen and verify",
|
||||
"detail": {
|
||||
"build_ms": 1574.1,
|
||||
"build_ms": 1633.1,
|
||||
"entity_count": 10000,
|
||||
"export_ms": 261.8,
|
||||
"export_ms": 273.0,
|
||||
"file_bytes": 1369663,
|
||||
"reopen_ms": 320.2
|
||||
"reopen_ms": 272.1
|
||||
},
|
||||
"metric": "entities_per_second",
|
||||
"metric_value": 4637.9,
|
||||
"wall_ms": 2156.1,
|
||||
"metric_value": 4590.8,
|
||||
"wall_ms": 2178.3,
|
||||
"workload_id": "roundtrip_10k"
|
||||
},
|
||||
{
|
||||
@@ -39,7 +39,7 @@
|
||||
},
|
||||
"metric": "entities_matched",
|
||||
"metric_value": 2500.0,
|
||||
"wall_ms": 890.8,
|
||||
"wall_ms": 2466.9,
|
||||
"workload_id": "region_query_10k"
|
||||
},
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"metric": "critique_issues",
|
||||
"metric_value": 0.0,
|
||||
"wall_ms": 176.5,
|
||||
"wall_ms": 255.7,
|
||||
"workload_id": "premium_pass"
|
||||
}
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 82 KiB |
+313
-308
@@ -39,8 +39,8 @@ z
|
||||
<g id="matplotlib.axis_1">
|
||||
<g id="xtick_1">
|
||||
<g id="line2d_1">
|
||||
<path d="M 455.882104 273.200579
|
||||
L 455.882104 44.677427
|
||||
<path d="M 424.840312 273.200579
|
||||
L 424.840312 44.677427
|
||||
" clip-path="url(#p16cc1d88db)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_2">
|
||||
@@ -50,12 +50,12 @@ L 0 3.5
|
||||
" style="stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</defs>
|
||||
<g>
|
||||
<use xlink:href="#m24a80d22c9" x="455.882104" y="273.200579" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m24a80d22c9" x="424.840312" y="273.200579" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_1">
|
||||
<!-- $\mathdefault{10^{3}}$ -->
|
||||
<g style="fill: #8b949e" transform="translate(447.082104 289.600579) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(416.040312 289.600579) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-14" d="M 794 531
|
||||
L 1825 531
|
||||
@@ -133,18 +133,18 @@ z
|
||||
</g>
|
||||
<g id="xtick_2">
|
||||
<g id="line2d_3">
|
||||
<path d="M 693.819287 273.200579
|
||||
L 693.819287 44.677427
|
||||
<path d="M 676.0002 273.200579
|
||||
L 676.0002 44.677427
|
||||
" clip-path="url(#p16cc1d88db)" style="fill: none; stroke: #30363d; stroke-opacity: 0.8; stroke-width: 0.8; stroke-linecap: square"/>
|
||||
</g>
|
||||
<g id="line2d_4">
|
||||
<g>
|
||||
<use xlink:href="#m24a80d22c9" x="693.819287" y="273.200579" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
<use xlink:href="#m24a80d22c9" x="676.0002" y="273.200579" style="fill: #8b949e; stroke: #8b949e; stroke-width: 0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_2">
|
||||
<!-- $\mathdefault{10^{4}}$ -->
|
||||
<g style="fill: #8b949e" transform="translate(685.019287 289.500579) scale(0.1 -0.1)">
|
||||
<g style="fill: #8b949e" transform="translate(667.2002 289.500579) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-17" d="M 2419 4116
|
||||
L 825 1625
|
||||
@@ -180,112 +180,105 @@ L 0 2
|
||||
" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</defs>
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="289.57115" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="293.514145" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_4">
|
||||
<g id="line2d_6">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="331.469808" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="324.893745" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_5">
|
||||
<g id="line2d_7">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="361.197379" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="349.233652" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_6">
|
||||
<g id="line2d_8">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="384.255874" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="369.120805" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_7">
|
||||
<g id="line2d_9">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="403.096037" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="385.935154" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_8">
|
||||
<g id="line2d_10">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="419.025168" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="400.500404" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_9">
|
||||
<g id="line2d_11">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="432.823608" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="413.347866" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_10">
|
||||
<g id="line2d_12">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="444.994695" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="500.446972" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_11">
|
||||
<g id="line2d_13">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="527.508333" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="544.674033" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_12">
|
||||
<g id="line2d_14">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="569.406991" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="576.053632" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_13">
|
||||
<g id="line2d_15">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="599.134562" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="600.39354" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_14">
|
||||
<g id="line2d_16">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="622.193058" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="620.280693" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_15">
|
||||
<g id="line2d_17">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="641.033221" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="637.095041" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_16">
|
||||
<g id="line2d_18">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="656.962351" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="651.660292" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_17">
|
||||
<g id="line2d_19">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="670.760792" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="xtick_18">
|
||||
<g id="line2d_20">
|
||||
<g>
|
||||
<use xlink:href="#m1face5d2a1" x="682.931879" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
<use xlink:href="#m1face5d2a1" x="664.507754" y="273.200579" style="stroke: #000000; stroke-width: 0.6"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
@@ -708,7 +701,7 @@ z
|
||||
</g>
|
||||
<g id="matplotlib.axis_2">
|
||||
<g id="ytick_1">
|
||||
<g id="line2d_21"/>
|
||||
<g id="line2d_20"/>
|
||||
<g id="text_4">
|
||||
<!-- Premium pass: layers + part + dims + full critique -->
|
||||
<g style="fill: #e6edf3" transform="translate(3.00024 249.783608) scale(0.1 -0.1)">
|
||||
@@ -955,7 +948,7 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_2">
|
||||
<g id="line2d_22"/>
|
||||
<g id="line2d_21"/>
|
||||
<g id="text_5">
|
||||
<!-- Create 2,000 lines (individual calls) -->
|
||||
<g style="fill: #e6edf3" transform="translate(79.172115 191.753351) scale(0.1 -0.1)">
|
||||
@@ -1066,81 +1059,10 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_3">
|
||||
<g id="line2d_23"/>
|
||||
<g id="line2d_22"/>
|
||||
<g id="text_6">
|
||||
<!-- Region query over 10,000 entities -->
|
||||
<g style="fill: #e6edf3" transform="translate(85.364303 133.723094) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-35" d="M 2841 2188
|
||||
Q 3044 2119 3236 1894
|
||||
Q 3428 1669 3622 1275
|
||||
L 4263 0
|
||||
L 3584 0
|
||||
L 2988 1197
|
||||
Q 2756 1666 2539 1819
|
||||
Q 2322 1972 1947 1972
|
||||
L 1259 1972
|
||||
L 1259 0
|
||||
L 628 0
|
||||
L 628 4666
|
||||
L 2053 4666
|
||||
Q 2853 4666 3247 4331
|
||||
Q 3641 3997 3641 3322
|
||||
Q 3641 2881 3436 2590
|
||||
Q 3231 2300 2841 2188
|
||||
z
|
||||
M 1259 4147
|
||||
L 1259 2491
|
||||
L 2053 2491
|
||||
Q 2509 2491 2742 2702
|
||||
Q 2975 2913 2975 3322
|
||||
Q 2975 3731 2742 3939
|
||||
Q 2509 4147 2053 4147
|
||||
L 1259 4147
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-35"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(65 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4a" transform="translate(126.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(190.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(217.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(278.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(342.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-54" transform="translate(374.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(437.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(501 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(562.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-5c" transform="translate(603.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(662.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(694.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(755.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(814.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(876.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(917.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-14" transform="translate(949.40625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1013.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-f" transform="translate(1076.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1108.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1172.0625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1235.6875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(1299.3125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(1331.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(1392.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(1456 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(1495.203125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(1522.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(1562.1875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(1589.96875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(1651.5 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_4">
|
||||
<g id="line2d_24"/>
|
||||
<g id="text_7">
|
||||
<!-- 10,000 lines: build + DXF export + reopen -->
|
||||
<g style="fill: #e6edf3" transform="translate(42.81274 75.692837) scale(0.1 -0.1)">
|
||||
<g style="fill: #e6edf3" transform="translate(42.81274 133.723094) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-27" d="M 1259 4147
|
||||
L 1259 519
|
||||
@@ -1249,36 +1171,107 @@ z
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="ytick_4">
|
||||
<g id="line2d_23"/>
|
||||
<g id="text_7">
|
||||
<!-- Region query over 10,000 entities -->
|
||||
<g style="fill: #e6edf3" transform="translate(85.364303 75.692837) scale(0.1 -0.1)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-35" d="M 2841 2188
|
||||
Q 3044 2119 3236 1894
|
||||
Q 3428 1669 3622 1275
|
||||
L 4263 0
|
||||
L 3584 0
|
||||
L 2988 1197
|
||||
Q 2756 1666 2539 1819
|
||||
Q 2322 1972 1947 1972
|
||||
L 1259 1972
|
||||
L 1259 0
|
||||
L 628 0
|
||||
L 628 4666
|
||||
L 2053 4666
|
||||
Q 2853 4666 3247 4331
|
||||
Q 3641 3997 3641 3322
|
||||
Q 3641 2881 3436 2590
|
||||
Q 3231 2300 2841 2188
|
||||
z
|
||||
M 1259 4147
|
||||
L 1259 2491
|
||||
L 2053 2491
|
||||
Q 2509 2491 2742 2702
|
||||
Q 2975 2913 2975 3322
|
||||
Q 2975 3731 2742 3939
|
||||
Q 2509 4147 2053 4147
|
||||
L 1259 4147
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-35"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(65 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4a" transform="translate(126.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(190.015625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(217.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(278.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(342.359375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-54" transform="translate(374.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-58" transform="translate(437.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(501 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(562.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-5c" transform="translate(603.640625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(662.828125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-52" transform="translate(694.609375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-59" transform="translate(755.796875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(814.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-55" transform="translate(876.515625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(917.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-14" transform="translate(949.40625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1013.03125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-f" transform="translate(1076.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1108.4375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1172.0625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-13" transform="translate(1235.6875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-3" transform="translate(1299.3125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(1331.09375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-51" transform="translate(1392.625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(1456 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(1495.203125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-57" transform="translate(1522.984375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-4c" transform="translate(1562.1875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-48" transform="translate(1589.96875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-56" transform="translate(1651.5 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="patch_3">
|
||||
<path d="M -238195.113004 262.813163
|
||||
L 276.654662 262.813163
|
||||
L 276.654662 229.155614
|
||||
L -238195.113004 229.155614
|
||||
<path d="M -251488.526977 262.813163
|
||||
L 276.086029 262.813163
|
||||
L 276.086029 229.155614
|
||||
L -251488.526977 229.155614
|
||||
z
|
||||
" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/>
|
||||
</g>
|
||||
<g id="patch_4">
|
||||
<path d="M -238195.113004 204.782906
|
||||
L 336.609897 204.782906
|
||||
L 336.609897 171.125357
|
||||
L -238195.113004 171.125357
|
||||
<path d="M -251488.526977 204.782906
|
||||
L 310.297324 204.782906
|
||||
L 310.297324 171.125357
|
||||
L -251488.526977 171.125357
|
||||
z
|
||||
" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/>
|
||||
</g>
|
||||
<g id="patch_5">
|
||||
<path d="M -238195.113004 146.75265
|
||||
L 443.932948 146.75265
|
||||
L 443.932948 113.0951
|
||||
L -238195.113004 113.0951
|
||||
<path d="M -251488.526977 146.75265
|
||||
L 509.761914 146.75265
|
||||
L 509.761914 113.0951
|
||||
L -251488.526977 113.0951
|
||||
z
|
||||
" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/>
|
||||
</g>
|
||||
<g id="patch_6">
|
||||
<path d="M -238195.113004 88.722393
|
||||
L 535.274342 88.722393
|
||||
L 535.274342 55.064843
|
||||
L -238195.113004 55.064843
|
||||
<path d="M -251488.526977 88.722393
|
||||
L 523.33305 88.722393
|
||||
L 523.33305 55.064843
|
||||
L -251488.526977 55.064843
|
||||
z
|
||||
" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/>
|
||||
</g>
|
||||
@@ -1554,8 +1547,8 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_9">
|
||||
<!-- 0.18 s · 0 issues -->
|
||||
<g style="fill: #e6edf3" transform="translate(288.365459 248.322631) scale(0.09 -0.09)">
|
||||
<!-- 0.26 s · 0 issues -->
|
||||
<g style="fill: #e6edf3" transform="translate(288.447622 248.322631) scale(0.09 -0.09)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-Bold-13" d="M 2944 2338
|
||||
Q 2944 3213 2780 3570
|
||||
@@ -1585,57 +1578,56 @@ L 653 0
|
||||
L 653 1209
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-14" d="M 750 831
|
||||
L 1813 831
|
||||
L 1813 3847
|
||||
L 722 3622
|
||||
L 722 4441
|
||||
L 1806 4666
|
||||
L 2950 4666
|
||||
L 2950 831
|
||||
L 4013 831
|
||||
L 4013 0
|
||||
L 750 0
|
||||
L 750 831
|
||||
<path id="DejaVuSans-Bold-15" d="M 1844 884
|
||||
L 3897 884
|
||||
L 3897 0
|
||||
L 506 0
|
||||
L 506 884
|
||||
L 2209 2388
|
||||
Q 2438 2594 2547 2791
|
||||
Q 2656 2988 2656 3200
|
||||
Q 2656 3528 2436 3728
|
||||
Q 2216 3928 1850 3928
|
||||
Q 1569 3928 1234 3808
|
||||
Q 900 3688 519 3450
|
||||
L 519 4475
|
||||
Q 925 4609 1322 4679
|
||||
Q 1719 4750 2100 4750
|
||||
Q 2938 4750 3402 4381
|
||||
Q 3866 4013 3866 3353
|
||||
Q 3866 2972 3669 2642
|
||||
Q 3472 2313 2841 1759
|
||||
L 1844 884
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-1b" d="M 2228 2088
|
||||
Q 1891 2088 1709 1903
|
||||
Q 1528 1719 1528 1375
|
||||
Q 1528 1031 1709 848
|
||||
Q 1891 666 2228 666
|
||||
Q 2563 666 2741 848
|
||||
Q 2919 1031 2919 1375
|
||||
Q 2919 1722 2741 1905
|
||||
Q 2563 2088 2228 2088
|
||||
<path id="DejaVuSans-Bold-19" d="M 2316 2303
|
||||
Q 2000 2303 1842 2098
|
||||
Q 1684 1894 1684 1484
|
||||
Q 1684 1075 1842 870
|
||||
Q 2000 666 2316 666
|
||||
Q 2634 666 2792 870
|
||||
Q 2950 1075 2950 1484
|
||||
Q 2950 1894 2792 2098
|
||||
Q 2634 2303 2316 2303
|
||||
z
|
||||
M 1350 2484
|
||||
Q 925 2613 709 2878
|
||||
Q 494 3144 494 3541
|
||||
Q 494 4131 934 4440
|
||||
Q 1375 4750 2228 4750
|
||||
Q 3075 4750 3515 4442
|
||||
Q 3956 4134 3956 3541
|
||||
Q 3956 3144 3739 2878
|
||||
Q 3522 2613 3097 2484
|
||||
Q 3572 2353 3814 2058
|
||||
Q 4056 1763 4056 1313
|
||||
Q 4056 619 3595 264
|
||||
Q 3134 -91 2228 -91
|
||||
Q 1319 -91 855 264
|
||||
Q 391 619 391 1313
|
||||
Q 391 1763 633 2058
|
||||
Q 875 2353 1350 2484
|
||||
z
|
||||
M 1631 3419
|
||||
Q 1631 3141 1786 2991
|
||||
Q 1941 2841 2228 2841
|
||||
Q 2509 2841 2662 2991
|
||||
Q 2816 3141 2816 3419
|
||||
Q 2816 3697 2662 3845
|
||||
Q 2509 3994 2228 3994
|
||||
Q 1941 3994 1786 3844
|
||||
Q 1631 3694 1631 3419
|
||||
M 3803 4544
|
||||
L 3803 3681
|
||||
Q 3506 3822 3243 3889
|
||||
Q 2981 3956 2731 3956
|
||||
Q 2194 3956 1894 3657
|
||||
Q 1594 3359 1544 2772
|
||||
Q 1750 2925 1990 3001
|
||||
Q 2231 3078 2516 3078
|
||||
Q 3231 3078 3670 2659
|
||||
Q 4109 2241 4109 1563
|
||||
Q 4109 813 3618 361
|
||||
Q 3128 -91 2303 -91
|
||||
Q 1394 -91 895 523
|
||||
Q 397 1138 397 2266
|
||||
Q 397 3422 980 4083
|
||||
Q 1563 4744 2578 4744
|
||||
Q 2900 4744 3203 4694
|
||||
Q 3506 4644 3803 4544
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-3" transform="scale(0.015625)"/>
|
||||
@@ -1741,8 +1733,8 @@ z
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-Bold-13"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-11" transform="translate(69.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1b" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-15" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-19" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(246.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(281.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(341.046875 0)"/>
|
||||
@@ -1761,8 +1753,8 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_10">
|
||||
<!-- 0.32 s · 6,343 entities/s -->
|
||||
<g style="fill: #e6edf3" transform="translate(348.320695 190.292374) scale(0.09 -0.09)">
|
||||
<!-- 0.35 s · 5,716 entities/s -->
|
||||
<g style="fill: #e6edf3" transform="translate(322.658917 190.292374) scale(0.09 -0.09)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-Bold-16" d="M 2981 2516
|
||||
Q 3453 2394 3698 2092
|
||||
@@ -1796,56 +1788,29 @@ Q 3834 3144 3618 2883
|
||||
Q 3403 2622 2981 2516
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-15" d="M 1844 884
|
||||
L 3897 884
|
||||
L 3897 0
|
||||
L 506 0
|
||||
L 506 884
|
||||
L 2209 2388
|
||||
Q 2438 2594 2547 2791
|
||||
Q 2656 2988 2656 3200
|
||||
Q 2656 3528 2436 3728
|
||||
Q 2216 3928 1850 3928
|
||||
Q 1569 3928 1234 3808
|
||||
Q 900 3688 519 3450
|
||||
L 519 4475
|
||||
Q 925 4609 1322 4679
|
||||
Q 1719 4750 2100 4750
|
||||
Q 2938 4750 3402 4381
|
||||
Q 3866 4013 3866 3353
|
||||
Q 3866 2972 3669 2642
|
||||
Q 3472 2313 2841 1759
|
||||
L 1844 884
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-19" d="M 2316 2303
|
||||
Q 2000 2303 1842 2098
|
||||
Q 1684 1894 1684 1484
|
||||
Q 1684 1075 1842 870
|
||||
Q 2000 666 2316 666
|
||||
Q 2634 666 2792 870
|
||||
Q 2950 1075 2950 1484
|
||||
Q 2950 1894 2792 2098
|
||||
Q 2634 2303 2316 2303
|
||||
z
|
||||
M 3803 4544
|
||||
L 3803 3681
|
||||
Q 3506 3822 3243 3889
|
||||
Q 2981 3956 2731 3956
|
||||
Q 2194 3956 1894 3657
|
||||
Q 1594 3359 1544 2772
|
||||
Q 1750 2925 1990 3001
|
||||
Q 2231 3078 2516 3078
|
||||
Q 3231 3078 3670 2659
|
||||
Q 4109 2241 4109 1563
|
||||
Q 4109 813 3618 361
|
||||
Q 3128 -91 2303 -91
|
||||
Q 1394 -91 895 523
|
||||
Q 397 1138 397 2266
|
||||
Q 397 3422 980 4083
|
||||
Q 1563 4744 2578 4744
|
||||
Q 2900 4744 3203 4694
|
||||
Q 3506 4644 3803 4544
|
||||
<path id="DejaVuSans-Bold-18" d="M 678 4666
|
||||
L 3669 4666
|
||||
L 3669 3781
|
||||
L 1638 3781
|
||||
L 1638 3059
|
||||
Q 1775 3097 1914 3117
|
||||
Q 2053 3138 2203 3138
|
||||
Q 3056 3138 3531 2711
|
||||
Q 4006 2284 4006 1522
|
||||
Q 4006 766 3489 337
|
||||
Q 2972 -91 2053 -91
|
||||
Q 1656 -91 1267 -14
|
||||
Q 878 63 494 219
|
||||
L 494 1166
|
||||
Q 875 947 1217 837
|
||||
Q 1559 728 1863 728
|
||||
Q 2300 728 2551 942
|
||||
Q 2803 1156 2803 1522
|
||||
Q 2803 1891 2551 2103
|
||||
Q 2300 2316 1863 2316
|
||||
Q 1603 2316 1309 2248
|
||||
Q 1016 2181 678 2041
|
||||
L 678 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-f" d="M 653 1209
|
||||
@@ -1857,23 +1822,28 @@ L 653 256
|
||||
L 653 1209
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-17" d="M 2356 3675
|
||||
L 1038 1722
|
||||
L 2356 1722
|
||||
L 2356 3675
|
||||
<path id="DejaVuSans-Bold-1a" d="M 428 4666
|
||||
L 3944 4666
|
||||
L 3944 3988
|
||||
L 2125 0
|
||||
L 953 0
|
||||
L 2675 3781
|
||||
L 428 3781
|
||||
L 428 4666
|
||||
z
|
||||
M 2156 4666
|
||||
L 3494 4666
|
||||
L 3494 1722
|
||||
L 4159 1722
|
||||
L 4159 850
|
||||
L 3494 850
|
||||
L 3494 0
|
||||
L 2356 0
|
||||
L 2356 850
|
||||
L 288 850
|
||||
L 288 1881
|
||||
L 2156 4666
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-14" d="M 750 831
|
||||
L 1813 831
|
||||
L 1813 3847
|
||||
L 722 3622
|
||||
L 722 4441
|
||||
L 1806 4666
|
||||
L 2950 4666
|
||||
L 2950 831
|
||||
L 4013 831
|
||||
L 4013 0
|
||||
L 750 0
|
||||
L 750 831
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-51" d="M 4056 2131
|
||||
@@ -1930,7 +1900,7 @@ z
|
||||
<use xlink:href="#DejaVuSans-Bold-13"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-11" transform="translate(69.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-16" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-15" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-18" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(246.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(281.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(341.046875 0)"/>
|
||||
@@ -1938,11 +1908,11 @@ z
|
||||
<use xlink:href="#DejaVuSans-Bold-79" transform="translate(410.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(448.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(483.46875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-19" transform="translate(518.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-18" transform="translate(518.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-f" transform="translate(587.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-16" transform="translate(625.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-17" transform="translate(695.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-16" transform="translate(765 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1a" transform="translate(625.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(695.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-19" transform="translate(765 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(834.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-48" transform="translate(869.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-51" transform="translate(937.21875 0)"/>
|
||||
@@ -1957,9 +1927,67 @@ z
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_11">
|
||||
<!-- 0.89 s · 2,500 matched -->
|
||||
<g style="fill: #e6edf3" transform="translate(455.643746 132.262117) scale(0.09 -0.09)">
|
||||
<!-- 2.18 s · 4,591 entities/s -->
|
||||
<g style="fill: #e6edf3" transform="translate(522.123507 132.262117) scale(0.09 -0.09)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-Bold-1b" d="M 2228 2088
|
||||
Q 1891 2088 1709 1903
|
||||
Q 1528 1719 1528 1375
|
||||
Q 1528 1031 1709 848
|
||||
Q 1891 666 2228 666
|
||||
Q 2563 666 2741 848
|
||||
Q 2919 1031 2919 1375
|
||||
Q 2919 1722 2741 1905
|
||||
Q 2563 2088 2228 2088
|
||||
z
|
||||
M 1350 2484
|
||||
Q 925 2613 709 2878
|
||||
Q 494 3144 494 3541
|
||||
Q 494 4131 934 4440
|
||||
Q 1375 4750 2228 4750
|
||||
Q 3075 4750 3515 4442
|
||||
Q 3956 4134 3956 3541
|
||||
Q 3956 3144 3739 2878
|
||||
Q 3522 2613 3097 2484
|
||||
Q 3572 2353 3814 2058
|
||||
Q 4056 1763 4056 1313
|
||||
Q 4056 619 3595 264
|
||||
Q 3134 -91 2228 -91
|
||||
Q 1319 -91 855 264
|
||||
Q 391 619 391 1313
|
||||
Q 391 1763 633 2058
|
||||
Q 875 2353 1350 2484
|
||||
z
|
||||
M 1631 3419
|
||||
Q 1631 3141 1786 2991
|
||||
Q 1941 2841 2228 2841
|
||||
Q 2509 2841 2662 2991
|
||||
Q 2816 3141 2816 3419
|
||||
Q 2816 3697 2662 3845
|
||||
Q 2509 3994 2228 3994
|
||||
Q 1941 3994 1786 3844
|
||||
Q 1631 3694 1631 3419
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-17" d="M 2356 3675
|
||||
L 1038 1722
|
||||
L 2356 1722
|
||||
L 2356 3675
|
||||
z
|
||||
M 2156 4666
|
||||
L 3494 4666
|
||||
L 3494 1722
|
||||
L 4159 1722
|
||||
L 4159 850
|
||||
L 3494 850
|
||||
L 3494 0
|
||||
L 2356 0
|
||||
L 2356 850
|
||||
L 288 850
|
||||
L 288 1881
|
||||
L 2156 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-1c" d="M 641 103
|
||||
L 641 966
|
||||
Q 928 831 1190 764
|
||||
@@ -1990,31 +2018,40 @@ Q 1491 2759 1650 2554
|
||||
Q 1809 2350 2125 2350
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
<path id="DejaVuSans-Bold-18" d="M 678 4666
|
||||
L 3669 4666
|
||||
L 3669 3781
|
||||
L 1638 3781
|
||||
L 1638 3059
|
||||
Q 1775 3097 1914 3117
|
||||
Q 2053 3138 2203 3138
|
||||
Q 3056 3138 3531 2711
|
||||
Q 4006 2284 4006 1522
|
||||
Q 4006 766 3489 337
|
||||
Q 2972 -91 2053 -91
|
||||
Q 1656 -91 1267 -14
|
||||
Q 878 63 494 219
|
||||
L 494 1166
|
||||
Q 875 947 1217 837
|
||||
Q 1559 728 1863 728
|
||||
Q 2300 728 2551 942
|
||||
Q 2803 1156 2803 1522
|
||||
Q 2803 1891 2551 2103
|
||||
Q 2300 2316 1863 2316
|
||||
Q 1603 2316 1309 2248
|
||||
Q 1016 2181 678 2041
|
||||
L 678 4666
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-Bold-15"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-11" transform="translate(69.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1b" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(246.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(281.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(341.046875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(375.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-79" transform="translate(410.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(448.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(483.46875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-17" transform="translate(518.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-f" transform="translate(587.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-18" transform="translate(625.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1c" transform="translate(695.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(765 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(834.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-48" transform="translate(869.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-51" transform="translate(937.21875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-57" transform="translate(1008.40625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-4c" transform="translate(1056.203125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-57" transform="translate(1090.484375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-4c" transform="translate(1138.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-48" transform="translate(1172.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(1240.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-12" transform="translate(1299.90625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(1336.4375 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_12">
|
||||
<!-- 2.47 s · 2,500 matched -->
|
||||
<g style="fill: #e6edf3" transform="translate(535.694643 74.23186) scale(0.09 -0.09)">
|
||||
<defs>
|
||||
<path id="DejaVuSans-Bold-50" d="M 3781 2919
|
||||
Q 3994 3244 4286 3414
|
||||
Q 4578 3584 4928 3584
|
||||
@@ -2150,10 +2187,10 @@ Q 1825 722 2181 722
|
||||
z
|
||||
" transform="scale(0.015625)"/>
|
||||
</defs>
|
||||
<use xlink:href="#DejaVuSans-Bold-13"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-15"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-11" transform="translate(69.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1b" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1c" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-17" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1a" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(246.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(281.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(341.046875 0)"/>
|
||||
@@ -2176,38 +2213,6 @@ z
|
||||
<use xlink:href="#DejaVuSans-Bold-47" transform="translate(1287.171875 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_12">
|
||||
<!-- 2.16 s · 4,638 entities/s -->
|
||||
<g style="fill: #e6edf3" transform="translate(546.98514 74.23186) scale(0.09 -0.09)">
|
||||
<use xlink:href="#DejaVuSans-Bold-15"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-11" transform="translate(69.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-14" transform="translate(107.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-19" transform="translate(177.140625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(246.71875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(281.53125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(341.046875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(375.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-79" transform="translate(410.671875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(448.65625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(483.46875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-17" transform="translate(518.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-f" transform="translate(587.859375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-19" transform="translate(625.84375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-16" transform="translate(695.421875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-1b" transform="translate(765 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-3" transform="translate(834.578125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-48" transform="translate(869.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-51" transform="translate(937.21875 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-57" transform="translate(1008.40625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-4c" transform="translate(1056.203125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-57" transform="translate(1090.484375 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-4c" transform="translate(1138.28125 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-48" transform="translate(1172.5625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(1240.390625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-12" transform="translate(1299.90625 0)"/>
|
||||
<use xlink:href="#DejaVuSans-Bold-56" transform="translate(1336.4375 0)"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="text_13">
|
||||
<!-- Self-measurement only: competitor servers would pay extra stdio serialization cost in-process runs do not. Reproduce: python -m benchmarks.perf_suite -->
|
||||
<g style="fill: #8b949e" transform="translate(263.723678 328.046136) scale(0.09 -0.09)">
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "GENERATED FILE - do not edit by hand. Derived from the live FastMCP component registry; regenerate with `python scripts/generate_tool_inventory.py`.",
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.0",
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 154,
|
||||
"resources": 6,
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from backends.quarantine import DocumentQuarantineError, quarantine_refusal
|
||||
|
||||
from .layers import ENGINEERING_LAYERS
|
||||
from .plan_spec import (
|
||||
ALL_CRITIQUE_FOCUSES,
|
||||
@@ -45,6 +47,8 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
try:
|
||||
layers = await backend.layer_list()
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
return [Issue("warning", "iso128", f"layer_list failed: {exc}")]
|
||||
allowed = set(ISO_128_LINEWEIGHTS_MM)
|
||||
@@ -73,6 +77,87 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
|
||||
detail={"layer": lyr.name, "lineweight": lw},
|
||||
)
|
||||
)
|
||||
issues.extend(_check_dimension_legibility(backend))
|
||||
return issues
|
||||
|
||||
|
||||
#: ISO 3098 minimum lettering height for technical drawings, in mm.
|
||||
ISO_3098_MIN_TEXT_MM = 2.5
|
||||
|
||||
|
||||
def _check_dimension_legibility(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""Dimension text must be readable and use an ISO 129 decimal marker.
|
||||
|
||||
This lane was blind: `iso128` checked layer lineweights only, so a sheet
|
||||
whose dimensions rendered at ezdxf's bare 1.0 mm with a comma separator
|
||||
critiqued clean — and "zero issues" is the claim that the drawing is
|
||||
conformant. Reading the settings rather than the rendered block keeps it
|
||||
cheap and catches the case before a dimension is even drawn.
|
||||
|
||||
It reads the *effective* value — header first, then the stored dimstyle
|
||||
attribute, then ezdxf's renderer fallback — because that is the order the
|
||||
renderer resolves in since the DIM* header variables started reaching it.
|
||||
Grading the dimstyle alone made this check wrong in both directions: it
|
||||
would report a 1.0 mm defect on a sheet the header letters at 2.5, and clear
|
||||
a sheet the header letters at 1.0 because the style still said 2.5.
|
||||
|
||||
Headless only by construction: a live AutoCAD document carries the
|
||||
operator's own template, and second-guessing it from here would report
|
||||
their house standard as a defect.
|
||||
"""
|
||||
doc = getattr(backend, "_doc", None)
|
||||
if doc is None: # the live backend has no ezdxf document to read
|
||||
return []
|
||||
|
||||
# This is the one check that reads `_doc` directly instead of going through
|
||||
# `_async`, so it also misses `_async`'s quarantine gate — and it runs on
|
||||
# the event loop thread, which is exactly the unsynchronised access to a
|
||||
# document an abandoned worker may still be writing that the quarantine
|
||||
# exists to prevent. Refuse here rather than read.
|
||||
record = getattr(backend, "_quarantine", None)
|
||||
if record is not None:
|
||||
reap = getattr(backend, "_reap_quarantine", None)
|
||||
if reap is not None:
|
||||
reap()
|
||||
record = getattr(backend, "_quarantine", None)
|
||||
if record is not None:
|
||||
raise quarantine_refusal(record)
|
||||
|
||||
from backends.ezdxf_backend import effective_dimvar
|
||||
|
||||
try:
|
||||
height = float(effective_dimvar(doc, "dimtxt"))
|
||||
separator = int(effective_dimvar(doc, "dimdsep"))
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
issues: list[Issue] = []
|
||||
if height < ISO_3098_MIN_TEXT_MM:
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(
|
||||
f"Dimension text is {height:g} mm; ISO 3098 sets the minimum "
|
||||
f"lettering height at {ISO_3098_MIN_TEXT_MM} mm."
|
||||
),
|
||||
detail={"dimtxt": height, "minimum": ISO_3098_MIN_TEXT_MM},
|
||||
)
|
||||
)
|
||||
# DIMDSEP 0 is not "unset" — ezdxf's `get_decimal_separator` renders it as a
|
||||
# comma — so it is the violation, not an excuse to skip the check.
|
||||
marker = "," if separator == 0 else chr(separator)
|
||||
if marker != ".":
|
||||
issues.append(
|
||||
Issue(
|
||||
severity="warning",
|
||||
focus="iso128",
|
||||
message=(f"Dimension decimal marker is {marker!r}; ISO 129 uses a point."),
|
||||
detail={"dimdsep": separator},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -81,6 +166,8 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
|
||||
issues: list[Issue] = []
|
||||
try:
|
||||
layers = await backend.layer_list()
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
for lyr in layers:
|
||||
@@ -111,6 +198,8 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""
|
||||
try:
|
||||
ents = await backend.entity_list(limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
|
||||
@@ -139,6 +228,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
|
||||
tol = 0.5 # mm — generous; tighten via PlanSpec.notes later
|
||||
try:
|
||||
lines = await backend.entity_list(type_filter="LINE", limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
endpoints: list[tuple[str, tuple[float, float]]] = []
|
||||
@@ -182,6 +273,8 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
|
||||
"""Two LINEs with identical endpoints (in either direction) are duplicates."""
|
||||
try:
|
||||
lines = await backend.entity_list(type_filter="LINE", limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
seen: dict[tuple, str] = {}
|
||||
@@ -240,6 +333,8 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
|
||||
tol = 5.0 # mm
|
||||
try:
|
||||
ents = await backend.entity_list(limit=5000)
|
||||
except DocumentQuarantineError:
|
||||
raise
|
||||
except Exception:
|
||||
return []
|
||||
dim_points: list[tuple[str, tuple[float, float]]] = []
|
||||
|
||||
@@ -98,7 +98,17 @@ def is_self_intersecting(vertices: Sequence[tuple[float, float]]) -> bool | None
|
||||
Worth reporting because the shoelace *cancels* crossed lobes rather than
|
||||
failing: a bowtie made of two 2500-unit triangles measures 0.0, which is a
|
||||
confident wrong number of exactly the kind this release removes.
|
||||
|
||||
A closed loop may arrive either with or without a repeated closing vertex —
|
||||
`ezdxf.path.flattening()` emits one, raw polyline points do not — and both
|
||||
spellings must give the same answer. They did not: the adjacency guard
|
||||
below excludes neighbours *by index*, so with the duplicate present the
|
||||
first and last real edges were cross-tested even though they share a point,
|
||||
and every plain hatch boundary came back flagged.
|
||||
"""
|
||||
vertices = list(vertices)
|
||||
if len(vertices) > 1 and math.dist(vertices[0][:2], vertices[-1][:2]) < _EPS:
|
||||
vertices = vertices[:-1]
|
||||
count = len(vertices)
|
||||
if count < 4:
|
||||
return False
|
||||
|
||||
+12
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -74,7 +74,13 @@ Changelog = "https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md"
|
||||
# renders nothing. Install `[pdf]` (or `[full]`) for rendering on any platform:
|
||||
# Pillow comes in transitively as a matplotlib dependency.
|
||||
com = [
|
||||
"pywin32>=306",
|
||||
# pywin32 publishes Windows wheels and no sdist, so without this marker
|
||||
# the extra cannot be resolved at all on Linux or macOS -- pip dies at
|
||||
# "no matching distribution" before installing anything. The marker
|
||||
# makes `pip install autocad-mcp-pro[com]` a no-op there instead of a
|
||||
# hard failure, which is the honest outcome: the COM backend needs
|
||||
# Windows, and the server already falls back to ezdxf.
|
||||
'pywin32>=306; sys_platform == "win32"',
|
||||
# Win32 window capture for the live-AutoCAD screenshot path.
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
@@ -83,7 +89,10 @@ pdf = [
|
||||
"matplotlib>=3.8",
|
||||
]
|
||||
full = [
|
||||
"pywin32>=306",
|
||||
# Same marker as `com` above, and for the same reason: `[full]` is the
|
||||
# extra this project recommends to contributors, and it was
|
||||
# uninstallable on every non-Windows machine.
|
||||
'pywin32>=306; sys_platform == "win32"',
|
||||
"Pillow>=10.0",
|
||||
"matplotlib>=3.8",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Hold the numbers in the documents equal to the numbers in the tree.
|
||||
|
||||
Every figure this checks has been wrong at least once, and the expensive one was
|
||||
wrong in three files at the same time: `benchmarks/README.md` and `CLAUDE.md`
|
||||
described the A/B suite as 24 checks at 87.5% while the suite held 26 and the
|
||||
published report said 80.8%. Nothing caught it because each file was edited on
|
||||
its own and nobody re-read the other two.
|
||||
|
||||
The rule is the one the release snapshot already follows: a number a human
|
||||
types is a number that rots, so derive it and fail when the prose disagrees.
|
||||
|
||||
python scripts/check_doc_numbers.py # report and exit non-zero on drift
|
||||
python scripts/check_doc_numbers.py --list # show every derived value
|
||||
|
||||
`tests/test_release_consistency.py` runs this as a gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _json(relative: str) -> dict:
|
||||
return json.loads(_read(relative))
|
||||
|
||||
|
||||
def _collected_tests() -> int:
|
||||
"""How many tests pytest actually collects.
|
||||
|
||||
On the same README line as tools/resources/prompts, and the only number on
|
||||
it that nothing derived -- so it drifted by 95 while its neighbours stayed
|
||||
exact. Collection only; running the suite here would make the gate cost a
|
||||
minute.
|
||||
"""
|
||||
env = dict(os.environ, AUTOCAD_MCP_BACKEND="ezdxf")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=300,
|
||||
)
|
||||
match = re.search(r"(\d+) tests collected", result.stdout)
|
||||
if not match:
|
||||
raise SystemExit(
|
||||
"check_doc_numbers: could not read a collected-test count from pytest.\n"
|
||||
+ result.stdout[-2000:]
|
||||
)
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def derived() -> dict[str, object]:
|
||||
"""Every number a document is allowed to quote, computed from the tree."""
|
||||
sys.path.insert(0, str(ROOT))
|
||||
inventory = _json("docs/tool-inventory.json")["totals"]
|
||||
ab = _json("benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json")["summary"]
|
||||
perf = {
|
||||
w["workload_id"]: w["wall_ms"]
|
||||
for w in _json("benchmarks/results/published/perf-ezdxf.json")["workloads"]
|
||||
}
|
||||
|
||||
from benchmarks import correctness_suite
|
||||
from benchmarks.tasks_v3 import TASKS_V3
|
||||
|
||||
return {
|
||||
"collected_tests": _collected_tests(),
|
||||
"tools": inventory["tools"],
|
||||
"resources": inventory["resources"],
|
||||
"prompts": inventory["prompts"],
|
||||
"groups": inventory["groups"],
|
||||
"ab_total": ab["total"],
|
||||
"ab_old_pass": ab["old_pass"],
|
||||
"ab_old_pct": ab["old_pct"],
|
||||
"ab_fixed": ab["fixed"],
|
||||
"ab_regressed": ab["regressed"],
|
||||
"suite_checks": len(correctness_suite.CHECKS),
|
||||
"v3_tasks": len(TASKS_V3),
|
||||
"perf_ms": perf,
|
||||
}
|
||||
|
||||
|
||||
#: (relative path, human label, regex over the file, callable -> expected value)
|
||||
#:
|
||||
#: Each pattern captures the number as group 1. A pattern that matches nothing is
|
||||
#: itself a failure: the sentence it was written for has moved, and a silent
|
||||
#: no-op gate is worse than no gate.
|
||||
CHECKS: list[tuple[str, str, str, str]] = [
|
||||
("README.md", "release snapshot tools", r"release snapshot:\*\*\s*(\d+) tools", "tools"),
|
||||
(
|
||||
"README.md",
|
||||
"release snapshot resources",
|
||||
r"release snapshot:.*?(\d+) resources",
|
||||
"resources",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"release snapshot prompts",
|
||||
r"release snapshot:.*?(\d+) prompt templates",
|
||||
"prompts",
|
||||
),
|
||||
("README.md", "A/B suite size", r"(\d+) deterministic headless checks", "ab_total"),
|
||||
("README.md", "A/B baseline pass", r"v1\.5\.0 \*\(baseline\)\* \| (\d+) / \d+", "ab_old_pass"),
|
||||
("README.md", "A/B suite denominator", r"v1\.5\.0 \*\(baseline\)\* \| \d+ / (\d+)", "ab_total"),
|
||||
("benchmarks/README.md", "A/B suite size", r"(\d+) checks, ezdxf backend", "ab_total"),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B baseline pass",
|
||||
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| (\d+) / \d+",
|
||||
"ab_old_pass",
|
||||
),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B suite denominator",
|
||||
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / (\d+)",
|
||||
"ab_total",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"release snapshot collected tests",
|
||||
r"release snapshot:.*?(\d+) collected tests",
|
||||
"collected_tests",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"A/B baseline pass rate",
|
||||
r"v1\.5\.0 \*\(baseline\)\* \| \d+ / \d+ \| ([\d.]+) %",
|
||||
"ab_old_pct",
|
||||
),
|
||||
(
|
||||
"README.md",
|
||||
"A/B fixed count",
|
||||
r"v1\.5\.1\*\* \*\(this release\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
|
||||
"ab_fixed",
|
||||
),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B baseline pass rate",
|
||||
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / \d+ \| ([\d.]+) %",
|
||||
"ab_old_pct",
|
||||
),
|
||||
(
|
||||
"benchmarks/README.md",
|
||||
"A/B fixed count",
|
||||
r"\*\*v1\.5\.1\*\* \(this release\) \| \d+ / \d+ \| \d+ % \| (\d+)",
|
||||
"ab_fixed",
|
||||
),
|
||||
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
|
||||
("CLAUDE.md", "v3 task count", r"the current set \((\d+) tasks", "v3_tasks"),
|
||||
]
|
||||
|
||||
|
||||
def run(verbose: bool = False) -> int:
|
||||
values = derived()
|
||||
|
||||
if values["suite_checks"] != values["ab_total"]:
|
||||
print(
|
||||
f"DRIFT correctness_suite.py has {values['suite_checks']} checks but the published "
|
||||
f"A/B report was run over {values['ab_total']} -- regenerate the report:\n"
|
||||
f" python benchmarks/compare_versions.py v1.5.0 "
|
||||
f"--json benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json"
|
||||
)
|
||||
return 1
|
||||
|
||||
if verbose:
|
||||
for key, value in values.items():
|
||||
print(f" {key:20s} {value}")
|
||||
print()
|
||||
|
||||
failures = 0
|
||||
for relative, label, pattern, key in CHECKS:
|
||||
text = _read(relative)
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
expected = values[key]
|
||||
if match is None:
|
||||
print(
|
||||
f"DRIFT {relative}: the sentence carrying '{label}' no longer matches its pattern"
|
||||
)
|
||||
failures += 1
|
||||
continue
|
||||
found = type(expected)(match.group(1))
|
||||
if found != expected:
|
||||
print(f"DRIFT {relative}: {label} says {found}, the tree says {expected}")
|
||||
failures += 1
|
||||
elif verbose:
|
||||
print(f" ok {relative:24s} {label:24s} {found}")
|
||||
|
||||
if failures:
|
||||
print(f"\n{failures} document number(s) disagree with the tree.")
|
||||
return 1
|
||||
print(f"ok: {len(CHECKS)} document numbers match the tree")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--list", action="store_true", help="Print every derived value")
|
||||
return run(verbose=parser.parse_args().list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Render the drawing the README shows, using only the server's own tools.
|
||||
|
||||
A CAD README that shows nothing but bar charts is asking to be taken on faith.
|
||||
This produces the one asset that is not a claim: an actual sheet, built through
|
||||
the same backend methods the MCP tools call, in the order CLAUDE.md's premium
|
||||
workflow prescribes -- ISO layers, deterministic gear geometry, a keyed bore,
|
||||
ISO 129 dimensions, an ISO 7200 title block -- and rendered headlessly.
|
||||
|
||||
Reproduce:
|
||||
|
||||
AUTOCAD_MCP_BACKEND=ezdxf python scripts/render_readme_showcase.py
|
||||
|
||||
Nothing here is decoration: every line on the sheet comes from a tool the
|
||||
server exposes, so if a tool regresses the picture changes with it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-showcase.png"
|
||||
|
||||
#: A 24-tooth, module-6 spur gear. Sized to fill the A3 drawing area, because
|
||||
#: the render crops to the sheet border: geometry that fills the border gets
|
||||
#: the pixels, and involute flanks that do not resolve prove nothing.
|
||||
GEAR_MODULE = 6.0
|
||||
GEAR_TEETH = 24
|
||||
BORE_DIAMETER = 40.0
|
||||
KEYWAY_WIDTH = 12.0
|
||||
KEYWAY_DEPTH = 3.3
|
||||
FACE_WIDTH = 46.0
|
||||
GEAR_CENTER = (135.0, 172.0)
|
||||
SECTION_X = 300.0
|
||||
|
||||
#: No DIMSCALE here on purpose. `drawing_settings({"dimscale": ...})` sets the
|
||||
#: header variable and reports success, but ezdxf renders dimensions from the
|
||||
#: dimstyle table entry, so headlessly it changes nothing (measured). Setting it
|
||||
#: in this script would put a call in the README's own showcase that does not do
|
||||
#: what it looks like it does.
|
||||
|
||||
|
||||
async def build(backend) -> dict:
|
||||
from engineering.gear import draw_gear_section_aa, draw_spur_gear_front_view
|
||||
from engineering.titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
|
||||
|
||||
await backend.drawing_new()
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await backend.drawing_settings({"units": "mm", "linear_precision": 2})
|
||||
|
||||
gear = await draw_spur_gear_front_view(
|
||||
backend,
|
||||
module=GEAR_MODULE,
|
||||
teeth=GEAR_TEETH,
|
||||
center=GEAR_CENTER,
|
||||
bore_diameter=BORE_DIAMETER,
|
||||
keyway_width=KEYWAY_WIDTH,
|
||||
keyway_depth=KEYWAY_DEPTH,
|
||||
)
|
||||
|
||||
# `gear_metadata` is the geometry record, not the handle map the front view
|
||||
# returns -- the section needs radii, not handles.
|
||||
await draw_gear_section_aa(
|
||||
backend,
|
||||
gear_metadata=gear["metadata"],
|
||||
x_offset=SECTION_X,
|
||||
face_width=FACE_WIDTH,
|
||||
)
|
||||
|
||||
# ISO 129 dimensions, and a real ISO 286 fit on the bore -- "H7" is the
|
||||
# whole reason a drafter trusts a generated hole. `fit=` is resolved by the
|
||||
# tool layer, so the deviations are looked up here the same way it does
|
||||
# rather than typed in as text.
|
||||
from engineering.fits import fit_lookup
|
||||
|
||||
cx, cy = GEAR_CENTER
|
||||
pitch_radius = GEAR_MODULE * GEAR_TEETH / 2.0
|
||||
outer_radius = pitch_radius + GEAR_MODULE
|
||||
await backend.dimension_linear(
|
||||
cx - pitch_radius,
|
||||
cy,
|
||||
cx + pitch_radius,
|
||||
cy,
|
||||
cx,
|
||||
cy - outer_radius - 22.0,
|
||||
layer="DIM",
|
||||
)
|
||||
# dimension_diameter takes the two ENDS OF A DIAMETER, not the centre and a
|
||||
# point on the circle. Passing the latter halves the measured size, which is
|
||||
# how the first draft of this sheet ended up calling a 40 mm bore 20.
|
||||
bore_fit = fit_lookup("H7", BORE_DIAMETER)
|
||||
await backend.dimension_diameter(
|
||||
cx - BORE_DIAMETER / 2.0,
|
||||
cy,
|
||||
cx + BORE_DIAMETER / 2.0,
|
||||
cy,
|
||||
leader_length=34.0,
|
||||
layer="DIM",
|
||||
tol_upper=bore_fit.upper_mm,
|
||||
tol_lower=-bore_fit.lower_mm,
|
||||
tol_mode="deviation",
|
||||
text_override=f"<> {bore_fit.code}",
|
||||
)
|
||||
|
||||
await apply_iso_a3_titleblock(
|
||||
backend,
|
||||
metadata=TitleBlockMetadata(
|
||||
title=f"SPUR GEAR m{GEAR_MODULE:g} z{GEAR_TEETH}",
|
||||
drawing_no="AMC-1500-001",
|
||||
part_no="1500-001",
|
||||
material="C45E",
|
||||
scale="1:1",
|
||||
drawn_by="autocad-mcp-pro",
|
||||
checked_by="drawing_critique",
|
||||
date="2026-08-06",
|
||||
revision="A",
|
||||
),
|
||||
origin=(0.0, 0.0),
|
||||
)
|
||||
|
||||
issues = await backend.drawing_critique(focus=None)
|
||||
info = await backend.drawing_info()
|
||||
return {"issues": len(issues), "entities": info.entity_count, "gear": gear}
|
||||
|
||||
|
||||
async def main(output: Path) -> int:
|
||||
os.environ.setdefault("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
try:
|
||||
summary = await build(backend)
|
||||
png = await backend.view_screenshot(overlay_handles=False)
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
if not png:
|
||||
print("render produced no image", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(png)
|
||||
print(
|
||||
f"wrote {output} ({len(png)} bytes) - "
|
||||
f"{summary['entities']} entities, {summary['issues']} critique issues"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main(build_parser().parse_args().output)))
|
||||
+82
-1
@@ -151,7 +151,15 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
|
||||
# `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:
|
||||
# rather than a contradiction.
|
||||
#
|
||||
# That "the CHANNEL" was singular was wrong, and it took an audit to notice.
|
||||
# `_ensure_linetype_loaded` and `ComBackend.linetype_load` reach SendCommand
|
||||
# too, carrying a caller's linetype name and file path, and neither passed
|
||||
# through anything here — so the denylist below was guarding one door of two.
|
||||
# `sanitize_symbol_name` / `sanitize_macro_argument` at the end of this module
|
||||
# close the other. Before adding a third SendCommand site, guard it there and
|
||||
# amend this paragraph rather than this one:
|
||||
#
|
||||
# * `block_insert(name, x, y, ...)` takes a block ALREADY in the drawing plus
|
||||
# typed geometry. The INSERT *command* takes a file path and pulls in an
|
||||
@@ -349,3 +357,76 @@ def sanitize_lisp(expression: str) -> str:
|
||||
)
|
||||
|
||||
return expression
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Symbol-table names interpolated into a SendCommand macro
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and
|
||||
# `sanitize_lisp` were the only routes into SendCommand. They were not.
|
||||
# `_ensure_linetype_loaded` builds `_-LINETYPE _LOAD {name} {file}` from a
|
||||
# caller's `linetype=` argument, and `ComBackend.linetype_load` adds a
|
||||
# caller-supplied path. SendCommand takes a whole macro and treats a newline
|
||||
# and `;` as segment separators, so either string could append commands that
|
||||
# the 36-verb denylist never sees.
|
||||
#
|
||||
# The guard is the DXF symbol-name rule rather than a second blocklist. AutoCAD
|
||||
# already forbids these characters in a table name, so nothing this rejects was
|
||||
# ever a loadable linetype, and the injection characters fall out of the rule
|
||||
# instead of having to be enumerated and kept up to date.
|
||||
|
||||
#: Characters DXF forbids in a symbol-table name, plus the whitespace that ends
|
||||
#: a SendCommand segment.
|
||||
_FORBIDDEN_SYMBOL_CHARS = set('<>/\\":;?*|,=`') | set("\n\r\t")
|
||||
|
||||
#: Long enough for any real linetype, short enough not to be a payload.
|
||||
_MAX_SYMBOL_NAME = 255
|
||||
|
||||
|
||||
def sanitize_symbol_name(name: str, *, kind: str = "symbol") -> str:
|
||||
"""Return `name` if it is a legal DXF symbol-table name, else refuse.
|
||||
|
||||
Used at every point where a caller-controlled name is interpolated into an
|
||||
AutoCAD command macro. The refusal deliberately does not echo the rejected
|
||||
string back: an error message that a client logs or renders is a channel
|
||||
too, and a payload that reaches a log is a payload that reached something.
|
||||
"""
|
||||
candidate = (name or "").strip()
|
||||
if not candidate:
|
||||
raise ToolError(f"{kind} name rejected: empty.")
|
||||
if len(candidate) > _MAX_SYMBOL_NAME:
|
||||
raise ToolError(f"{kind} name rejected: longer than {_MAX_SYMBOL_NAME} characters.")
|
||||
offenders = sorted({c for c in candidate if c in _FORBIDDEN_SYMBOL_CHARS or ord(c) < 32})
|
||||
if offenders:
|
||||
shown = ", ".join(repr(c) for c in offenders)
|
||||
raise ToolError(
|
||||
f"{kind} name rejected: DXF symbol-table names cannot contain {shown}. "
|
||||
"This name would also end the AutoCAD command macro it is placed in."
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
#: What ends a segment inside a SendCommand macro. A path legitimately contains
|
||||
#: ':' and '\', so a file argument cannot be held to the symbol-name rule -- it
|
||||
#: gets this narrower one plus `validate_path`.
|
||||
_MACRO_SEPARATORS = set("\n\r;") | {chr(c) for c in range(32)}
|
||||
|
||||
|
||||
def sanitize_macro_argument(value: str, *, kind: str = "argument") -> str:
|
||||
"""Return `value` if it cannot end the AutoCAD command macro it sits in.
|
||||
|
||||
For arguments that are legitimately allowed the characters DXF forbids in a
|
||||
symbol name -- a file path, mostly. Narrower than `sanitize_symbol_name` on
|
||||
purpose: the goal is only that the string cannot start a second command.
|
||||
"""
|
||||
candidate = (value or "").strip()
|
||||
if not candidate:
|
||||
raise ToolError(f"{kind} rejected: empty.")
|
||||
if any(c in _MACRO_SEPARATORS for c in candidate):
|
||||
raise ToolError(
|
||||
f"{kind} rejected: contains a newline, carriage return, semicolon or "
|
||||
"control character, which would end the AutoCAD command macro it is "
|
||||
"placed in and start another."
|
||||
)
|
||||
return candidate
|
||||
|
||||
+3
-3
@@ -7,12 +7,12 @@
|
||||
"url": "https://github.com/U-C4N/Autocad-MCP",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "autocad-mcp-pro",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"name": "ALLOWED_PATHS",
|
||||
"description": "Comma-separated absolute paths the server may read and write. Empty blocks file access outside temp.",
|
||||
"description": "Comma-separated absolute paths the server may read and write. Empty means no allowlist is enforced at all - traversal and system-directory checks still run, but nothing scopes the filesystem. Set this.",
|
||||
"isRequired": false,
|
||||
"format": "string",
|
||||
"isSecret": false
|
||||
|
||||
@@ -198,6 +198,11 @@ class CapabilityRefusalMiddleware(Middleware):
|
||||
only channel that carries both a machine-readable payload *and* the error
|
||||
flag; rebasing the exception on ``FastMCPError`` carries neither, which is
|
||||
why no tool function changes here.
|
||||
|
||||
R32 added a sibling kind. A quarantined document is *not* a capability
|
||||
boundary — the engine can draw a circle perfectly well, the document it would
|
||||
draw into is untrusted — so it gets ``kind: "quarantined"`` and carries the
|
||||
abandoned-call record instead of a capability key.
|
||||
"""
|
||||
|
||||
async def on_call_tool(self, context: MiddlewareContext, call_next):
|
||||
@@ -205,19 +210,25 @@ class CapabilityRefusalMiddleware(Middleware):
|
||||
return await call_next(context)
|
||||
except Exception as exc:
|
||||
capability = _batch_capability_of(exc)
|
||||
if capability is None:
|
||||
quarantine = None if capability else _batch_quarantine_of(exc)
|
||||
if capability is None and quarantine is None:
|
||||
raise # not ours — leave it byte-for-byte as it was
|
||||
message = _refusal_message(exc)
|
||||
return ToolResult(
|
||||
content=message, # prose-only clients still get a full sentence
|
||||
structured_content={
|
||||
payload = {
|
||||
"ok": False,
|
||||
"kind": "unsupported",
|
||||
"capability": capability,
|
||||
"error": message,
|
||||
"tool": context.message.name,
|
||||
"backend": _backend_name_or_none(context),
|
||||
},
|
||||
}
|
||||
if capability is not None:
|
||||
payload["kind"] = "unsupported"
|
||||
payload["capability"] = capability
|
||||
else:
|
||||
payload["kind"] = "quarantined"
|
||||
payload["quarantine"] = quarantine
|
||||
return ToolResult(
|
||||
content=message, # prose-only clients still get a full sentence
|
||||
structured_content=payload,
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
@@ -832,7 +843,7 @@ def cad_tool(*, summary: str, cost: str):
|
||||
# Some MCP clients degrade (or hard-cap) when a server exposes 100+ tools.
|
||||
# TOOL_PROFILE selects how much of the surface is advertised:
|
||||
# full — everything (default, backwards compatible)
|
||||
# lean — a curated ~46-tool drafting/inspection core
|
||||
# lean — a curated 47-tool drafting/inspection core
|
||||
# Disabled tools stay registered (system_about still reports the full
|
||||
# inventory) but are hidden from MCP list_tools and rejected if called.
|
||||
|
||||
@@ -3514,6 +3525,43 @@ def _batch_capability_of(exc: BaseException) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_quarantine_error_type: type | None = None
|
||||
|
||||
|
||||
def _quarantine_refusal_type() -> type | None:
|
||||
"""``DocumentQuarantineError``, imported lazily like the capability types.
|
||||
|
||||
It lives in ``backends.quarantine``, which deliberately imports no ezdxf, so
|
||||
this resolves even on a box where a backend's dependencies are missing.
|
||||
"""
|
||||
global _quarantine_error_type
|
||||
if _quarantine_error_type is None:
|
||||
from backends.quarantine import DocumentQuarantineError
|
||||
|
||||
_quarantine_error_type = DocumentQuarantineError
|
||||
return _quarantine_error_type
|
||||
|
||||
|
||||
def _batch_quarantine_of(exc: BaseException) -> dict | None:
|
||||
"""The abandoned-call record ``exc`` refuses on behalf of, or None.
|
||||
|
||||
Recognised by type, not structurally: ``DocumentQuarantineError``
|
||||
deliberately has no ``capability`` attribute, because
|
||||
``_batch_capability_of`` would then report it to the client as
|
||||
``kind: "unsupported"`` — telling them the ezdxf engine cannot draw a circle
|
||||
when the engine is fine and the document is not.
|
||||
"""
|
||||
try:
|
||||
quarantine_type = _quarantine_refusal_type()
|
||||
except Exception as exc_import: # pragma: no cover - core module
|
||||
log.debug("quarantine refusal type unavailable: %s", exc_import)
|
||||
return None
|
||||
for error in _batch_cause_chain(exc):
|
||||
if isinstance(error, quarantine_type):
|
||||
return getattr(error, "quarantine", None) or {}
|
||||
return None
|
||||
|
||||
|
||||
def _refusal_message(exc: BaseException) -> str:
|
||||
"""The most specific message on the cause chain, in full.
|
||||
|
||||
@@ -3560,6 +3608,16 @@ def _classify_batch_error(exc: BaseException) -> dict:
|
||||
"capability": capability,
|
||||
"message": _batch_message(exc),
|
||||
}
|
||||
quarantine = _batch_quarantine_of(exc)
|
||||
if quarantine is not None:
|
||||
# R32: its own kind. Filing it under "unsupported" would tell the batch
|
||||
# reader the engine lacks a feature; filing it under "failed" would hide
|
||||
# that every remaining step will refuse for the same reason.
|
||||
return {
|
||||
"kind": "quarantined",
|
||||
"quarantine": quarantine,
|
||||
"message": _batch_message(exc),
|
||||
}
|
||||
chain = _batch_cause_chain(exc)
|
||||
if any(isinstance(error, NotFoundError) for error in chain):
|
||||
return {"kind": "unknown_tool", "message": _batch_message(exc)}
|
||||
@@ -4849,8 +4907,10 @@ async def drawing_settings(
|
||||
description=(
|
||||
"Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: "
|
||||
"units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, "
|
||||
"dimscale, text_size, point_mode, point_size, osmode, fillet_radius. "
|
||||
'Example: {"units": "mm", "dimscale": 1.0, "linear_precision": 2}.'
|
||||
"dimscale, dim_text_height, dim_arrow_size, dim_decimals, "
|
||||
'decimal_separator ("." or ","), zero_suppression, text_size, point_mode, '
|
||||
"point_size, osmode, fillet_radius. "
|
||||
'Example: {"units": "mm", "dimscale": 1.0, "dim_text_height": 3.5}.'
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
@@ -4859,9 +4919,14 @@ async def drawing_settings(
|
||||
"""Read or change common AutoCAD drawing settings by friendly name.
|
||||
|
||||
A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE,
|
||||
DIMSCALE, TEXTSIZE, OSMODE, …) so the user can say "set units to mm and
|
||||
dimension scale to 1" without memorising sysvar names. Call with no argument
|
||||
to get a full snapshot of the current settings.
|
||||
DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, …) so
|
||||
the user can say "set units to mm and dimension text to 3.5" without
|
||||
memorising sysvar names. Call with no argument to get a full snapshot of the
|
||||
current settings.
|
||||
|
||||
`dim_text_height` / `dim_arrow_size` / `dim_decimals` / `decimal_separator`
|
||||
/ `zero_suppression` shape the *dimension* — `text_size` is TEXTSIZE, the
|
||||
height of a standalone TEXT entity, and does not touch dimensions.
|
||||
"""
|
||||
if settings:
|
||||
await ctx.info(f"Applying drawing settings: {', '.join(settings)}")
|
||||
@@ -6599,9 +6664,47 @@ _HTTP_TRANSPORTS = {"http", "sse", "streamable-http"}
|
||||
_orig_run_async = mcp.run_async
|
||||
|
||||
|
||||
def _effective_http_host(kwargs: dict) -> str:
|
||||
"""The address that will actually be bound — not the one we hope for.
|
||||
|
||||
This used to read ``kwargs.get("host", "127.0.0.1")`` and treat an absent
|
||||
kwarg as loopback. It is not: ``fastmcp run`` forwards ``host`` only when
|
||||
``--host`` was given, and otherwise fastmcp resolves it from
|
||||
``fastmcp.settings.host``, which is environment-backed. So
|
||||
``FASTMCP_HOST=0.0.0.0`` bound every interface while this guard inspected
|
||||
the literal string ``"127.0.0.1"`` and let it through — the check failed
|
||||
*open*, without a token, on the exact launch path it was written for.
|
||||
|
||||
If the setting cannot be resolved we fall back to loopback rather than
|
||||
refusing to start, but we say so: a guard that cannot see the host is not
|
||||
enforcing anything, and silence is how the original bug survived.
|
||||
"""
|
||||
host = kwargs.get("host")
|
||||
if host:
|
||||
return str(host)
|
||||
try:
|
||||
import fastmcp
|
||||
|
||||
resolved = getattr(fastmcp.settings, "host", None)
|
||||
if resolved:
|
||||
return str(resolved)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
log.warning(
|
||||
"Could not resolve the effective HTTP host from fastmcp settings (%s); "
|
||||
"assuming loopback. Pass --host explicitly to be sure of the bind guard.",
|
||||
exc,
|
||||
)
|
||||
return "127.0.0.1"
|
||||
log.warning(
|
||||
"fastmcp exposes no resolvable host setting; assuming loopback. Pass "
|
||||
"--host explicitly to be sure of the bind guard."
|
||||
)
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
async def _guarded_run_async(transport=None, *args, **kwargs):
|
||||
if (transport in _HTTP_TRANSPORTS) or (kwargs.get("transport") in _HTTP_TRANSPORTS):
|
||||
_validate_http_bind(kwargs.get("host", "127.0.0.1"))
|
||||
_validate_http_bind(_effective_http_host(kwargs))
|
||||
return await _orig_run_async(transport, *args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -301,3 +301,183 @@ async def test_com_backend_connects():
|
||||
assert backend.is_connected
|
||||
await backend.disconnect()
|
||||
assert not backend.is_connected
|
||||
|
||||
|
||||
# ── entity_measure: a good .Area must not be thrown away ────────────────────
|
||||
#
|
||||
# The fallback fired when `.Area` OR `.Length` was missing, and then demanded
|
||||
# `.Coordinates`, which only the polyline types carry. So HATCH, REGION and
|
||||
# 3DSOLID — the very types the README sells the COM backend on — discarded a
|
||||
# successfully-read area and raised "AutoCAD reported neither an area nor
|
||||
# usable vertices", which was false: it had the area.
|
||||
#
|
||||
# These profiles are ASSUMPTIONS about the ActiveX member table, not facts, and
|
||||
# they are what a live seat has to confirm. What they do test is our dispatch,
|
||||
# which is where the defect is: given a member profile, does the code keep the
|
||||
# number it already has?
|
||||
|
||||
|
||||
def _measure_backend(entity):
|
||||
"""A ComBackend whose HandleToObject returns exactly this entity."""
|
||||
import backends.com_backend as cb
|
||||
|
||||
backend = ComBackend()
|
||||
|
||||
async def _run(func, *args, **kwargs): # bypass the STA executor
|
||||
return func(*args, **kwargs)
|
||||
|
||||
backend._run = _run
|
||||
doc = MagicMock()
|
||||
doc.HandleToObject.return_value = entity
|
||||
cb._acad_doc = lambda: doc
|
||||
return backend
|
||||
|
||||
|
||||
class _Entity:
|
||||
"""An ActiveX entity that has only the members it is given."""
|
||||
|
||||
def __init__(self, object_name, **members):
|
||||
self.ObjectName = object_name
|
||||
for name, value in members.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
def __getattr__(self, name): # anything not set simply does not exist
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_hatch_with_an_area_and_no_length_keeps_the_area():
|
||||
"""The red case: today this raises, holding a perfectly good 300.0."""
|
||||
backend = _measure_backend(_Entity("AcDbHatch", Area=300.0))
|
||||
|
||||
result = await backend.entity_measure("2AF")
|
||||
|
||||
assert result["area"] == pytest.approx(300.0)
|
||||
assert result["method"] == "activex_area"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_solid_without_a_perimeter_says_so_instead_of_inventing_one():
|
||||
backend = _measure_backend(_Entity("AcDb3dSolid", Area=1250.0))
|
||||
|
||||
result = await backend.entity_measure("3B0")
|
||||
|
||||
assert result["area"] == pytest.approx(1250.0)
|
||||
assert result["perimeter"] is None
|
||||
assert result["perimeter_exact"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_polyline_fallback_still_fires_when_the_area_is_the_missing_one():
|
||||
"""The fallback exists for a reason and must keep working."""
|
||||
entity = _Entity("AcDbPolyline", Coordinates=(0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0))
|
||||
entity.GetBulge = lambda index: 0.0
|
||||
entity.Closed = True
|
||||
backend = _measure_backend(entity)
|
||||
|
||||
result = await backend.entity_measure("4C1")
|
||||
|
||||
assert result["method"] == "activex_fallback_analytic"
|
||||
assert result["area"] == pytest.approx(100.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_refusal_is_only_used_when_there_is_genuinely_nothing():
|
||||
"""The message names both halves, so it must only appear when both are true."""
|
||||
backend = _measure_backend(_Entity("AcDbSpline"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="neither an area nor usable vertices"):
|
||||
await backend.entity_measure("5D2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_line_is_still_refused_before_any_of_this():
|
||||
backend = _measure_backend(_Entity("AcDbLine", Area=0.0))
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not bound an area"):
|
||||
await backend.entity_measure("6E3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_live_bowtie_is_flagged_even_though_activex_answered():
|
||||
"""`area: 0.0` with no warning is the exact failure this field prevents.
|
||||
|
||||
A bowtie LWPOLYLINE has both `.Area` (which AutoCAD returns as the
|
||||
shoelace, cancelling the crossed lobes to ~0) and `.Length`, so it never
|
||||
reached the fallback where `self_intersecting` used to be computed.
|
||||
"""
|
||||
entity = _Entity(
|
||||
"AcDbPolyline",
|
||||
Area=0.0,
|
||||
Length=200.0,
|
||||
Coordinates=(0.0, 0.0, 50.0, 50.0, 50.0, 0.0, 0.0, 50.0),
|
||||
)
|
||||
backend = _measure_backend(entity)
|
||||
|
||||
result = await backend.entity_measure("7F4")
|
||||
|
||||
assert result["method"] == "activex_area"
|
||||
assert result["self_intersecting"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_simple_polyline_is_not_flagged():
|
||||
entity = _Entity(
|
||||
"AcDbPolyline",
|
||||
Area=2500.0,
|
||||
Length=200.0,
|
||||
Coordinates=(0.0, 0.0, 50.0, 0.0, 50.0, 50.0, 0.0, 50.0),
|
||||
)
|
||||
backend = _measure_backend(entity)
|
||||
|
||||
assert (await backend.entity_measure("8A5"))["self_intersecting"] is False
|
||||
|
||||
|
||||
# ── the member profile, measured on a live AutoCAD 2026 (2026-08-06) ────────
|
||||
#
|
||||
# Probed on a fresh seat with a throwaway drawing, nothing saved:
|
||||
# AcDbCircle Area 314.1593 Circumference 62.8319 (no Length, no Coordinates)
|
||||
# AcDbPolyline Area 1600.0 Length 160.0 Coordinates present
|
||||
# AcDbHatch Area 942.4778 -- r20 with an r10 island, so pi*(400-100):
|
||||
# AutoCAD's hatch area DOES subtract islands
|
||||
# AcDbRegion Area 1600.0 Perimeter 160.0
|
||||
# AcDb3dSolid Area ABSENT Volume 1000.0
|
||||
#
|
||||
# The last row is the one that matters: ActiveX exposes no `.Area` on a 3DSOLID
|
||||
# at all, so "REGION / 3DSOLID area" was half a claim.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_circle_uses_circumference_for_its_perimeter():
|
||||
"""Measured: AcDbCircle has `.Circumference` and no `.Length`."""
|
||||
backend = _measure_backend(_Entity("AcDbCircle", Area=314.1593, Circumference=62.8319))
|
||||
|
||||
result = await backend.entity_measure("C01")
|
||||
|
||||
assert result["area"] == pytest.approx(314.1593)
|
||||
assert result["perimeter"] == pytest.approx(62.8319)
|
||||
assert result["perimeter_exact"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_region_uses_perimeter():
|
||||
"""Measured: AcDbRegion has `.Perimeter` and no `.Length`."""
|
||||
backend = _measure_backend(_Entity("AcDbRegion", Area=1600.0, Perimeter=160.0))
|
||||
|
||||
result = await backend.entity_measure("R01")
|
||||
|
||||
assert result["area"] == pytest.approx(1600.0)
|
||||
assert result["perimeter"] == pytest.approx(160.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_3dsolid_is_refused_by_name_because_activex_has_no_area_for_it():
|
||||
"""Measured: AcDb3dSolid exposes Volume and no Area at all.
|
||||
|
||||
The generic "neither an area nor usable vertices" would send the reader
|
||||
hunting for geometry that was never the problem.
|
||||
"""
|
||||
backend = _measure_backend(_Entity("AcDb3dSolid", Volume=1000.0))
|
||||
|
||||
with pytest.raises(RuntimeError, match="no surface-area member"):
|
||||
await backend.entity_measure("S01")
|
||||
|
||||
@@ -117,6 +117,80 @@ async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeyp
|
||||
assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh"
|
||||
|
||||
|
||||
# ── R32 — what the timeout message must admit, and the undo mark it must record ──
|
||||
|
||||
|
||||
async def test_com_timeout_says_the_call_may_still_land(com_backend, monkeypatch):
|
||||
"""AutoCAD's own command processor arbitrates the abandoned call, not us.
|
||||
|
||||
Unlike the ezdxf backend there is no ``self._doc`` to quarantine — the
|
||||
drawing belongs to the operator and lives in AutoCAD's process. What the
|
||||
server *can* do is stop the message implying the operation is dead: the
|
||||
worker is still blocked inside an uncancellable COM call, so a blind retry
|
||||
double-applies it.
|
||||
"""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
|
||||
def _hang():
|
||||
try:
|
||||
release.wait(5.0)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await com_backend._run(_hang)
|
||||
finally:
|
||||
release.set()
|
||||
assert finished.wait(5)
|
||||
|
||||
message = str(excinfo.value).lower()
|
||||
assert "may still" in message, "the abandoned call can still execute in AutoCAD"
|
||||
assert "retry" in message and "double" in message
|
||||
|
||||
|
||||
async def test_timed_out_transaction_begin_does_not_hide_an_open_undo_mark(
|
||||
com_backend, monkeypatch
|
||||
):
|
||||
"""RED: ``_transaction_active`` was set only after ``_run`` returned.
|
||||
|
||||
A timed-out ``transaction_begin`` therefore left ``StartUndoMark`` open in
|
||||
AutoCAD while the server recorded no transaction, and the next
|
||||
``transaction_begin`` nested a second mark nobody could see — the exact
|
||||
inverse of the R16 fix ``transaction_commit`` already carries.
|
||||
"""
|
||||
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
marks = []
|
||||
|
||||
class _FakeDoc:
|
||||
def StartUndoMark(self):
|
||||
marks.append("start")
|
||||
try:
|
||||
release.wait(5.0)
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: _FakeDoc())
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="did not respond"):
|
||||
await com_backend.transaction_begin()
|
||||
|
||||
assert com_backend._transaction_active is True, (
|
||||
"the mark may already be set in AutoCAD; recording nothing is the unsafe direction"
|
||||
)
|
||||
again = await com_backend.transaction_begin()
|
||||
assert again == {"ok": False, "error": "A transaction is already active"}
|
||||
assert marks == ["start"], "the second begin must not nest another undo mark"
|
||||
finally:
|
||||
release.set()
|
||||
assert finished.wait(5)
|
||||
|
||||
|
||||
def _raise_value_error():
|
||||
raise ValueError("nope")
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""DIM* header variables have to reach the dimension, not just the header.
|
||||
|
||||
v1.5.1 fixed exactly one of them. `system_set_variable("DIMSCALE", 4)` grew a
|
||||
fold that carried `$DIMSCALE` onto the per-dimension override, because ezdxf
|
||||
renders a DIMENSION from the dimstyle table entry plus that override and never
|
||||
reads the document header. Every *other* DIM* variable kept the original defect:
|
||||
|
||||
drawing_new()
|
||||
system_set_variable("DIMTXT", 5.0) -> {"ok": True}
|
||||
system_set_variable("DIMASZ", 6.0) -> {"ok": True}
|
||||
system_set_variable("DIMDEC", 1) -> {"ok": True}
|
||||
system_set_variable("DIMDSEP", 44) -> {"ok": True}
|
||||
system_set_variable("DIMZIN", 0) -> {"ok": True}
|
||||
dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
|
||||
Measured before the fix: five `ok: True` results, a header that reads back every
|
||||
one of the five values, and a dimension still rendered at 2.5 mm text with 2.5
|
||||
arrows and "33.33" — byte-identical to the one drawn before the writes. A live
|
||||
AutoCAD seat builds a dimension from the current dimvars, so the same five calls
|
||||
did change the drawing there: one tool call, one reported success, two engines
|
||||
drawing different sheets.
|
||||
|
||||
These tests read the *rendered* geometry block rather than the header, because
|
||||
the header was never the thing that was broken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _rendered(backend, handle):
|
||||
"""(text, text height, arrow scales) out of the dimension's geometry block.
|
||||
|
||||
The override lands in the dimension's XDATA, not on `dim.dxf`, so the block
|
||||
ezdxf renders is the only honest witness. Arrow size shows up as the xscale
|
||||
of the `_CLOSEDFILLED` arrowhead INSERT — the one non-text observable here,
|
||||
which is what keeps these tests from passing on a text-only patch.
|
||||
"""
|
||||
dim = backend._doc.entitydb.get(handle)
|
||||
block = backend._doc.blocks.get(dim.dxf.geometry)
|
||||
text = None
|
||||
height = None
|
||||
arrows = []
|
||||
for entity in block:
|
||||
if entity.dxftype() == "MTEXT" and text is None:
|
||||
text, height = entity.text, round(float(entity.dxf.char_height), 4)
|
||||
elif entity.dxftype() == "TEXT" and text is None:
|
||||
text, height = entity.dxf.text, round(float(entity.dxf.height), 4)
|
||||
elif entity.dxftype() == "INSERT":
|
||||
arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4))
|
||||
if text is None:
|
||||
raise AssertionError("the dimension block carries no text")
|
||||
return text, height, sorted(set(arrows))
|
||||
|
||||
|
||||
# ── text height ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_dimtxt_reaches_the_next_dimension(backend):
|
||||
"""Asserted as a ratio, for the reason recorded in the DIMSCALE tests.
|
||||
|
||||
Hardcoding 5.0 would hide the day the base height moves; hardcoding 2.5
|
||||
would make the test depend on the ISO default it is not measuring.
|
||||
"""
|
||||
plain = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
_t, base, _a = _rendered(backend, plain.handle)
|
||||
|
||||
await backend.system_set_variable("DIMTXT", base * 2)
|
||||
after = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
|
||||
|
||||
_t, height, _a = _rendered(backend, after.handle)
|
||||
assert height == pytest.approx(base * 2), (
|
||||
f"DIMTXT must letter the next dimension: {base} -> {height}"
|
||||
)
|
||||
|
||||
|
||||
async def test_dimtxt_and_dimscale_still_multiply(backend):
|
||||
"""`text_height = get_char_height(...) * dimscale` (ezdxf dim_base.py).
|
||||
|
||||
Both fold into the same override dict, so the two must compose rather than
|
||||
the later one winning.
|
||||
"""
|
||||
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
_t, base, _a = _rendered(backend, plain.handle)
|
||||
|
||||
await backend.system_set_variable("DIMTXT", base * 2)
|
||||
await backend.system_set_variable("DIMSCALE", 3.0)
|
||||
after = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
|
||||
|
||||
_t, height, _a = _rendered(backend, after.handle)
|
||||
assert height == pytest.approx(base * 6)
|
||||
|
||||
|
||||
async def test_a_fixed_height_text_style_still_defeats_dimtxt(backend):
|
||||
"""A limitation this fold cannot repair, pinned so nobody assumes it can.
|
||||
|
||||
`get_char_height` reads the *text style's* height first and only falls back
|
||||
to DIMTXT when it is 0 (ezdxf dim_base.py). Measured: with the `Standard`
|
||||
text style at 3.0 mm, DIMTXT 5.0 still renders 3.0 — the header write
|
||||
reports success and the sheet does not move. Only a height-0 (variable)
|
||||
text style lets DIMTXT through, which is what `drawing_new` ships.
|
||||
"""
|
||||
backend._doc.styles.get("Standard").dxf.height = 3.0
|
||||
await backend.system_set_variable("DIMTXT", 5.0)
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
|
||||
_t, height, _a = _rendered(backend, dim.handle)
|
||||
assert height == pytest.approx(3.0), "a fixed-height text style outranks DIMTXT"
|
||||
|
||||
|
||||
# ── arrows: the one non-text observable ─────────────────────────────────────
|
||||
|
||||
|
||||
async def test_dimasz_scales_the_arrowheads(backend):
|
||||
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
_t, _h, base = _rendered(backend, plain.handle)
|
||||
assert base, "no arrowhead INSERT to measure"
|
||||
|
||||
await backend.system_set_variable("DIMASZ", 6.0)
|
||||
after = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
|
||||
|
||||
_t, _h, arrows = _rendered(backend, after.handle)
|
||||
assert arrows == pytest.approx([6.0]), f"DIMASZ must size the arrows: {base} -> {arrows}"
|
||||
|
||||
|
||||
# ── number formatting ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("decimals", "expected"), [(1, "33.3"), (0, "33")])
|
||||
async def test_dimdec_sets_the_decimal_places(backend, decimals, expected):
|
||||
await backend.system_set_variable("DIMDEC", decimals)
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
|
||||
text, _h, _a = _rendered(backend, dim.handle)
|
||||
assert text == expected
|
||||
|
||||
|
||||
async def test_dimdsep_switches_the_decimal_marker(backend):
|
||||
"""44 is the char code for a comma; ezdxf refuses a string in the header."""
|
||||
await backend.system_set_variable("DIMDSEP", 44)
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
|
||||
text, _h, _a = _rendered(backend, dim.handle)
|
||||
assert text == "33,33"
|
||||
|
||||
|
||||
async def test_an_untouched_drawing_still_uses_a_point(backend):
|
||||
"""The converse regression: ISO 129 wants a point, and ezdxf's own renderer
|
||||
fallback for `dimdsep` is 0 -> a comma. A fold keyed on the wrong baseline
|
||||
would reintroduce the comma on every dimension."""
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
|
||||
text, _h, _a = _rendered(backend, dim.handle)
|
||||
assert text == "33.33"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("dimzin", "expected"), [(0, "100.000"), (8, "100")])
|
||||
async def test_dimzin_controls_trailing_zeros(backend, dimzin, expected):
|
||||
await backend.system_set_variable("DIMDEC", 3)
|
||||
await backend.system_set_variable("DIMZIN", dimzin)
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
|
||||
text, _h, _a = _rendered(backend, dim.handle)
|
||||
assert text == expected
|
||||
|
||||
|
||||
# ── all five tools, not just linear ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_dimtxt_reaches_all_five_dimension_tools(backend):
|
||||
"""aligned and angular do not go through `build_dim_override`.
|
||||
|
||||
That is exactly how they were missed when DIMSCALE was folded the first
|
||||
time (see `test_dimscale_reaches_aligned_and_angular_too`), so every tool
|
||||
is measured here rather than trusting the shared helper.
|
||||
"""
|
||||
plain = {
|
||||
"linear": await backend.dimension_linear(0, 0, 100, 0, 50, -30),
|
||||
"aligned": await backend.dimension_aligned(0, 0, 60, 60, 40, 70),
|
||||
"angular": await backend.dimension_angular(0, 0, 50, 0, -30, 50, 30, 30),
|
||||
"radius": await backend.dimension_radius(0, 0, 20, 0, 10),
|
||||
"diameter": await backend.dimension_diameter(0, 0, 40, 0, 10),
|
||||
}
|
||||
base = {name: _rendered(backend, info.handle)[1] for name, info in plain.items()}
|
||||
|
||||
await backend.system_set_variable("DIMTXT", 5.0)
|
||||
scaled = {
|
||||
"linear": await backend.dimension_linear(0, 0, 100, 0, 50, -60),
|
||||
"aligned": await backend.dimension_aligned(0, 0, 60, 60, 40, 90),
|
||||
"angular": await backend.dimension_angular(0, 0, 50, 0, -30, 50, 50, 50),
|
||||
"radius": await backend.dimension_radius(0, 0, 20, 0, 30),
|
||||
"diameter": await backend.dimension_diameter(0, 0, 40, 0, 30),
|
||||
}
|
||||
|
||||
for name, info in scaled.items():
|
||||
_t, height, _a = _rendered(backend, info.handle)
|
||||
assert height == pytest.approx(5.0), f"{name}: {base[name]} -> {height}, wanted 5.0"
|
||||
|
||||
|
||||
async def test_angular_text_is_a_different_knob_and_is_deliberately_not_folded(backend):
|
||||
"""DIMDEC does not format an angular dimension — DIMADEC does.
|
||||
|
||||
Pinned because $DIMADEC and $DIMAZIN sit at 0 in a fresh header while
|
||||
ezdxf's renderer falls back to 2 and 2. Adding them to the fold table to
|
||||
make it look "complete" would silently reformat every angular dimension
|
||||
this server has ever drawn, from 239.04 deg to 239 deg. The allowlist is
|
||||
measured, not exhaustive.
|
||||
"""
|
||||
await backend.system_set_variable("DIMDEC", 0)
|
||||
|
||||
dim = await backend.dimension_angular(0, 0, 50, 0, -30, 50, 30, 30)
|
||||
|
||||
text, _h, _a = _rendered(backend, dim.handle)
|
||||
assert text.startswith("239.04"), f"angular text must keep DIMADEC's 2 places, got {text!r}"
|
||||
|
||||
|
||||
# ── the fold must not litter ────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_fresh_drawing_carries_no_dimension_override(backend):
|
||||
"""Generalises `test_the_default_dimscale_adds_no_override`.
|
||||
|
||||
After `drawing_new` the header and the `Standard` dimstyle agree on every
|
||||
folded variable ($DIMTXT 2.5/2.5, $DIMASZ 2.5/2.5, $DIMDEC 2/2, $DIMDSEP
|
||||
46/46, $DIMSCALE and $DIMZIN unset on the style and equal to the renderer
|
||||
fallback), so the fold has nothing to say and the dimension must carry no
|
||||
XDATA override at all.
|
||||
"""
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
|
||||
entity = backend._doc.entitydb.get(dim.handle)
|
||||
style = backend._doc.dimstyles.get("Standard")
|
||||
assert entity.get_acad_dstyle(style) == {}
|
||||
|
||||
|
||||
async def test_the_basic_tolerance_box_survives_the_fold(backend):
|
||||
"""`tol_mode="basic"` sets dimgap=-1.0 to draw the theoretically-exact box.
|
||||
|
||||
The fold runs *after* `build_dim_override` and must only `setdefault`, so a
|
||||
caller's explicit value always outranks the header. dimgap is not in the
|
||||
table today, but this pins the ordering that protects it if it ever is.
|
||||
"""
|
||||
await backend.system_set_variable("DIMTXT", 5.0)
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30, tol_mode="basic")
|
||||
|
||||
entity = backend._doc.entitydb.get(dim.handle)
|
||||
style = backend._doc.dimstyles.get("Standard")
|
||||
overrides = entity.get_acad_dstyle(style)
|
||||
assert overrides.get("dimgap") == pytest.approx(-1.0)
|
||||
assert overrides.get("dimtxt") == pytest.approx(5.0)
|
||||
|
||||
|
||||
# ── precedence, both directions ─────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_setting_the_variable_back_reverts_the_next_dimension(backend):
|
||||
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
_t, base, _a = _rendered(backend, plain.handle)
|
||||
|
||||
await backend.system_set_variable("DIMTXT", 5.0)
|
||||
await backend.dimension_linear(0, 0, 100, 0, 50, -60)
|
||||
await backend.system_set_variable("DIMTXT", base)
|
||||
reverted = await backend.dimension_linear(0, 0, 100, 0, 50, -90)
|
||||
|
||||
_t, height, _a = _rendered(backend, reverted.handle)
|
||||
assert height == pytest.approx(base)
|
||||
|
||||
|
||||
async def test_a_rollback_restores_the_header_and_the_next_dimension(backend):
|
||||
"""Transactions snapshot the whole DXF, header included."""
|
||||
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
_t, base, _a = _rendered(backend, plain.handle)
|
||||
|
||||
await backend.transaction_begin()
|
||||
await backend.system_set_variable("DIMTXT", 5.0)
|
||||
await backend.transaction_rollback()
|
||||
|
||||
after = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
|
||||
_t, height, _a = _rendered(backend, after.handle)
|
||||
assert height == pytest.approx(base)
|
||||
|
||||
|
||||
# ── opened files ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_an_opened_file_honours_its_own_header(backend, tmp_path):
|
||||
"""A file whose header disagrees with its dimstyle used to draw the style.
|
||||
|
||||
`drawing_open` never runs `_apply_iso_dimstyle`, so before the fold the
|
||||
header of an opened template was decorative: it could claim $DIMTXT 5.0
|
||||
and every dimension still came out at the style's 2.5. AutoCAD builds a
|
||||
dimension from the current dimvars, so the file stops lying about itself.
|
||||
"""
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.new(dxfversion="R2010")
|
||||
doc.dimstyles.get("Standard").dxf.dimtxt = 2.5
|
||||
doc.header["$DIMTXT"] = 5.0
|
||||
path = tmp_path / "header_says_five.dxf"
|
||||
doc.saveas(str(path))
|
||||
|
||||
await backend.drawing_open(str(path))
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
|
||||
_t, height, _a = _rendered(backend, dim.handle)
|
||||
assert height == pytest.approx(5.0)
|
||||
|
||||
|
||||
# ── the fold itself ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_header_without_the_variable_does_not_break_the_fold():
|
||||
"""An opened file may be missing a DIM* var, or carry junk in it."""
|
||||
from backends.ezdxf_backend import _with_header_dimvars
|
||||
|
||||
class _Header:
|
||||
def get(self, name, default=None):
|
||||
return {"$DIMTXT": "not a number"}.get(name, default)
|
||||
|
||||
class _Doc:
|
||||
header = _Header()
|
||||
dimstyles = None # resolving the style must not be required either
|
||||
|
||||
assert _with_header_dimvars(_Doc(), None) is None
|
||||
assert _with_header_dimvars(_Doc(), {"dimgap": -1.0}) == {"dimgap": -1.0}
|
||||
|
||||
|
||||
async def test_the_dollar_prefixed_name_is_still_rejected(backend):
|
||||
"""`system_set_variable` prefixes '$' unconditionally, so '$DIMTXT' becomes
|
||||
'$$DIMTXT' and ezdxf refuses it. Pinned because the original bug report
|
||||
reached for the dollar form and read the resulting exception as the no-op:
|
||||
a test written that way would fail for the wrong reason forever."""
|
||||
with pytest.raises(Exception, match=r"\$\$DIMTXT"):
|
||||
await backend.system_set_variable("$DIMTXT", 5.0)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Headless dimensions must be readable ISO drawings, not ezdxf defaults.
|
||||
|
||||
`drawing_new` called `ezdxf.new(dxfversion=...)` without `setup=True`, and
|
||||
nothing in the repo ever created or configured a dimstyle. So every headless
|
||||
dimension rendered through ezdxf's bare `Standard` style: **1.0 mm** text with a
|
||||
**comma** decimal marker. ISO 3098 puts the minimum lettering height at 2.5 mm,
|
||||
and ISO 129 wants a point.
|
||||
|
||||
Two things made that worse than a cosmetic default:
|
||||
|
||||
* The document's own header already said ``$DIMTXT 2.5``. ezdxf renders from the
|
||||
dimstyle table entry and never reads the header, so the file asserted one
|
||||
height and drew another.
|
||||
* A live AutoCAD seat renders from whatever its template carries, so the two
|
||||
engines printed a different text height *and* a different decimal marker for
|
||||
byte-identical tool calls, with no capability key or test recording it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
#: ISO 3098 minimum lettering height for technical drawings.
|
||||
ISO_MIN_TEXT_MM = 2.5
|
||||
|
||||
|
||||
def _rendered(backend, handle):
|
||||
"""The text and height a drafter actually sees, out of the dimension block."""
|
||||
dim = backend._doc.entitydb.get(handle)
|
||||
block = backend._doc.blocks.get(dim.dxf.geometry)
|
||||
for entity in block:
|
||||
if entity.dxftype() == "MTEXT":
|
||||
return entity.text, float(entity.dxf.char_height)
|
||||
if entity.dxftype() == "TEXT":
|
||||
return entity.dxf.text, float(entity.dxf.height)
|
||||
raise AssertionError("the dimension block carries no text")
|
||||
|
||||
|
||||
async def test_a_headless_dimension_is_lettered_at_iso_height(backend):
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
||||
|
||||
_text, height = _rendered(backend, dim.handle)
|
||||
|
||||
assert height >= ISO_MIN_TEXT_MM, (
|
||||
f"rendered at {height} mm; ISO 3098 minimum is {ISO_MIN_TEXT_MM} mm"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_decimal_marker_is_a_point(backend):
|
||||
"""ISO 129 wants a point. ezdxf's bare default is a comma."""
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
|
||||
|
||||
text, _height = _rendered(backend, dim.handle)
|
||||
|
||||
assert "," not in text, f"rendered {text!r} with a comma decimal marker"
|
||||
assert "33.33" in text
|
||||
|
||||
|
||||
async def test_the_header_and_the_dimstyle_agree_about_text_height(backend):
|
||||
"""The file used to assert $DIMTXT 2.5 and draw 1.0."""
|
||||
header = float(backend._doc.header.get("$DIMTXT"))
|
||||
style = backend._doc.dimstyles.get("Standard")
|
||||
|
||||
assert float(style.dxf.dimtxt) == pytest.approx(header)
|
||||
|
||||
|
||||
async def test_a_reopened_drawing_keeps_the_style(backend, tmp_path):
|
||||
"""The style has to be in the file, not applied in memory on the way out."""
|
||||
import ezdxf
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
||||
path = tmp_path / "styled.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
|
||||
reopened = ezdxf.readfile(str(path))
|
||||
style = reopened.dimstyles.get("Standard")
|
||||
assert float(style.dxf.dimtxt) >= ISO_MIN_TEXT_MM
|
||||
assert int(style.dxf.dimdsep) == ord(".")
|
||||
assert reopened.entitydb.get(dim.handle) is not None
|
||||
|
||||
|
||||
# ── the gate has to be able to see it ───────────────────────────────────────
|
||||
|
||||
|
||||
async def test_the_iso_critique_catches_sub_iso_dimension_text(backend):
|
||||
"""A drawing whose dimensions are 1.0 mm used to critique clean.
|
||||
|
||||
Fixing the default stops this server *producing* such a drawing, but the
|
||||
gate still has to catch one — a template, an opened file, or a caller who
|
||||
sets the style back can all reintroduce it, and `drawing_critique`
|
||||
returning [] is the claim that the sheet is ISO-conformant.
|
||||
|
||||
Both the style and the header are pushed down here. Since the DIM* header
|
||||
fold, the header outranks the style at render time, so setting the style
|
||||
alone no longer produces a 1.0 mm drawing — it would leave this test
|
||||
asserting a defect that is not on the sheet.
|
||||
"""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
|
||||
await backend.system_set_variable("DIMTXT", 1.0)
|
||||
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
||||
|
||||
issues = await run_critique(backend, focus=["iso128"])
|
||||
|
||||
assert issues, "1.0 mm dimension text is below the ISO 3098 minimum"
|
||||
assert any("2.5" in issue.message for issue in issues)
|
||||
|
||||
|
||||
async def test_the_iso_critique_catches_a_comma_decimal_marker(backend):
|
||||
from engineering.critique import run_critique
|
||||
|
||||
backend._doc.dimstyles.get("Standard").dxf.dimdsep = ord(",")
|
||||
await backend.system_set_variable("DIMDSEP", ord(","))
|
||||
await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
|
||||
|
||||
issues = await run_critique(backend, focus=["iso128"])
|
||||
|
||||
assert any("decimal" in issue.message.lower() for issue in issues)
|
||||
|
||||
|
||||
async def test_a_conformant_drawing_still_critiques_clean(backend):
|
||||
"""The rule must not fire on the styles this server now ships."""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
||||
|
||||
assert await run_critique(backend, focus=["iso128"]) == []
|
||||
|
||||
|
||||
async def test_the_critique_grades_what_will_actually_be_drawn(backend):
|
||||
"""When the header and the style disagree, the header is what gets drawn.
|
||||
|
||||
Reading the style alone made `drawing_critique` able to be wrong in both
|
||||
directions once DIM* header variables started reaching the renderer: it
|
||||
would report a 1.0 mm defect on a sheet lettered at 2.5, and clear a sheet
|
||||
lettered at 1.0 because the style still said 2.5. The gate has to resolve
|
||||
the same header-then-style order the renderer does.
|
||||
"""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
# Style below the minimum, header above it -> the sheet is conformant.
|
||||
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
|
||||
await backend.system_set_variable("DIMTXT", 2.5)
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
||||
assert _rendered(backend, dim.handle)[1] == pytest.approx(2.5)
|
||||
assert await run_critique(backend, focus=["iso128"]) == []
|
||||
|
||||
# Style above it, header below -> the sheet is not.
|
||||
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 2.5
|
||||
await backend.system_set_variable("DIMTXT", 1.0)
|
||||
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -60, layer="DIM")
|
||||
assert _rendered(backend, dim.handle)[1] == pytest.approx(1.0)
|
||||
assert await run_critique(backend, focus=["iso128"])
|
||||
|
||||
|
||||
# ── the DIM* header fold must not fight the ISO defaults ────────────────────
|
||||
|
||||
|
||||
async def test_the_iso_defaults_survive_the_header_fold(backend):
|
||||
"""The regression that fails if the fold is keyed on the wrong baseline.
|
||||
|
||||
The fold skips a variable when the header already agrees with what the
|
||||
renderer would use. That baseline is ezdxf's *renderer* fallback (dimtxt
|
||||
1.0, dimasz 0.25, dimdsep 0 -> comma), not the DXF schema default (2.5,
|
||||
2.5, 44). Key it on the schema defaults and a fresh drawing starts folding
|
||||
values it should leave alone — most visibly reintroducing the comma this
|
||||
file exists to keep out.
|
||||
"""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
await backend.drawing_apply_iso_layers("mech")
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
|
||||
|
||||
text, height = _rendered(backend, dim.handle)
|
||||
assert height == pytest.approx(ISO_MIN_TEXT_MM)
|
||||
assert text == "33.33"
|
||||
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
|
||||
arrows = {round(float(e.dxf.get("xscale", 1.0)), 3) for e in block if e.dxftype() == "INSERT"}
|
||||
assert arrows == {2.5}
|
||||
assert await run_critique(backend, focus=["iso128"]) == []
|
||||
|
||||
|
||||
# ── DIMRND 0.0 means "no rounding", not "round to whole units" ──────────────
|
||||
|
||||
|
||||
async def _reopened(backend, tmp_path):
|
||||
path = tmp_path / "roundtrip.dxf"
|
||||
await backend.drawing_save_as(str(path))
|
||||
await backend.drawing_open(str(path))
|
||||
return path
|
||||
|
||||
|
||||
def _text(backend, handle) -> str:
|
||||
dim = backend._doc.entitydb.get(handle)
|
||||
for entity in backend._doc.blocks.get(dim.dxf.geometry):
|
||||
if entity.dxftype() == "MTEXT":
|
||||
return entity.text
|
||||
if entity.dxftype() == "TEXT":
|
||||
return entity.dxf.text
|
||||
raise AssertionError("the dimension block carries no text")
|
||||
|
||||
|
||||
async def test_a_reopened_drawing_does_not_round_every_dimension_to_a_whole_unit(backend, tmp_path):
|
||||
"""A saved file stores `dimrnd = 0.0`, and ezdxf rounds on it.
|
||||
|
||||
AutoCAD's DIMRND 0 means *no* rounding. ezdxf applies `xround(value,
|
||||
dimrnd)` whenever the attribute is present at all, and `xround(33.333, 0.0)`
|
||||
is 33 — so every dimension on any reopened file was silently rounded to a
|
||||
whole unit *before* DIMDEC ever formatted it. Measured on a file this
|
||||
server saved itself: a 12.75 mm feature dimensioned as 13.
|
||||
"""
|
||||
await _reopened(backend, tmp_path)
|
||||
|
||||
await backend.drawing_settings({"dim_decimals": 2})
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
|
||||
assert _text(backend, dim.handle) == "33.33"
|
||||
|
||||
|
||||
async def test_dim_decimals_still_works_after_a_reopen(backend, tmp_path):
|
||||
await _reopened(backend, tmp_path)
|
||||
|
||||
for decimals, expected in ((0, "33"), (1, "33.3"), (3, "33.333")):
|
||||
await backend.drawing_settings({"dim_decimals": decimals})
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30 * (decimals + 2))
|
||||
assert _text(backend, dim.handle) == expected
|
||||
|
||||
|
||||
async def test_a_real_rounding_value_is_left_alone(backend, tmp_path):
|
||||
"""Only the 0.0 sentinel is discarded — a drafter who asked for 0.5 mm
|
||||
rounding must keep it."""
|
||||
backend._doc.dimstyles.get("Standard").dxf.dimrnd = 0.5
|
||||
await _reopened(backend, tmp_path)
|
||||
|
||||
assert float(backend._doc.dimstyles.get("Standard").dxf.dimrnd) == 0.5
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
# 33.5, not 33.33: the rounding survived. The trailing zero is gone because
|
||||
# DIMZIN defaults to 8, which suppresses it — that is formatting, and it is
|
||||
# a different knob from the rounding this test is about.
|
||||
assert _text(backend, dim.handle) == "33.5"
|
||||
@@ -36,3 +36,60 @@ async def test_dimension_diameter(backend):
|
||||
info = await backend.dimension_diameter(20, 50, 80, 50, leader_length=10)
|
||||
assert info.handle
|
||||
assert info.type == "DIMENSION"
|
||||
|
||||
|
||||
# ── the number on the dimension must be the number on the drawing ───────────
|
||||
#
|
||||
# `add_diameter_dim`/`add_radius_dim` take `mpoint` as a point ON the circle;
|
||||
# `leader_length` is where the *text* sits. Passing `centre + (radius + leader)`
|
||||
# as `mpoint` makes ezdxf measure to that point, so every diameter came out
|
||||
# `2 x leader_length` too big and every radius `leader_length` too big -- at
|
||||
# DEFAULT settings, silently, on a drawing whose whole purpose is the number.
|
||||
# The live COM backend was never wrong here: AddDimDiametric takes the two
|
||||
# chord points and the leader length as separate arguments.
|
||||
|
||||
|
||||
async def test_a_diameter_dimension_measures_the_diameter(backend):
|
||||
"""Two ends of a diameter of a true 40 mm circle."""
|
||||
info = await backend.dimension_diameter(-20, 0, 20, 0)
|
||||
|
||||
dim = backend._doc.entitydb.get(info.handle)
|
||||
assert dim.get_measurement() == pytest.approx(40.0)
|
||||
|
||||
|
||||
async def test_the_leader_length_moves_the_text_not_the_measurement(backend):
|
||||
"""The regression in one assertion: leader_length must not be measurable."""
|
||||
near = await backend.dimension_diameter(-20, 0, 20, 0, leader_length=5.0)
|
||||
far = await backend.dimension_diameter(-20, 0, 20, 0, leader_length=40.0)
|
||||
|
||||
measurements = [
|
||||
backend._doc.entitydb.get(near.handle).get_measurement(),
|
||||
backend._doc.entitydb.get(far.handle).get_measurement(),
|
||||
]
|
||||
assert measurements == pytest.approx([40.0, 40.0])
|
||||
|
||||
|
||||
async def test_a_radius_dimension_measures_the_radius(backend):
|
||||
info = await backend.dimension_radius(0, 0, 20, 0)
|
||||
|
||||
dim = backend._doc.entitydb.get(info.handle)
|
||||
assert dim.get_measurement() == pytest.approx(20.0)
|
||||
|
||||
|
||||
async def test_a_radius_measurement_ignores_the_leader_too(backend):
|
||||
near = await backend.dimension_radius(0, 0, 20, 0, leader_length=5.0)
|
||||
far = await backend.dimension_radius(0, 0, 20, 0, leader_length=40.0)
|
||||
|
||||
measurements = [
|
||||
backend._doc.entitydb.get(near.handle).get_measurement(),
|
||||
backend._doc.entitydb.get(far.handle).get_measurement(),
|
||||
]
|
||||
assert measurements == pytest.approx([20.0, 20.0])
|
||||
|
||||
|
||||
async def test_an_off_axis_diameter_is_measured_from_its_own_chord(backend):
|
||||
"""The two points are a diameter at any angle, not just horizontal."""
|
||||
info = await backend.dimension_diameter(0, 0, 30, 40, leader_length=12.0)
|
||||
|
||||
dim = backend._doc.entitydb.get(info.handle)
|
||||
assert dim.get_measurement() == pytest.approx(50.0)
|
||||
|
||||
@@ -78,3 +78,138 @@ async def test_server_tool_reads_and_writes(backend):
|
||||
assert write["ok"] is True
|
||||
read = await server.drawing_settings(ctx=ctx)
|
||||
assert read["settings"]["units"]["name"] == "mm"
|
||||
|
||||
|
||||
# ── DIMSCALE has to reach the dimension, not just the header ────────────────
|
||||
|
||||
|
||||
def _dim_text_heights(backend, handle) -> list[float]:
|
||||
"""The text heights actually rendered into the dimension's geometry block.
|
||||
|
||||
The override lands in the dimension's XDATA, not on `dim.dxf`, so the only
|
||||
honest check is the drawing it produces.
|
||||
"""
|
||||
dim = backend._doc.entitydb.get(handle)
|
||||
block = backend._doc.blocks.get(dim.dxf.geometry)
|
||||
heights = []
|
||||
for entity in block:
|
||||
if entity.dxftype() == "MTEXT":
|
||||
heights.append(round(float(entity.dxf.char_height), 3))
|
||||
elif entity.dxftype() == "TEXT":
|
||||
heights.append(round(float(entity.dxf.height), 3))
|
||||
return sorted(set(heights))
|
||||
|
||||
|
||||
async def test_dimscale_changes_the_dimension_it_is_set_before(backend):
|
||||
"""`drawing_settings({"dimscale": ...})` reported success and did nothing.
|
||||
|
||||
It writes the `$DIMSCALE` header variable, which is what AutoCAD reads when
|
||||
it creates a dimension — so on the live backend the call worked. ezdxf
|
||||
renders dimensions from the dimstyle table entry and never consults the
|
||||
header, so headlessly the text stayed the same size however large the
|
||||
number was: one call, one reported success, two different drawings.
|
||||
"""
|
||||
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
await backend.drawing_settings({"dimscale": 4.0})
|
||||
scaled = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
|
||||
|
||||
before = _dim_text_heights(backend, plain.handle)
|
||||
after = _dim_text_heights(backend, scaled.handle)
|
||||
assert before and after
|
||||
assert after == pytest.approx([h * 4.0 for h in before]), (
|
||||
f"DIMSCALE=4 must scale the dimension text: {before} -> {after}"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_default_dimscale_adds_no_override(backend):
|
||||
"""DIMSCALE 1.0 is the default; it must not litter every dimension."""
|
||||
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
|
||||
|
||||
dim = backend._doc.entitydb.get(plain.handle)
|
||||
assert not dim.dxf.hasattr("dimscale") or dim.dxf.dimscale == pytest.approx(1.0)
|
||||
|
||||
|
||||
async def test_dimscale_reaches_aligned_and_angular_too(backend):
|
||||
"""Those two do not go through `build_dim_override`, so they were missed.
|
||||
|
||||
Asserted as a *ratio* against the same dimension drawn unscaled. This used
|
||||
to hardcode 3.0, which only held because the base text height was ezdxf's
|
||||
bare 1.0 mm — so the assertion silently depended on the very defect that
|
||||
made every headless drawing non-conformant, and moving the base to the ISO
|
||||
2.5 mm broke a test that was measuring the right thing by accident.
|
||||
"""
|
||||
plain_aligned = await backend.dimension_aligned(0, 0, 60, 60, 40, 70)
|
||||
plain_angular = await backend.dimension_angular(0, 0, 50, 0, 0, 50, 30, 30)
|
||||
base_aligned = _dim_text_heights(backend, plain_aligned.handle)
|
||||
base_angular = _dim_text_heights(backend, plain_angular.handle)
|
||||
|
||||
await backend.drawing_settings({"dimscale": 3.0})
|
||||
aligned = await backend.dimension_aligned(0, 0, 60, 60, 40, 90)
|
||||
angular = await backend.dimension_angular(0, 0, 50, 0, 0, 50, 50, 50)
|
||||
|
||||
assert _dim_text_heights(backend, aligned.handle) == pytest.approx(
|
||||
[h * 3.0 for h in base_aligned]
|
||||
)
|
||||
assert _dim_text_heights(backend, angular.handle) == pytest.approx(
|
||||
[h * 3.0 for h in base_angular]
|
||||
)
|
||||
|
||||
|
||||
# ── the rest of the dimension variables need friendly names too ─────────────
|
||||
|
||||
|
||||
def _dim_text(backend, handle) -> str:
|
||||
dim = backend._doc.entitydb.get(handle)
|
||||
for entity in backend._doc.blocks.get(dim.dxf.geometry):
|
||||
if entity.dxftype() == "MTEXT":
|
||||
return entity.text
|
||||
if entity.dxftype() == "TEXT":
|
||||
return entity.dxf.text
|
||||
raise AssertionError("the dimension block carries no text")
|
||||
|
||||
|
||||
async def test_the_dimension_variables_have_friendly_names(backend):
|
||||
"""DIMTXT / DIMASZ / DIMDEC / DIMDSEP / DIMZIN had no friendly key at all.
|
||||
|
||||
`text_size` is TEXTSIZE — the height of a standalone TEXT entity, not of a
|
||||
dimension — so the only way to reach dimension lettering was raw
|
||||
`system_set_variable`, which is exactly the path that silently did nothing
|
||||
headlessly. A drafter should not have to know a sysvar name to set the
|
||||
height of the numbers on their own drawing.
|
||||
"""
|
||||
res = await backend.drawing_settings(
|
||||
{
|
||||
"dim_text_height": 5.0,
|
||||
"dim_arrow_size": 6.0,
|
||||
"dim_decimals": 1,
|
||||
"decimal_separator": ",",
|
||||
"zero_suppression": 0,
|
||||
}
|
||||
)
|
||||
assert res["ok"] is True, res.get("errors")
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
assert _dim_text(backend, dim.handle) == "33,3"
|
||||
assert _dim_text_heights(backend, dim.handle) == pytest.approx([5.0])
|
||||
|
||||
|
||||
async def test_the_decimal_separator_reads_back_as_a_character(backend):
|
||||
"""DIMDSEP is an int char code in the header — ezdxf refuses a string
|
||||
there. The friendly key is the place to translate, so a snapshot says
|
||||
',' rather than 44."""
|
||||
await backend.drawing_settings({"decimal_separator": ","})
|
||||
|
||||
snap = (await backend.drawing_settings())["settings"]
|
||||
|
||||
assert snap["decimal_separator"] == ","
|
||||
assert snap["dim_text_height"] == 2.5
|
||||
|
||||
|
||||
async def test_a_bad_decimal_separator_is_reported_and_not_written(backend):
|
||||
before = await backend.system_get_variable("DIMDSEP")
|
||||
|
||||
res = await backend.drawing_settings({"decimal_separator": "x"})
|
||||
|
||||
assert "decimal_separator" in res["errors"]
|
||||
assert res["ok"] is False
|
||||
assert await backend.system_get_variable("DIMDSEP") == before
|
||||
|
||||
@@ -29,6 +29,7 @@ import pytest
|
||||
import config
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
from backends.quarantine import DocumentQuarantineError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@@ -113,14 +114,26 @@ async def test_both_backends_declare_the_same_capability_keys():
|
||||
|
||||
|
||||
async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch):
|
||||
"""The anti-deadlock guarantee, restated after R32.
|
||||
|
||||
This used to assert that the next ``drawing_info`` / ``entity_create_line``
|
||||
*succeeded*, on the strength of a ``_hang`` that only slept and touched no
|
||||
document. Both assertions were the defect written down as a requirement: the
|
||||
lock swap that stops the deadlock is exactly what drops mutual exclusion on
|
||||
the one shared ``Drawing``, so "succeeded" meant "ran against a document
|
||||
another thread may still be writing". The guarantee that actually holds is
|
||||
that the next call returns *immediately* — refused, not queued.
|
||||
"""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def _hang():
|
||||
entered.set()
|
||||
time.sleep(2.0)
|
||||
release.wait(5.0)
|
||||
return "too late"
|
||||
|
||||
try:
|
||||
started = time.monotonic()
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend._async(_hang)
|
||||
@@ -131,13 +144,15 @@ async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch
|
||||
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
|
||||
started = time.monotonic()
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await asyncio.wait_for(backend.drawing_info(), timeout=3)
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await asyncio.wait_for(backend.entity_create_line(0, 0, 1, 1), timeout=3)
|
||||
assert time.monotonic() - started < 1.0, "refused up front, never queued"
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(backend, monkeypatch):
|
||||
@@ -151,6 +166,10 @@ async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(bac
|
||||
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"
|
||||
# R32: nothing was abandoned here, so nothing may be quarantined — that
|
||||
# lock is held by a live call which will release it normally.
|
||||
assert backend._quarantine is None
|
||||
assert backend._abandoned_calls == []
|
||||
finally:
|
||||
live.release()
|
||||
|
||||
@@ -166,6 +185,10 @@ async def test_zero_timeout_disables_the_check(backend, monkeypatch):
|
||||
return "finished"
|
||||
|
||||
assert await backend._async(_slow) == "finished"
|
||||
# R32: with the deadline off there is no abandon branch at all, so there is
|
||||
# nothing that could quarantine the document.
|
||||
assert backend._quarantine is None
|
||||
assert backend._abandoned_calls == []
|
||||
|
||||
|
||||
async def test_async_still_serialises_calls(backend):
|
||||
@@ -202,6 +225,9 @@ async def test_a_call_raising_TimeoutError_is_not_reported_as_the_deadline(backe
|
||||
await backend._async(_boom)
|
||||
|
||||
assert backend._lock is live, "a call that merely failed must not abandon its lock"
|
||||
# R32: not our deadline, so no thread was abandoned and the document is fine.
|
||||
assert backend._quarantine is None
|
||||
assert backend._abandoned_calls == []
|
||||
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""R32 — a timed-out ezdxf call must quarantine the DOCUMENT, not just the lock.
|
||||
|
||||
``EzdxfBackend._async`` cannot cancel an ``asyncio.to_thread`` worker. When a
|
||||
call overruns ``EZDXF_CALL_TIMEOUT`` the handler swaps in a fresh
|
||||
``asyncio.Lock`` so later callers do not deadlock behind the orphaned one — and
|
||||
that swap is precisely what removes the mutual exclusion the lock existed for.
|
||||
The runaway thread still holds the one shared ``ezdxf.Drawing``, which is
|
||||
explicitly not thread-safe.
|
||||
|
||||
Measured against the unfixed backend, with a runaway ``_sync`` that really
|
||||
appends LINEs to ``backend._doc``:
|
||||
|
||||
* ``entity_create_circle`` after the abandonment returned handle ``A1`` —
|
||||
full success, no warning.
|
||||
* ``drawing_info`` reported ``entity_count=116`` for a modelspace observed at
|
||||
113 -> 118 -> 302 under the same caller, finishing at 310.
|
||||
* ``drawing_save`` returned ``{"ok": True}`` and wrote a valid DXF holding
|
||||
15200 of the 60000 entities the abandoned call would go on to write. It reads
|
||||
back fine. It is a considered document of a state that never existed.
|
||||
* ``drawing_info`` iterating a container another thread appends to does not
|
||||
tear loudly — it ran unbounded, blew its own 2.0s deadline at 2.03s and
|
||||
abandoned a SECOND worker with a SECOND orphaned lock. That is why reads are
|
||||
refused too.
|
||||
|
||||
Only the timed-out caller was ever warned. Every later caller was told nothing.
|
||||
|
||||
Every runaway helper here is bounded AND sets its ``finished`` Event in a
|
||||
``finally``: asyncio joins the default ``to_thread`` executor at loop shutdown,
|
||||
so a still-spinning worker hangs the pytest process rather than failing a test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
|
||||
import config
|
||||
import server
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
from backends.quarantine import DocumentQuarantineError, QuarantineRecord, real_call_name
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── runaway helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Runaway:
|
||||
"""A ``_sync`` that outlives its deadline, with a guaranteed way to stop it."""
|
||||
|
||||
def __init__(self):
|
||||
self.started = threading.Event()
|
||||
self.finished = threading.Event()
|
||||
self.stop = threading.Event()
|
||||
self.written = 0
|
||||
|
||||
def writer(self, backend, *, delay: float = 0.0005, limit: int = 20_000):
|
||||
"""Really appends LINEs to the shared Drawing, like a real ``_sync`` would.
|
||||
|
||||
The existing honesty test used ``time.sleep(2.0)`` as the stand-in,
|
||||
which touches no document — which is exactly why the race it was
|
||||
written to cover was never caught.
|
||||
"""
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
msp = backend._doc.modelspace()
|
||||
self.started.set()
|
||||
while self.written < limit and not self.stop.is_set():
|
||||
msp.add_line((0, 0), (1, 1))
|
||||
self.written += 1
|
||||
time.sleep(delay) # releases the GIL so the event loop runs
|
||||
finally:
|
||||
self.finished.set()
|
||||
return {"ok": True, "written": self.written}
|
||||
|
||||
return _sync
|
||||
|
||||
def slow_but_complete(self, extra: float = 0.5, result: str = "value nobody received"):
|
||||
"""A legitimately slow call that finishes cleanly just past the deadline."""
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
self.started.set()
|
||||
self.stop.wait(extra)
|
||||
finally:
|
||||
self.finished.set()
|
||||
return result
|
||||
|
||||
return _sync
|
||||
|
||||
def slow_then_raises(self, extra: float = 0.5):
|
||||
def _sync():
|
||||
try:
|
||||
self.started.set()
|
||||
self.stop.wait(extra)
|
||||
raise ValueError("died mid-write")
|
||||
finally:
|
||||
self.finished.set()
|
||||
|
||||
return _sync
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runaways():
|
||||
"""Factory for runaway calls; every one is stopped and joined on teardown."""
|
||||
made: list[_Runaway] = []
|
||||
|
||||
def make() -> _Runaway:
|
||||
r = _Runaway()
|
||||
made.append(r)
|
||||
return r
|
||||
|
||||
yield make
|
||||
|
||||
for r in made:
|
||||
r.stop.set()
|
||||
for r in made:
|
||||
if r.started.is_set():
|
||||
assert r.finished.wait(15), "a runaway worker outlived its test"
|
||||
|
||||
|
||||
async def _abandon(backend, monkeypatch, func, *, timeout: float = 0.3) -> RuntimeError:
|
||||
"""Run ``func`` past a short deadline and return the deadline error."""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", timeout)
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend._async(func)
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 30)
|
||||
return excinfo.value
|
||||
|
||||
|
||||
# ── the quarantine ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_timed_out_write_quarantines_the_document(backend, monkeypatch, runaways):
|
||||
"""RED: this returned handle 'A1' off a modelspace another thread was growing."""
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
assert r.started.is_set()
|
||||
|
||||
with pytest.raises(DocumentQuarantineError) as refusal:
|
||||
await backend.entity_create_circle(0, 0, 5)
|
||||
|
||||
payload = refusal.value.to_dict()
|
||||
assert payload["ok"] is False
|
||||
assert payload["kind"] == "quarantined"
|
||||
assert payload["quarantine"]["call"] == "writer"
|
||||
assert payload["quarantine"]["thread_alive"] is True
|
||||
assert "drawing_open" in payload["error"], "a refusal that names no way out is not help"
|
||||
assert r.written > 0, "the abandoned thread really was mutating the document"
|
||||
|
||||
|
||||
async def test_reads_are_refused_too(backend, monkeypatch, runaways):
|
||||
"""RED: drawing_info reported entity_count=116 for a space at 302 and climbing."""
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.drawing_info()
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.entity_list()
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.analysis_stats()
|
||||
|
||||
|
||||
async def test_a_read_does_not_cascade_a_second_abandonment(backend, monkeypatch, runaways, caplog):
|
||||
"""RED: a read on the growing document blew its own deadline and abandoned again.
|
||||
|
||||
Measured at 2.03s against a 2.0s deadline, with a second lock swap. Reads on
|
||||
a container another thread appends to do not tear loudly — they never finish.
|
||||
"""
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
quarantined_lock = backend._lock
|
||||
|
||||
caplog.clear() # the first abandonment is expected; a second one is the defect
|
||||
with caplog.at_level(logging.ERROR, logger="backends.ezdxf_backend"):
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.drawing_info()
|
||||
|
||||
assert backend._lock is quarantined_lock, "a refusal must not swap the lock again"
|
||||
assert not [rec for rec in caplog.records if "abandoning the worker" in rec.getMessage()]
|
||||
assert len(backend._abandoned_calls) == 1
|
||||
|
||||
|
||||
async def test_save_is_refused_while_quarantined(backend, monkeypatch, runaways, tmp_path):
|
||||
"""RED: this wrote a valid DXF of 15200 of 60000 entities and said {'ok': True}."""
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
|
||||
target = tmp_path / "torn.dxf"
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.drawing_save(str(target))
|
||||
assert not target.exists(), "a refusal must not leave a torn document on disk"
|
||||
|
||||
|
||||
# ── the ways back ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_drawing_open_is_the_way_back(backend, monkeypatch, runaways, tmp_path):
|
||||
good = tmp_path / "known_good.dxf"
|
||||
doc = ezdxf.new("R2010")
|
||||
for i in range(7):
|
||||
doc.modelspace().add_line((0, i), (1, i))
|
||||
doc.saveas(str(good))
|
||||
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
|
||||
# Reopen WHILE the runaway is still writing: that is the whole point.
|
||||
assert not r.finished.is_set()
|
||||
result = await backend.drawing_open(str(good))
|
||||
assert result["ok"] is True
|
||||
assert result["quarantine_cleared"]["call"] == "writer"
|
||||
assert backend._quarantine is None
|
||||
|
||||
written_before = r.written
|
||||
info = await backend.drawing_info()
|
||||
assert info.entity_count == 7
|
||||
|
||||
# Let the runaway write more, then prove none of it landed here.
|
||||
while r.written < written_before + 20 and not r.finished.is_set():
|
||||
await asyncio.sleep(0.01)
|
||||
assert (await backend.drawing_info()).entity_count == 7
|
||||
assert (await backend.entity_create_circle(0, 0, 5)).handle
|
||||
|
||||
|
||||
async def test_drawing_new_and_drawing_close_also_clear_it(backend, monkeypatch, runaways):
|
||||
first = runaways()
|
||||
await _abandon(backend, monkeypatch, first.writer(backend))
|
||||
assert (await backend.drawing_new())["quarantine_cleared"]["call"] == "writer"
|
||||
assert backend._quarantine is None
|
||||
assert (await backend.entity_create_line(0, 0, 1, 1)).handle
|
||||
|
||||
second = runaways()
|
||||
await _abandon(backend, monkeypatch, second.writer(backend))
|
||||
closed = await backend.drawing_close(save=True)
|
||||
assert closed["ok"] is True
|
||||
assert closed["saved"] is False, "a quarantined document must not be saved on the way out"
|
||||
assert backend._quarantine is None
|
||||
await backend.drawing_new()
|
||||
assert (await backend.entity_create_line(0, 0, 1, 1)).handle
|
||||
|
||||
|
||||
async def test_undo_and_rollback_are_not_a_way_back(backend, monkeypatch, runaways):
|
||||
"""They rebind ``self._doc`` too, but from a snapshot that may itself be torn.
|
||||
|
||||
``_snapshot_doc`` does ``self._doc.saveas(tmp)`` on the LIVE document, so any
|
||||
snapshot pushed during the runaway window was serialised mid-mutation.
|
||||
"""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 5)
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.drawing_undo()
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.drawing_redo()
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.transaction_rollback()
|
||||
assert backend._quarantine is not None
|
||||
|
||||
|
||||
# ── the free exit ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_slow_but_complete_call_lifts_the_quarantine(backend, monkeypatch, runaways):
|
||||
"""The common real trigger is a slow drawing_audit, not a hang.
|
||||
|
||||
If the ``_sync`` body ran to completion the document holds exactly the state
|
||||
that call intended — and everybody else was refused in the meantime, which
|
||||
is the only reason that inference is sound.
|
||||
"""
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.slow_but_complete(0.5))
|
||||
assert backend._quarantine is not None
|
||||
assert r.finished.wait(5)
|
||||
|
||||
circle = await backend.entity_create_circle(0, 0, 5)
|
||||
assert circle.handle, "no reopen should be needed for a call that simply ran long"
|
||||
|
||||
status = await backend.system_status()
|
||||
assert status["quarantine"] is None
|
||||
(record,) = status["abandoned_calls"]
|
||||
assert record["outcome"] == "completed_after_deadline"
|
||||
assert record["result_lost"] is True, "the caller never received the return value"
|
||||
assert "value nobody received" in record["lost_return_value"]
|
||||
|
||||
|
||||
async def test_a_call_that_raises_after_the_deadline_stays_quarantined(
|
||||
backend, monkeypatch, runaways
|
||||
):
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.slow_then_raises(0.5))
|
||||
assert r.finished.wait(5)
|
||||
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.entity_create_circle(0, 0, 5)
|
||||
status = await backend.system_status()
|
||||
assert status["quarantine"]["outcome"] == "failed_after_deadline"
|
||||
assert "died mid-write" in status["quarantine"]["detail"]
|
||||
|
||||
|
||||
# ── the diagnostic channel ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_system_status_reports_the_quarantine_and_is_never_itself_refused(
|
||||
backend, monkeypatch, runaways
|
||||
):
|
||||
"""Otherwise the quarantine's first act is to close the only way to ask why."""
|
||||
r = runaways()
|
||||
await _abandon(backend, monkeypatch, r.writer(backend))
|
||||
|
||||
status = await backend.system_status()
|
||||
record = status["quarantine"]
|
||||
assert record["call"] == "writer"
|
||||
assert record["timeout"] == pytest.approx(0.3)
|
||||
assert record["age_seconds"] >= 0
|
||||
assert record["thread_alive"] is True
|
||||
assert record["outcome"] == "running"
|
||||
|
||||
|
||||
# ── the message ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_the_timeout_message_names_the_real_call(backend, monkeypatch, runaways, tmp_path):
|
||||
"""RED: every real backend method passes a closure literally named ``_sync``.
|
||||
|
||||
Measured: ``__name__='_sync'``, ``__qualname__='EzdxfBackend.drawing_save.
|
||||
<locals>._sync'`` — so in production the error always read
|
||||
``ezdxf call '_sync' timed out``. The qualname carries the name the user
|
||||
typed and it was being discarded.
|
||||
"""
|
||||
r = runaways()
|
||||
|
||||
def _slow_saveas(path, *args, **kwargs):
|
||||
r.started.set()
|
||||
try:
|
||||
r.stop.wait(5)
|
||||
finally:
|
||||
r.finished.set()
|
||||
|
||||
monkeypatch.setattr(backend._doc, "saveas", _slow_saveas)
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.3)
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await backend.drawing_save(str(tmp_path / "part.dxf"))
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "drawing_save" in message
|
||||
assert "_sync" not in message
|
||||
assert "drawing_open" in message, "the message must name the way back"
|
||||
assert backend._quarantine.call == "drawing_save"
|
||||
|
||||
|
||||
async def test_real_call_name_falls_back_when_there_is_no_enclosing_method():
|
||||
assert real_call_name(len) == "len"
|
||||
assert real_call_name(object()) == "call"
|
||||
|
||||
|
||||
# ── it is not a capability refusal ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_the_refusal_is_not_reported_as_a_capability():
|
||||
"""``_batch_capability_of`` classifies structurally, so a borrowed
|
||||
``.capability`` here would tell the client the ezdxf engine cannot draw a
|
||||
circle. The engine is fine; the document is not."""
|
||||
record = QuarantineRecord("drawing_save", 120.0, _FinishedWatcher())
|
||||
exc = DocumentQuarantineError(record, "quarantined")
|
||||
|
||||
assert not hasattr(exc, "capability")
|
||||
assert server._batch_capability_of(exc) is None
|
||||
assert server._classify_batch_error(exc)["kind"] == "quarantined"
|
||||
|
||||
|
||||
class _FinishedWatcher:
|
||||
finished = True
|
||||
result = None
|
||||
error = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(monkeypatch):
|
||||
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 test_the_wire_payload_carries_kind_quarantined(client, monkeypatch):
|
||||
record = QuarantineRecord("drawing_audit", 120.0, _FinishedWatcher())
|
||||
|
||||
async def _refuse(self, *args, **kwargs):
|
||||
raise DocumentQuarantineError(record, "document quarantined; use drawing_open()")
|
||||
|
||||
monkeypatch.setattr(EzdxfBackend, "entity_create_circle", _refuse)
|
||||
|
||||
result = await client.call_tool(
|
||||
"entity_create_circle", {"cx": 0, "cy": 0, "radius": 5}, raise_on_error=False
|
||||
)
|
||||
assert result.is_error is True
|
||||
payload = result.structured_content
|
||||
assert payload["kind"] == "quarantined"
|
||||
assert "capability" not in payload
|
||||
assert payload["quarantine"]["call"] == "drawing_audit"
|
||||
assert payload["tool"] == "entity_create_circle"
|
||||
assert payload["backend"] == "ezdxf"
|
||||
@@ -179,3 +179,70 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog):
|
||||
finally:
|
||||
cfg.settings.allow_remote_http = old_remote
|
||||
cfg.settings.mcp_auth_token = old_token
|
||||
|
||||
|
||||
# ── the guard must read the host that will actually be bound ────────────────
|
||||
#
|
||||
# `_guarded_run_async` exists to cover the `fastmcp run server.py:mcp
|
||||
# --transport http` path, which the module docstring and CLAUDE.md both
|
||||
# advertise. It inferred the bind address from the `host` kwarg and treated an
|
||||
# absent one as loopback -- but fastmcp only forwards that kwarg when `--host`
|
||||
# was passed, and otherwise resolves the host from `fastmcp.settings.host`,
|
||||
# which is environment-backed (`FASTMCP_HOST`). So `FASTMCP_HOST=0.0.0.0`
|
||||
# bound every interface while the guard inspected the string "127.0.0.1" and
|
||||
# waved it through: the check failed OPEN, with no token required, on the one
|
||||
# path it was written for.
|
||||
|
||||
|
||||
def _guard_probe(monkeypatch, *, fastmcp_host, allow_remote=False, token=""):
|
||||
"""Drive `mcp.run_async` without starting a server."""
|
||||
sys.modules.pop("server", None)
|
||||
import fastmcp
|
||||
|
||||
import config as cfg
|
||||
import server as srv
|
||||
|
||||
monkeypatch.setattr(fastmcp.settings, "host", fastmcp_host, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "allow_remote_http", allow_remote, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "mcp_auth_token", token, raising=False)
|
||||
|
||||
async def _never_runs(*args, **kwargs):
|
||||
raise AssertionError("the server started despite the guard")
|
||||
|
||||
monkeypatch.setattr(srv, "_orig_run_async", _never_runs, raising=False)
|
||||
return srv
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_omitted_host_is_resolved_not_assumed_to_be_loopback(monkeypatch):
|
||||
"""FASTMCP_HOST=0.0.0.0 with no --host must still be refused."""
|
||||
srv = _guard_probe(monkeypatch, fastmcp_host="0.0.0.0")
|
||||
|
||||
with pytest.raises(SystemExit, match="Refusing"):
|
||||
await srv.mcp.run_async(transport="http")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_omitted_host_on_a_loopback_default_still_starts(monkeypatch):
|
||||
"""The fix must not refuse the ordinary case."""
|
||||
srv = _guard_probe(monkeypatch, fastmcp_host="127.0.0.1")
|
||||
|
||||
with pytest.raises(AssertionError, match="the server started"):
|
||||
await srv.mcp.run_async(transport="http")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_explicit_host_still_wins_over_the_setting(monkeypatch):
|
||||
"""An explicit loopback --host must not be overridden by the env default."""
|
||||
srv = _guard_probe(monkeypatch, fastmcp_host="0.0.0.0")
|
||||
|
||||
with pytest.raises(AssertionError, match="the server started"):
|
||||
await srv.mcp.run_async(transport="http", host="127.0.0.1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_never_consults_the_http_guard(monkeypatch):
|
||||
srv = _guard_probe(monkeypatch, fastmcp_host="0.0.0.0")
|
||||
|
||||
with pytest.raises(AssertionError, match="the server started"):
|
||||
await srv.mcp.run_async(transport="stdio")
|
||||
|
||||
@@ -198,3 +198,39 @@ def test_two_straight_vertices_still_enclose_nothing():
|
||||
|
||||
def test_a_single_vertex_is_still_degenerate():
|
||||
assert polygon_area_perimeter([(0.0, 0.0, 1.0)], closed=True) == (0.0, 0.0)
|
||||
|
||||
|
||||
# ── a closed loop must answer the same however it was handed over ───────────
|
||||
|
||||
|
||||
def test_a_repeated_closing_vertex_is_not_a_self_intersection():
|
||||
"""The false alarm this shipped with.
|
||||
|
||||
`ezdxf.path.flattening()` repeats the start point, so every hatch boundary
|
||||
and every flattened curve arrived here with a duplicate closing vertex. The
|
||||
adjacency guard excludes neighbours *by index*, so the first and last real
|
||||
edges — which share a point but not an index neighbourhood — were
|
||||
cross-tested and reported as crossing. A plain 40x40 square hatch came back
|
||||
`self_intersecting: True`: a confident wrong warning on the field that
|
||||
exists to warn.
|
||||
"""
|
||||
square = [(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (0.0, 50.0)]
|
||||
|
||||
assert is_self_intersecting(square) is False
|
||||
assert is_self_intersecting([*square, (0.0, 0.0)]) is False
|
||||
|
||||
|
||||
def test_a_bowtie_is_still_caught_when_it_carries_a_closing_vertex():
|
||||
"""Dropping the duplicate must not disarm the check."""
|
||||
bowtie = [(0.0, 0.0), (50.0, 50.0), (50.0, 0.0), (0.0, 50.0)]
|
||||
|
||||
assert is_self_intersecting(bowtie) is True
|
||||
assert is_self_intersecting([*bowtie, (0.0, 0.0)]) is True
|
||||
|
||||
|
||||
def test_a_triangle_with_a_closing_vertex_is_still_not_degenerate():
|
||||
"""Four entries, three real corners — must not fall under the `< 4` guard
|
||||
and answer False for the wrong reason."""
|
||||
triangle = [(0.0, 0.0), (60.0, 0.0), (30.0, 40.0), (0.0, 0.0)]
|
||||
|
||||
assert is_self_intersecting(triangle) is False
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""A quarantined document must not be able to pass the quality gate.
|
||||
|
||||
`backends/quarantine.py` refuses every call routed through `_async` once a
|
||||
timed-out write has been abandoned on the document. But `engineering/critique.py`
|
||||
wraps each check in a bare ``except Exception`` and degrades to ``[]`` — so
|
||||
`DocumentQuarantineError` was swallowed and `drawing_critique` returned *zero
|
||||
issues* on a document the backend had declared untrusted.
|
||||
|
||||
CLAUDE.md premium rule 7 makes "drawing_critique must return zero issues before
|
||||
drawing_finalize" the gate an agent is told to trust. A gate that reports clean
|
||||
because it could not look is worse than no gate: it is the same silent-success
|
||||
failure the whole quarantine exists to stop, one layer up.
|
||||
|
||||
The same swallow sat in `construction_clear` (step 9 of the standard workflow),
|
||||
which answered ``{"ok": true, "deleted": 0}`` — "there was nothing to clear" —
|
||||
on a document it could not read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from backends.quarantine import DocumentQuarantineError
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _quarantine(backend, monkeypatch):
|
||||
"""Abandon a real write, the way a real timeout does.
|
||||
|
||||
Hand-building a QuarantineRecord would test the gate against a fixture
|
||||
rather than against the state the backend actually reaches.
|
||||
"""
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def _writer():
|
||||
entered.set()
|
||||
release.wait(5.0)
|
||||
return "too late"
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await backend._async(_writer)
|
||||
assert entered.is_set()
|
||||
assert backend._quarantine is not None
|
||||
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10)
|
||||
return release
|
||||
|
||||
|
||||
async def test_a_geometric_focus_refuses_rather_than_reporting_clean(backend, monkeypatch):
|
||||
from engineering.critique import run_critique
|
||||
|
||||
await backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
|
||||
release = await _quarantine(backend, monkeypatch)
|
||||
|
||||
try:
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await run_critique(backend, focus=["duplicate_entities"])
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
async def test_the_full_critique_refuses_too(backend, monkeypatch):
|
||||
from engineering.critique import run_critique
|
||||
|
||||
release = await _quarantine(backend, monkeypatch)
|
||||
|
||||
try:
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await run_critique(backend, focus=None)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
async def test_the_legibility_check_does_not_read_the_document_under_quarantine(
|
||||
backend, monkeypatch
|
||||
):
|
||||
"""It reads `backend._doc` directly, off the event loop thread, with no
|
||||
lock — the exact unsynchronised access the quarantine exists to prevent."""
|
||||
from engineering.critique import _check_dimension_legibility
|
||||
|
||||
release = await _quarantine(backend, monkeypatch)
|
||||
|
||||
try:
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
_check_dimension_legibility(backend)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
async def test_construction_clear_does_not_report_nothing_to_clear(backend, monkeypatch):
|
||||
await backend.entity_create_line(0, 0, 10, 0, layer="CONSTRUCTION")
|
||||
release = await _quarantine(backend, monkeypatch)
|
||||
|
||||
try:
|
||||
with pytest.raises(DocumentQuarantineError):
|
||||
await backend.construction_clear()
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
async def test_an_ordinary_failure_is_still_degraded_not_raised(backend):
|
||||
"""The bare excepts exist so one broken check cannot fail the whole pass.
|
||||
|
||||
Only the quarantine escapes them; everything else keeps degrading.
|
||||
"""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
async def _boom(*_a, **_k):
|
||||
raise RuntimeError("layer_list is having a bad day")
|
||||
|
||||
backend.layer_list = _boom
|
||||
|
||||
issues = await run_critique(backend, focus=["layer_color"])
|
||||
|
||||
assert issues == []
|
||||
@@ -29,6 +29,7 @@ from __future__ import annotations
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
@@ -245,3 +246,90 @@ def test_dockerfile_copies_every_wheel_include() -> None:
|
||||
includes = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]["only-include"]
|
||||
missing = [name for name in includes if name not in copy_lines]
|
||||
assert not missing, f"Dockerfile COPY misses wheel-shipped paths: {missing}"
|
||||
|
||||
|
||||
def test_windows_only_dependencies_carry_a_platform_marker() -> None:
|
||||
"""`pip install autocad-mcp-pro[full]` must resolve on Linux and macOS.
|
||||
|
||||
pywin32 publishes Windows wheels and **no sdist**, so an unmarked
|
||||
`pywin32>=306` in an extra makes that extra uninstallable everywhere else:
|
||||
pip fails at resolution with "no matching distribution" before it reaches
|
||||
anything else. `[full]` is the extra this project's own README recommends
|
||||
to contributors, and CI never caught it because every Linux lane installs
|
||||
`[pdf]` or uses uv.lock, whose resolution markers paper over the gap.
|
||||
"""
|
||||
extras = _pyproject()["project"]["optional-dependencies"]
|
||||
unmarked: list[str] = []
|
||||
for extra, requirements in extras.items():
|
||||
for requirement in requirements:
|
||||
name = re.split(r"[<>=!~\[; ]", requirement, maxsplit=1)[0].strip().lower()
|
||||
if name in {"pywin32"} and "sys_platform" not in requirement:
|
||||
unmarked.append(f"{extra}: {requirement}")
|
||||
assert not unmarked, (
|
||||
'Windows-only distributions need `; sys_platform == "win32"` or the '
|
||||
f"extra cannot be installed off Windows: {unmarked}"
|
||||
)
|
||||
|
||||
|
||||
def test_document_numbers_match_the_tree() -> None:
|
||||
"""Every figure the docs quote, recomputed from its source of truth.
|
||||
|
||||
The A/B suite grew 24 -> 26 and three files kept saying 24: `README.md`
|
||||
(fixed by hand), `benchmarks/README.md` and `CLAUDE.md` (not). Each was
|
||||
edited on its own and nobody re-read the other two, so the repository
|
||||
shipped three different answers to one question. That is a mechanical
|
||||
failure and it deserves a mechanical gate rather than another careful
|
||||
reader.
|
||||
|
||||
`scripts/check_doc_numbers.py` also fails when a pattern stops matching, so
|
||||
rewording the sentence around a number cannot silently disarm the check.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts" / "check_doc_numbers.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=ROOT,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stdout + completed.stderr
|
||||
|
||||
|
||||
def test_pre_commit_ruff_matches_the_project_pin() -> None:
|
||||
"""A pre-commit hook that formats differently from CI is a trap, armed.
|
||||
|
||||
`.pre-commit-config.yaml` sat at ruff v0.8.6 while `[dependency-groups] dev`
|
||||
pinned 0.16.1, and 0.16 formats Python inside Markdown where 0.8 does not.
|
||||
Anyone who ran `pre-commit install` therefore produced commits that failed
|
||||
CI's own `ruff format --check .` -- the same class of failure the ruff pin
|
||||
was introduced to end.
|
||||
"""
|
||||
config = (ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")
|
||||
hook = re.search(r"ruff-pre-commit\s*\n\s*rev:\s*v?([\d.]+)", config)
|
||||
assert hook, ".pre-commit-config.yaml no longer pins ruff-pre-commit"
|
||||
|
||||
dev_group = _pyproject()["dependency-groups"]["dev"]
|
||||
pinned = next((d for d in dev_group if d.startswith("ruff==")), None)
|
||||
assert pinned, "pyproject no longer pins ruff in [dependency-groups] dev"
|
||||
|
||||
assert hook.group(1) == pinned.split("==", 1)[1], (
|
||||
f"pre-commit runs ruff {hook.group(1)} but the project pins {pinned} — "
|
||||
"a contributor's hook would format differently from CI"
|
||||
)
|
||||
|
||||
|
||||
def test_the_release_gate_covers_the_platforms_ci_covers() -> None:
|
||||
"""`release.yml` publishes to PyPI; a lane CI has and it lacks is a hole.
|
||||
|
||||
Its test job was one ubuntu/3.11 run, so a Windows-only defect could reach
|
||||
PyPI with no Windows job having executed. v1.5.0 shipped exactly such a
|
||||
defect (`except pywintypes.com_error` -> NameError without pywin32).
|
||||
"""
|
||||
release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
||||
gate = release.split("build:", 1)[0]
|
||||
|
||||
assert "windows-latest" in gate, "the release test gate never runs on Windows"
|
||||
assert "3.12" in gate, "the release test gate never runs on Python 3.12"
|
||||
assert "uv sync --locked" in gate, "the release gate must install from the lockfile"
|
||||
|
||||
@@ -544,3 +544,47 @@ async def test_the_filter_count_matches_the_handles(backend):
|
||||
result = await backend.selection_filter(layer="GEOMETRY")
|
||||
|
||||
assert result["count"] == len(result["handles"]) == 2
|
||||
|
||||
|
||||
# ── min_area must agree with the measurement tool ───────────────────────────
|
||||
|
||||
|
||||
async def test_min_area_sees_a_hatch_at_the_area_entity_measure_reports(backend):
|
||||
"""`_area` read `entity.dxf.get("area", 0.0)` for SOLID/TRACE/HATCH.
|
||||
|
||||
Those types carry no `area` DXF attribute, so the lookup always fell to the
|
||||
default and every hatch measured 0.0 — excluded by any positive threshold,
|
||||
while `filtered_by` asserted the filter had run. Its own docstring says
|
||||
None means "this shape has no area", not "its area is zero", and the live
|
||||
COM path reads ActiveX `.Area` correctly, so the two engines returned
|
||||
different selections for the same call.
|
||||
"""
|
||||
hatch = await backend.entity_create_hatch(
|
||||
"SOLID", [[0, 0], [40, 0], [40, 40], [0, 40]], layer="HATCH"
|
||||
)
|
||||
measured = await backend.entity_measure(hatch.handle)
|
||||
assert measured["area"] == pytest.approx(1600.0)
|
||||
|
||||
kept = await backend.selection_filter(entity_type="HATCH", min_area=1.0)
|
||||
|
||||
assert kept["handles"] == [hatch.handle], "a 1600 mm^2 hatch must survive a 1 mm^2 threshold"
|
||||
|
||||
|
||||
async def test_min_area_excludes_a_hatch_that_is_genuinely_too_small(backend):
|
||||
"""The fix must not simply admit everything."""
|
||||
await backend.entity_create_hatch("SOLID", [[0, 0], [2, 0], [2, 2], [0, 2]], layer="HATCH")
|
||||
|
||||
kept = await backend.selection_filter(entity_type="HATCH", min_area=100.0)
|
||||
|
||||
assert kept["handles"] == []
|
||||
|
||||
|
||||
async def test_min_area_and_entity_measure_cannot_disagree_on_a_solid(backend):
|
||||
solid = backend._doc.modelspace().add_solid([(0, 0), (10, 0), (0, 10), (10, 10)])
|
||||
measured = await backend.entity_measure(solid.dxf.handle)
|
||||
|
||||
just_under = await backend.selection_filter(min_area=measured["area"] - 0.5)
|
||||
just_over = await backend.selection_filter(min_area=measured["area"] + 0.5)
|
||||
|
||||
assert solid.dxf.handle in just_under["handles"]
|
||||
assert solid.dxf.handle not in just_over["handles"]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""A setting the renderer cannot use must be refused, not reported applied.
|
||||
|
||||
Folding the DIM* header variables into the per-dimension override closed a
|
||||
silent no-op and opened a louder failure: values that used to die harmlessly in
|
||||
the header now reach ezdxf's formatter. Measured before this guard existed:
|
||||
|
||||
* ``dim_decimals=-1`` returned ``ok: True`` and the *next* dimension of four of
|
||||
the five kinds died with ``ValueError: Format specifier missing precision``,
|
||||
from inside the renderer, nowhere near the call that caused it.
|
||||
* ``dim_decimals=40`` returned ``ok: True`` and printed
|
||||
``33.3329999999999984083842718973755836486816`` onto the drawing.
|
||||
* ``dim_text_height=-3.0`` returned ``ok: True`` and changed nothing.
|
||||
|
||||
AutoCAD's own ranges are the reference: DIMDEC 0-8, DIMZIN a 0-15 bitmask, and
|
||||
the two lengths strictly positive. `applied` has to mean applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 9, 40])
|
||||
async def test_out_of_range_dim_decimals_is_refused(backend, value):
|
||||
result = await backend.drawing_settings({"dim_decimals": value})
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "dim_decimals" in result["errors"]
|
||||
assert "dim_decimals" not in result.get("applied", {})
|
||||
|
||||
|
||||
async def test_a_refused_dim_decimals_cannot_break_the_next_dimension(backend):
|
||||
"""The crash was the point: the bad value reached the formatter."""
|
||||
await backend.drawing_settings({"dim_decimals": -1})
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
|
||||
assert dim.handle
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 2, 8])
|
||||
async def test_dim_decimals_in_range_is_still_accepted(backend, value):
|
||||
result = await backend.drawing_settings({"dim_decimals": value})
|
||||
|
||||
assert result["ok"] is True, result.get("errors")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["dim_text_height", "dim_arrow_size"])
|
||||
@pytest.mark.parametrize("value", [0.0, -3.0])
|
||||
async def test_a_non_positive_length_is_refused(backend, key, value):
|
||||
result = await backend.drawing_settings({key: value})
|
||||
|
||||
assert result["ok"] is False
|
||||
assert key in result["errors"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [-1, 16, 999])
|
||||
async def test_zero_suppression_outside_the_bitmask_is_refused(backend, value):
|
||||
result = await backend.drawing_settings({"zero_suppression": value})
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "zero_suppression" in result["errors"]
|
||||
|
||||
|
||||
async def test_one_bad_key_does_not_discard_the_good_ones(backend):
|
||||
result = await backend.drawing_settings({"dim_text_height": 5.0, "dim_decimals": -1})
|
||||
|
||||
assert result["applied"]["dim_text_height"] == 5.0
|
||||
assert "dim_decimals" in result["errors"]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""SendCommand takes a whole macro, so a name interpolated into one is code.
|
||||
|
||||
`security.py` recorded the policy as "`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." That premise was false.
|
||||
|
||||
`_ensure_linetype_loaded` builds ``_-LINETYPE _LOAD {name} {lin_file}`` and
|
||||
hands it to `doc.SendCommand`, and it is reached from the ``linetype=``
|
||||
argument of every COM entity-creation and layer tool — plus
|
||||
`ComBackend.linetype_load`, which interpolates a caller-supplied *file path*
|
||||
as well. SendCommand treats a newline and ``;`` as segment separators, so a
|
||||
linetype name carrying either injects further AutoCAD commands, and the 36-verb
|
||||
denylist and `DANGEROUS_COMMANDS_ENABLED` never see that channel at all.
|
||||
|
||||
The guard here is the DXF symbol-name rule rather than a bespoke blocklist:
|
||||
AutoCAD already forbids these characters in a table name, so anything this
|
||||
rejects was never a loadable linetype, and the injection characters fall out of
|
||||
the rule rather than having to be enumerated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from security import sanitize_symbol_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"CENTER",
|
||||
"HIDDEN2",
|
||||
"DASHDOT",
|
||||
"ACAD_ISO02W100",
|
||||
"My-Linetype_01",
|
||||
],
|
||||
)
|
||||
def test_ordinary_linetype_names_pass(name):
|
||||
assert sanitize_symbol_name(name, kind="linetype") == name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
"CENTER\n_ERASE ALL\n", # newline ends the macro segment
|
||||
"CENTER;_ERASE ALL", # so does a semicolon
|
||||
"CENTER\r_QUIT",
|
||||
'CENTER"',
|
||||
"CENTER|X",
|
||||
"CENTER<X",
|
||||
"CENTER>X",
|
||||
"CENTER?X",
|
||||
"CENTER*X",
|
||||
"CENTER,X",
|
||||
"CENTER=X",
|
||||
"CENTER:X",
|
||||
"CENTER/X",
|
||||
"CENTER" + chr(92) + "X",
|
||||
],
|
||||
)
|
||||
def test_a_name_that_could_end_the_macro_segment_is_refused(payload):
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
sanitize_symbol_name(payload, kind="linetype")
|
||||
|
||||
assert "linetype" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_an_empty_name_is_refused():
|
||||
with pytest.raises(ToolError):
|
||||
sanitize_symbol_name(" ", kind="linetype")
|
||||
|
||||
|
||||
def test_the_refusal_does_not_echo_the_payload_back_verbatim():
|
||||
"""An error message is a channel too."""
|
||||
with pytest.raises(ToolError) as excinfo:
|
||||
sanitize_symbol_name("CENTER\n_ERASE ALL\n", kind="linetype")
|
||||
|
||||
assert "\n_ERASE" not in str(excinfo.value)
|
||||
|
||||
|
||||
def test_the_com_linetype_loader_refuses_before_it_reaches_sendcommand():
|
||||
"""The guard has to sit in front of the macro, not inside AutoCAD."""
|
||||
import backends.com_backend as cb
|
||||
|
||||
with pytest.raises(ToolError):
|
||||
cb._ensure_linetype_loaded("CENTER\n_ERASE ALL\n")
|
||||
@@ -67,7 +67,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "autocad-mcp-pro"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ezdxf" },
|
||||
@@ -78,12 +78,12 @@ dependencies = [
|
||||
[package.optional-dependencies]
|
||||
com = [
|
||||
{ name = "pillow" },
|
||||
{ name = "pywin32" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
full = [
|
||||
{ name = "matplotlib" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pywin32" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
pdf = [
|
||||
{ name = "matplotlib" },
|
||||
@@ -106,8 +106,8 @@ requires-dist = [
|
||||
{ name = "pillow", marker = "extra == 'com'", specifier = ">=10.0" },
|
||||
{ name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0,<3" },
|
||||
{ name = "pywin32", marker = "extra == 'com'", specifier = ">=306" },
|
||||
{ name = "pywin32", marker = "extra == 'full'", specifier = ">=306" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32' and extra == 'com'", specifier = ">=306" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32' and extra == 'full'", specifier = ">=306" },
|
||||
]
|
||||
provides-extras = ["com", "pdf", "full"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user