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