258 Commits
Author SHA1 Message Date
anatoly d4fd6d2310 docs: INSTALL.ru.md — наша эксплуатация, источники версий и порядок обновления
CI / lint (push) Canceled after 0s
CI / test-linux (3.11) (push) Canceled after 0s
CI / test-linux (3.12) (push) Canceled after 0s
CI / test-windows (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / docker (push) Canceled after 0s
CI / registry-validate (push) Canceled after 0s
2026-10-05 17:54:33 +03:00
Umut CANandClaude Opus 5.5 cdb1063896 fix(server): stop stacking a tool profile's visibility on every session
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
What the first Linux CI run of the 1.6 tree found.

- fastmcp 3's enable()/disable() append a Visibility transform per call,
  and a tool lookup walks the transform stack as nested calls. The
  lifespan re-applies the tool profile on every client connection, so an
  in-process server grew two layers per session until every tool call,
  system_status included, raised RecursionError (CPython 3.11 counts C
  frames against the limit). The profile's transforms now replace the
  previous ones, as _apply_discovery_mode already did; a test switches
  the profile 600 times and then calls a tool. The local suite runs in
  half the time it took with the stack growing.
- 21 fake-ActiveX tests build a real pywin32 VARIANT; they now skip
  without pywin32, like the suite's other VARIANT-dependent COM tests,
  and the Windows lane keeps running them.
- A test spelled a path through a directory it never created, which only
  Windows normalises away; it creates the directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-25 00:49:16 +03:00
Umut CANandClaude Opus 5.5 17bf3a39ce release: 1.6.0
The v1.6 roadmap in five tracks: 154 -> 247 registered tools in 28 groups.

Evidence re-measured for the release:
- correctness A/B against v1.5.1: 26/48 -> 48/48, 22 fixed, 0 regressed
  (ab-v1.5.1-vs-v1.6.0.json, replacing the -dev report);
- the reference adapter on matrix v7: 21/21 (autocad-mcp-pro.json, which
  was the v3 run of 1.5); the published-report test follows it to v7;
- the 100-point rubric re-scored per category, every withheld point named:
  93 (the competitor rows are their July snapshots, not re-reviewed);
- the perf lane as medians of five alternating runs, beside a v1.4.0
  comparison on one interpreter (perf-v1.4.0-vs-v1.6.0.json).

Two roadmap criteria are not green, and the changelog, the README and the
roadmap say so: criterion 5 is withdrawn (track D, Plant 3D, dropped) and
criterion 7 is not met (creation 1.25x / 1.38x v1.4.0's time, still
1.17x / 1.20x with the timeout off). Carried to 1.7.

README rewritten for 1.6: what is new, the mechanical and architectural
sheets, the rubric and task-matrix charts, the measured limits (including
the four feature tables that ship empty), and the 1.7 roadmap. server.json
describes the 1.6 surface and declares TOOL_PACKS. CLAUDE.md corrects the
ISO 286 letter set and the empty feature tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-25 00:17:17 +03:00
Umut CANandClaude Opus 5.5 7e7fe11a71 fix(arch, table): what the 1.6.0 README renders showed
- The exterior dimension chains follow the plot scale. DIMSCALE is the
  plan's scale while they are drawn and is put back afterwards; a 1:50
  chain's 2.5 text had been 2.5 drawing units, 0.05 mm on the sheet.
- A headless TABLE merges its title row, as AutoCAD's TABLE does; a long
  title no longer wraps inside the first column over the header row.
- A headless TABLE's cell margin scales with the lettering. A flat 1.0 was
  0.02 mm on a 1:50 schedule; tables at the default 2.5 text are unchanged.
- drawing_new writes the Standard text style in arial.ttf, as AutoCAD
  2026's acadiso.dwt ships it: txt has no superscript two, so 'm²' read
  'm?' live and rendered as a box headlessly. The five bundled templates
  are rebuilt and build_templates --check reproduces them.

Each fix has a test that fails on the old code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-25 00:17:10 +03:00
Umut CANandClaude Opus 5.5 554daff8d0 feat(understand): room frames, the nearest panel and possible packages — what the field test asked for
The takeoffs were run again on a real P&ID pair (kept outside the repository)
and compared with the hand-made workbooks. The user chose how three gaps
should close:

- Rooms drawn as frames. A room label that no wall face holds takes the
  smallest closed, straight-sided polyline around it, provided it holds no
  other room number and is at least ten label heights a side (label+frame,
  0.8). A P&ID draws its rooms this way, so P&ID lengths are now cut at the
  frames rather than sent to the nearest label. An unnumbered mention inside
  a frame ('hot room') does not deny it.
- The nearest panel, flagged. cable_takeoff(panel_rule="nearest") is the
  default: a load that no callout places takes the nearest panel on the
  layout, among names with a panel prefix (is_panel_name: CP, JB, DB,
  MCC ...) or names a callout gives. The row is flagged panel_inferred, and
  an inferred panel never makes a package. panel_rule="stated" keeps the old
  behaviour. The takeoff_roundtrip gate and the cable correctness check read
  stated panels only: a guess is no evidence.
- Possible packages and detail copies. pipe_takeoff reports detail_copies:
  compact groups of tags drawn twice (repeated), and compact groups of one
  equipment's own parts, such as M150B, M150C and M150D of M150 (family). A
  vendor package or an equipment detail shows up this way. Nothing is
  removed until exclude_regions names a region. The runs left out go to the
  control rows as excluded_region and to excluded_m. The workbook's method
  sheet names the regions.

Each change has a synthetic test that failed first. Suite: 4986 passed, 1
xfailed. The inventory and the document numbers match (README: 4987
collected).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 17:18:18 +03:00
Umut CANandClaude Opus 5.5 db5b4d3ef8 fix(understand): the bare Russian "to" before a panel is a wiring callout
A Russian P&ID often closes a load's text with the panel it is wired to:
'... кВт' on one line, then 'к CP1' ('to CP1') or 'к щиту CP-2' ('to
switchboard CP-2') on the next. wiring_target knew 'подключение к ...' but
not the bare preposition. On the field P&ID most loads therefore had no
panel and no measurable cable.

The preposition к / ко now counts when it is a whole word and the panel is
the next word, optionally after щиту / шкафу / панели. The к of кВт is
never read as the preposition. The new test failed before the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 16:10:24 +03:00
Umut CANandClaude Opus 5.5 77cb325467 docs(changelog): the field-test fixes of track H
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 15:57:50 +03:00
Umut CANandClaude Opus 5.5 ac32700ba8 fix(understand): what the field test found — P&ID lengths need no equipment, caret notation, numbered rooms
Run on a real P&ID pair (kept outside the repository), the pipe takeoff wrote
an empty table. Three causes, each fixed with a test that failed first:

- Measured on the P&ID, a run still needed equipment tags at both ends,
  which only routing on the layout needs. Few pipes on a field P&ID end on
  a tagged outline. On the P&ID each piece now goes to the room it lies in:
  cut at the room outlines when the P&ID has room faces, else to the
  nearest room label. Diameters are kept piece by piece. On the layout a
  takeoff that could not route every run now says how many, why, the drawn
  length left to the control rows, and that length_source='pid' with
  force=True measures on the P&ID instead.
- A TEXT stores its line breaks in caret notation ('6^JROOM'), which
  plain() left as it was, so a room label's number read as '6^J...'.
  plain() now decodes ^J, ^M, ^I and '^ ', leaving MTEXT stacks
  ('\S1^2;') alone.
- Labels that only mention a room were read as rooms: a control cabinet
  naming the room it serves, or a note pointing into a room. An unnumbered
  label that names equipment is no room. Without room faces, when the
  drawing numbers its rooms, only numbered labels take pieces.

On the field pair the takeoff now fills its table, and every room key is a
real room number. The comparison with the hand-made workbook stays with
the user's files. README collected tests: 4981. The suite passes (4980, 1
xfailed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 15:57:18 +03:00
Umut CANandClaude Opus 5.5 307ccc799f fix(understand): a network layer holds lines — valve, pump and annotation layers are no pipes
The field test ran the takeoffs on a real P&ID pair, kept outside the
repository. It showed that the default network layers took every layer the
vocabulary files under piping or electrical. That included the valve layers
(KLEPPEN, Valve_*), the pump layers (a "product pumps" layer reads as
product), and annotation layers such as pipe sizes, materials and valve
numbers. Symbol outlines and leader lines became pipe runs.

vocab.is_network_layer is now the one rule. A layer is a network layer when
it is piping or electrical at 0.9, its name names no equipment
(equipment_kind: valve, pump, tank, motor, panel, reducer), and it names no
annotation (sizes, materials, numbers, hatching, or an annotation word).
build_network's default layers, drawing_topology_check and the topo_*
focuses all use it, so they agree on which lines are the network.

Sixteen parametrized cases pin it, across symbol, annotation, pipe and cable
layers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 15:57:17 +03:00
Umut CANandClaude Opus 5.5 ba41428c88 bench: matrix v7 adds takeoff_roundtrip and understand_foreign; five understanding checks; the live smoke and the field-test script
The evidence for track H, in the shape tracks A, E, B + G and F used.

- benchmarks/tasks_v7.py: 21 tasks, DEFAULT_MATRIX = "v7"; v6 to v2 stay
  addressable. Both new tasks read the synthetic plant pair and nothing
  else. takeoff_roundtrip: the P&ID called schematic, every routable run's
  layout length equal to the rectilinear MST of its tags, sizes and services
  as drawn (a return drawn on the supply layer included), the cable metres
  rounded up after the allowance, an unstated power left empty.
  understand_foreign: inches over millimetres flagged, the stray entity found
  by handle, two plan copies separated, five layers classified. A test breaks
  each input and watches its gate fail by name.
- Five correctness checks, every number worked out by hand in its docstring;
  the published A/B report is re-run over 48 checks: 26 / 48 -> 48 / 48,
  zero regressed.
- scripts/smoke_understand_com.py reads the pair through the live engine -
  snapshots, the DWG route, both takeoffs and a diff - and closes every
  document it opens; the CHANGELOG records what it returned.
  scripts/field_test_takeoff.py runs the takeoffs on a user's own files,
  refuses to write inside the repository and prints no quantity by default.
- The README token table is remeasured; check_doc_numbers derives the task
  count from the v7 matrix; CLAUDE.md, both READMEs, the CHANGELOG and the
  spec status line say what shipped.

One reader defect found on the way: describe.tag_occurrences read a wiring
callout ("Wiring to CP-2") as a place its panel is written, so the scale
check called CP-2 ambiguous and read "Wiring to CP1" as a phantom tag CP1 -
10 tags and 45 pairs (13.3 % within 10 %) where the generator placed 11 and
55 (21.8 %). A callout is no longer a tag occurrence, the rule
network.tag_occurrences already kept; two tests failed before the fix.

The smoke works only in documents it opened from its own temporary folder,
known by full path; it attaches with GetActiveObject only (the backend's
Dispatch fallback would launch AutoCAD), checks in the same COM-thread call
that the active document is its own before each snapshot, the DWG save and
the move, closes its documents with Close(False) and re-activates the
document that was active when it started. No new ActiveX member: Documents,
Item, FullName, ActiveDocument, Activate and Close are the ones the backend
already calls. Measured on AutoCAD 2026 (25.1s): exit 0, model-space types
equal file / live / DWG, export 0.104 s, no document of the run left open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 15:10:37 +03:00
Umut CANandClaude Opus 5.5 835bc4b65e feat(understand): the plant pack, lean 65, five-language aliases, eight golden queries and the counts
The three track-H branches are merged; this is the integration that makes the
six tools one surface.

- TOOL_PACK_NAMES gains "plant" (pipe_takeoff, cable_takeoff); SECTION 26's
  four readers stay core. Lean gains drawing_understand and reaches 65 (55
  with TOOL_PACKS=core). The `understand` and `plant` tags rank after `arch`,
  so the sections file as `understanding` (4) and `plant` (2): 247 tools in
  28 groups.
- discovery/aliases.py folds spec section 12's vocabulary into the six records
  the branches wrote - metraj, metre cetveli, boru / kablo listesi, fark,
  karsilastir, revizyon farki, olcek, birim in both Turkish spellings, BOQ,
  takeoff, scale check, COMPARE - without writing a phrase twice. The Russian
  terms are data only: the BM25 tokenizer keeps [a-z0-9], and no golden case
  pretends otherwise.
- Eight golden tuning cases, four of them Turkish; the shared-command
  allowlist reconciled with what the merged records claim; the token-suite
  formula excludes the new pack; the inventory regenerated, the registry
  snapshot retaken and the README figures taken from check_doc_numbers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 14:51:29 +03:00
Umut CANandClaude Opus 5.5 8c1a6cb27c refactor(understand): one reading of units, plan copies, scale and rooms
Groups U and N were built side by side, so the takeoffs carried narrow
stand-ins for what group U reads properly. On main they read through U:

- describe.drawing_units is the one call every reader makes (kept records,
  labelled room faces as evidence); describe, scale_check and the takeoffs
  all use it. Without the room faces, a layout that declares inches over
  millimetres can tie three ways and fall to the header.
- layout_tags takes its plan copies from describe.prepare, room_regions its
  rooms from describe.find_rooms, and pipe_rows its scale from scale_check,
  per-room groups included. scale_stand_in and the takeoff's own unit bands
  and cluster grid are gone.
- test_understand_one_reading pins the agreement: one unit, one plan copy,
  one verdict and one set of rooms per drawing, across drawing_understand,
  drawing_scale_check and the two takeoffs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 14:45:27 +03:00
Umut CANandClaude Opus 5.5 c4759e696d fix(understand): the pipe network's tolerance follows the P&ID's unit, and a dropped stub is said
pipe_takeoff merged line ends closer than tol = 1.0 drawing units and
silently dropped any segment shorter than that. On a millimetre P&ID that is
1 mm. On a metre drawing it is a metre: ends a metre apart were merged and
every pipe under a metre disappeared from the totals, with no warning. The
review of the network engine found this.

- tol now defaults to 1 mm in the unit the P&ID's geometry implies: 1.0 on
  a millimetre drawing, 0.001 on a metre one. A tol the caller gives is
  used as given.
- build_network counts the segments that collapse to a point
  (stats.segments_below_tol), and pipe_rows turns a non-zero count into a
  warning naming the tolerance.

A new wire test builds a metre P&ID with a 0.6 m pipe and a 5 m pipe. Before
this change the takeoff said 5.0 m; now it says 5.6 m. The network test
checks that a sub-tolerance stub is counted. Registry, packs, aliases and
every track H suite: 472 passed. The inventory matches the live surface.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:51:10 +03:00
Umut CANandClaude Opus 5.5 fb2953c324 fix(understand): a line number is not an equipment tag
parse_tag started a token at the first letter, so '100-P-001', a line
number that begins with its size, read as the pump tag P-001. The review of
the network engine found this. On a P&ID the phantom tag can capture a
valve beside the line as equipment, or collide with the real pump P-001.

A token that is the tail of a word beginning with a digit is no longer a
tag. The same text still yields T101 in 'LINE 100-P-001 TO T101', and
P-001 alone is still a tag. The new test failed before this change; the
label, network, describe, scale, cable and plant-pair suites all pass
(160 tests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:48:56 +03:00
Umut CANandClaude Opus 5.5 27d44e909e merge: track H group N
The merge agent stopped this branch on an add/add conflict it had not been
told to expect: groups U and N each created tests/test_understand_tools.py.
Resolved by hand:
- tests/test_understand_tools.py stays U's (the drawing_understand wire
  tests), and N's takeoff wire tests move whole to
  tests/test_understand_takeoff_tools.py. Each file keeps its own client
  fixture: U's opens a fresh document, N's reads files only.
- server.py: SECTION 26 (4 tools, U then D) is kept as merged, and SECTION
  27 (Plant Takeoffs, 2 tools) follows it whole, above the RESOURCES
  banner.
- discovery/aliases.py: additive; the two takeoff records follow the four
  readers.
- tests/test_tool_registry.py: all three branches' paragraphs are kept.
  The snapshot is recomputed from the live registry: analysis 18, 247
  tools. Task 11 ranks `understand` and `plant`.
- docs/tool-inventory.json regenerated (247 tools, 26 groups); README
  counts are 247 registered / 242 advertised. The collected-tests figure
  is left to Task 11.

ruff check passes. Suite: 4919 passed, 1 xfailed. The one deselected test
is the collected-tests figure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:48:12 +03:00
Umut CANandClaude Opus 5.5 cfcc8ce6bb merge: track H group D
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:32:37 +03:00
Umut CANandClaude Opus 5.5 d0e99bab7b merge: track H group U
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:30:30 +03:00
Umut CANandClaude Opus 5.5 459d3b9228 feat(understand): cable_takeoff - one row per load, rounded to the metre
cable_rows reads each load's power, phases, voltage, neutral and VFD from the
P&ID's electrical texts near its tag, and its panel from the layout's wiring
callouts (else the P&ID's), names normalised CP 1 -> CP-1. The length is the
Manhattan distance on the layout from the tag to its panel, and the cable
ROUNDUP(length x (1 + allowance)) to the metre, on the length rounded to
1e-6 m so binary noise never buys a metre. Power is never invented: a load
with no stated kW keeps an empty cell and an open item. Without a layout the
lengths stay empty with an open item per row. Room and panel totals count a
panel package's power once, never its children's as well; the rule is on the
method sheet. Sections only from the caller's section_rules - no standard
table exists here.

The workbook gains the cable shape (Metraj, Ozet by room and panel, Kontrol
as the open-items list, Metodoloji) in the same three languages;
cable_takeoff joins SECTION 27. Inventory regenerated; README snapshot 243
tools (238 advertised); registry snapshot analysis: 14.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:28:59 +03:00
Umut CANandClaude Opus 5.5 1795dc7f2c fix(understand): drawing_diff minors from the review — direction, repeated tags, bounded output
The review of Task 9 left four minor findings. Three change what a user reads:

- A LINE drawn back to front and moved was reported as a geometry change.
  The signature already ignores a LINE's direction, so its movement now
  does too: the reversed order is tried before the entity is called
  reshaped.
- An INSERT carrying one attribute tag twice was read as a dict. An edit
  of the first value could disappear, and since the pair then had nothing
  to report, it counted as unchanged. A repeated tag is now compared as
  the list of its values; a tag carried once keeps plain from / to.
- cap_report capped the three lists but not the clusters. A widespread
  edit of a large drawing could return an unbounded cluster list. It is
  capped like the others, with truncated["clusters"]; the markup still
  clouds every cluster.

The fourth bounds work: when the whole drawing moves, every entity reaches
the position stage, and a generous move_tol gave each one hundreds of
candidates, which is n x m memory. Only the 32 nearest per entity are kept
now. A 200-line test shows each line still pairs with its own moved copy.

The first three tests failed against the previous module (the attribute
test with no changed row at all). The whole diff suite passes: 38 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:26:56 +03:00
Umut CANandClaude Opus 5.5 cdff63fce0 fix(understand): a DWG the operator has open is exported as it stands, never closed
On the live engine, take_snapshot read a .dwg path by calling
Documents.Open(path, read-only), exporting and then Close(False) on what
Open returned. If the operator already had that drawing open, that was
their document, and closing it unsaved could throw away unsaved work.
drawing_understand, drawing_scale_check, drawing_diff and the takeoffs all
read a path through this function.

The review of drawing_diff raised the risk and did not probe it live, to
avoid a modal dialog on the shared AutoCAD. The fix makes the question
moot: the path is looked up among the open documents first, case-blind as
Windows paths are, and an open drawing is exported as it stands (unsaved
edits included) and left open. Only a document opened here is closed here.
The new test, against the fake that behaves as measured, fails on the old
code with an open and a close in the log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:24:02 +03:00
Umut CANandClaude Opus 5.5 efa1ea8182 fix(understand): a truncated DXF is refused by the snapshot, not left hanging
ezdxf's tag reader raises StopIteration on a file that ends early, for example
after an interrupted save or a partial copy. read_snapshot caught only OSError
and DXFError, so the StopIteration escaped. asyncio.to_thread cannot hand
StopIteration back to an awaiting caller, so every track H tool reading such
a file would have hung instead of refusing it.

The review of drawing_diff found this in its own reader. The snapshot now
does the same: every parse or walk failure except MemoryError becomes the
existing "is not a readable DXF file" refusal. A new test checks this both
directly and through a worker thread with a 10 s timeout. It failed with
StopIteration before this change and passes after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:22:23 +03:00
Umut CANandClaude Opus 5.5 653535bcc1 feat(understand): the takeoff workbook and pipe_takeoff (SECTION 27 opens)
write_workbook builds the user's takeoff workbook from a result: Metraj (rows
with direct, continuity and unassigned lengths split, the allowance as its
own column, routed / unroutable / grand totals), Ozet (by service, diameter
and room), Kontrol (unassigned diameters and unroutable runs with the
reason) and Metodoloji, generated from the result: sources, the length
source and why, the scale-check verdict with its statistics, the units,
every rule and tolerance, and every warning. Headers and sentences in
Turkish, English or Russian. CSV always, one file per sheet; XLSX with
openpyxl, otherwise the xlsx_write refusal naming the office extra while
the CSV files are still written.

pipe_takeoff reads the P&ID and the layout once each (take_snapshot),
computes in a worker thread and never modifies either drawing. Tagged plant
and analysis; the plant pack is the integration task's. Alias record in
English, Turkish and Russian. Inventory regenerated; README snapshot 242
tools (237 advertised); registry snapshot analysis: 13.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:22:15 +03:00
Umut CANandClaude Opus 5.5 2b74bf20a6 feat(understand): drawing_topology_check and the three topo_* critique focuses
engineering/understand/topology.py reads a line network (LINE, polylines
with true bulge arcs, ARC) on the chosen layers and reports dangling
ends, near misses (end-end and end-segment within gap, one row per pair)
and interior crossings of two straight segments of one layer. An end on
another network line is joined; an end on any curve of another layer or
inside a block reference's box is attached; a line drawn twice is not a
join, so a doubled pipe still dangles. gap defaults to 0.2 % of the
network diagonal, never under ten tol.

drawing_topology_check joins drawing_diff in SECTION 26: default layers
are those classify_layer calls piping or electrical at >= 0.9, reported
with their classification; no such layer is a refusal, not a pass.

topo_dangling_endpoint (warning), topo_near_miss (error) and
topo_interior_crossing (info) join the closed enum and run under
focus=None, excluding the P&ID and architectural layer sets, so a
pid_from_spec sheet and an arch_plan_from_spec plan get none of them;
a drawing with no candidate layer is never snapshotted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:20:24 +03:00
Umut CANandClaude Opus 5.5 e640def4ff feat(understand): orthogonal lengths and pipe takeoff rows
route.py: Manhattan distance, the rectilinear minimum spanning tree (Prim
over |dx| + |dy|, exact on the points given) and the x-then-y route.

pipe_rows turns the P&ID's runs into rows by room x service x diameter.
length_source auto measures on the P&ID only when the scale check calls it
to scale; otherwise each run is the RMST of its tags' layout positions plus
vertical_allowance per connection, with the drawn length kept beside it as
schematic_m. length_source=pid on a schematic P&ID is refused with the
statistics unless force=True. Status A / B / C; a C run goes to control
with its schematic length, counted and flagged. Routed lengths are shared
among the layout rooms their route crosses (track F's face finder); the
allowance is its own column. Lengths are metres from the unit the geometry
implies: a layout declaring inches over millimetres is read in mm and warned
about, never multiplied by 25.4.

The takeoff carries narrow stand-ins for the unit inference, the plan-copy
clusters and the scale check (group U's modules are on another branch); a
scale result passed in is used first. Tags written twice in the chosen
cluster are ambiguous, never picked.

Beyond the plan: the scale stand-in calls a non-positive median ratio
insufficient (as group U's scale_check does) instead of to_scale with a
factor of 0; an INSUNITS code the reader does not convert is named in the
unit warning instead of being called "no unit"; wall layers are classified
once per layer, not once per record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:14:17 +03:00
Umut CANandClaude Opus 5.5 30f886c240 fix(task-9): no phantom changes from the tol grid, no handle stage without evidence, a truncated DXF refused
Three blocking review findings on drawing_diff.

1. An unedited entity reported as changed with changes == {} and clouded.
   The signature snaps values to the tol grid, so a value 1 ulp across a
   half-grid point lands in another bucket. AutoCAD writes 16 significant
   digits (0.14 + 0.005 = 0.14500000000000002 comes back as 0.145), so a
   round trip split the pair, which was matched later and counted as
   changed. A matched pair is now compared by difference (more than half a
   tol, around the circle for angles) instead of by grid bucket, and a pair
   with nothing to report counts as unchanged, with no row and no cluster.
   Measured live on AutoCAD 2026 (Documents.Open read-only, select all,
   Export DXF, Close(False)): before, summary changed=1, a row with
   changes {} and one cluster; after, unchanged=21, changed=0, no cluster.
   Over 12000 computed coordinates (a*0.01 + {0.005, 0.015, 0.105, 0.305})
   795 signatures flip; before, 795 were changed, and after, 0.

2. With zero exact matches, handles were declared stable, so strangers from
   two fresh files that share handles were paired and given wrong moves.
   The handle stage now needs at least one exact match. The report adds
   handle_evidence {exact_matches, kept_handle}.

3. A truncated DXF raised StopIteration from ezdxf's tag reader.
   asyncio.to_thread cannot hand that exception to an awaiting caller, so
   drawing_diff hung instead of refusing. read_revision now turns every
   parse or walk failure (not MemoryError) into ValueError "not a readable
   DXF (...)". tol is validated first, so its refusal keeps its own name.

Tests: grid splits by handle and by position, radius/height/rotation/bulge
split at tol=1, a real edit next to a split, no-evidence handles, a
truncated file directly and over the MCP wire with a 30 s timeout. The six
new tests fail against the previous module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:12:40 +03:00
Umut CANandClaude Opus 5.5 7bfba02ef0 fix(task-5): fittings bridge every port pair, reducers found however the pipe meets them, unread-layer lines keep their labels
- A three- or four-way fitting joined its ports as a star around the port
  seen first, so the diameter carried into an unlabelled branch depended on
  record order. Every port pair is now bridged; two sizes equally near stay
  unassigned whatever the order (six-order parametrised test).
- A reducer was seen only through free ends in its box. It is now also found
  where pieces meet at a vertex inside its box (a line broken at the
  reducer) and where a straight segment is drawn through the middle of its
  box (cut at the foot of the box centre). A direct label holds for its
  entity only on its side of every reducer.
- A diameter or direction label nearer to an open line on a layer that is
  not read belongs to that line: it is dropped and listed in
  stats.labels_skipped instead of landing on the nearest read pipe.

Headless engine only: no new ActiveX call, so no live probe applies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 12:08:47 +03:00
Umut CANandClaude Opus 5.5 7aaead43d9 feat(understand): drawing_scale_check - is this drawing to scale against that one
Equipment tags found exactly once in each drawing are matched (a tag
found twice - a plan copy, a legend - is reported ambiguous and takes no
part; two plan copies resolve to the cluster that matches the most
tags). For every pair of matched tags at least min_pair_mm apart on the
reference, the ratio of their distances is taken in millimetres by each
drawing's inferred unit; the median, the quartiles, the share within
+/-10 % of the median, the same per room of the reference, and the
verdict: to_scale (>= 80 %, with its factor), schematic (<= 50 %),
partly, or insufficient under three matched tags.

server.py grows SECTION 26 to two tools with drawing_scale_check (core,
read-only, tagged analysis/query); alias record, inventory, README
release snapshot 243, frozen group sizes (analysis 14).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:55:57 +03:00
Umut CANandClaude Opus 5.5 1426c53fd3 feat(understand): the line-network engine - runs, services, diameters, equipment
build_network reads the segments of a foreign P&ID's piping and electrical
layers into runs: ends merged within tol, a branch ending on a header split
into a tee, a crossing without an end left unconnected, and a line drawn
twice - or a line over a polyline - cut into identical pieces that are kept
once, the doubled length reported as overlap_length. An INSERT holding two to
four free ends is an inline fitting joined through; a reducer word in its
block name (reducer, perekhod, reduksiyon, verloop, reduzier) stops diameter
continuity.

Each diameter label goes to the nearest piece and holds for its drawn entity
(direct); unlabelled pieces take the nearest direct diameter along the run
(continuity) or stay unassigned - two sizes equally near are never guessed.
Labels stay as drawn. A supply or return word near a run overrides its
layer's service for the whole run. Run ends attach to the tag whose labelled
geometry holds them; a shape holding two tags (a room, a skid) is not
equipment. Tests on hand-built drawings and on the synthetic plant pair.

Beyond the plan: a CIP / glycol layer naming no direction (classify_layer
files it `unknown` on purpose) now takes its direction from the word instead
of staying unknown; is_reducer matches on vocab.fold and knows the German
word equipment_kind already had; the fitting, equipment and attachment
lookups go through grid indexes - measured 149 s -> 2.8 s on a 59,200-record
synthetic drawing, results identical to the plan engine on it and on the
plant pair. No ActiveX call is added (pure module).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:53:03 +03:00
Umut CANandClaude Opus 5.5 54325e6de0 feat(understand): drawing_diff - two revisions matched by geometric signature
engineering/understand/diff.py reads each revision once into entity
records and matches them in three stages: exact signature (type, layer,
geometry rounded to tol, text, block, rotation, scale, attributes;
handles ignored), then handle while at least half of the exact matches
kept theirs, then type + layer + nearest anchor within move_tol (default
2 % of the 1st-99th percentile diagonal). The report counts changes by
layer and type, says what changed per entity (moved by, text, attribute,
radius ... from -> to), compares named block definitions by content and
groups the changes into clusters.

drawing_diff opens group D's copy of SECTION 26. It is read-only unless
markup=True, which files revision clouds around the model-space clusters
on the current document through add_revision - refused before any read
with new_path, without a valid rev, or on an engine without revcloud.
A revision path that is neither .dxf nor .dwg is refused naming the
parameter (old_path: / new_path:), like every other path refusal.

Tests: the synthetic plant P&ID edited five ways (a move, a text, an
attribute, an add, a delete) under renumbered handles reports exactly
those five edits; renumbering alone reports nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:51:19 +03:00
Umut CANandClaude Opus 5.5 2459694880 feat(understand): drawing_understand - units, extents, clusters, layers, tags, rooms
A drawing somebody else made, read back as one report in which every
claim carries its evidence and a confidence; the drawing is never
modified.

- units.py: $INSUNITS against the unit the geometry implies (text
  heights, wall thickness, door swings, labelled room areas, overall
  size). The header is overruled only when the evidence outweighs it,
  and then with a warning that quotes the numbers. Robust extents grow
  from the quartile box of entity centres; what never joins is an
  outlier, named by handle.
- clusters.py: separate bodies of model-space content by raster,
  8-connectivity and box absorption - two copies of one plan are two
  clusters.
- describe.py: layers with drawn length and the vocabulary's meaning
  (name keyword, else the texts on the layer), equipment tags (one tag
  lettered twice beside its symbol merged, repeats flagged ambiguous),
  room labels in the wall-layer faces of engineering.arch.faces, text
  languages by character class, blocks, layouts, the title block.
- server.py opens SECTION 26 (Understanding & QA) with
  drawing_understand: core, read-only, tagged analysis/query. Alias
  record, inventory, README release snapshot 242, frozen group sizes
  (analysis 13).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:50:41 +03:00
Umut CANandClaude Opus 5.5 704a44b190 test(understand): the synthetic plant pair - a schematic P&ID and its layout with known answers
Every track H reader is measured on this pair, never on a client drawing
(spec section 14). tests/fixtures/plant_pair.py writes pid.dxf and layout.dxf
with invented names and returns their truth, computed from the positions it
placed:

- the P&ID is room 1 of the layout stretched 1.3x and room 2 rearranged, so
  21.8 % of the 55 tag pairs fall within 10 % of the median ratio (schematic)
  while room 1 alone is 100 % at 1.3;
- service layers in English and Dutch, Russian supply / return words, one CIP
  return drawn on the supply layer, sizes spelled as drafters do (an MTEXT
  format run, %%c, \U+2205, the U+2300 sign, o-stroke, SMS, DN, 20x27), a
  reducer, inline valves, an arc bend, a segment drawn twice;
- electrical texts in Russian with the power stated for all loads but one;
- a layout declaring inches over millimetre geometry, a stray line 5,000 km
  out, two plan copies 100 m apart, wall outlines, room labels and wiring
  callouts in four spellings.

Run lengths are the rectilinear MST of each run's tags, cable metres are
the Manhattan length rounded up after the 20 % allowance; the generator
refuses to write a pair whose own premises fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:42:09 +03:00
Umut CANandClaude Opus 5.5 9ab0ae1773 feat(understand): the snapshot, the vocabulary and the label parsers; MAX_DXF_BYTES 512 MB; the office extra
Track H's foundation, landed on main before its three groups branch.

- engineering/understand/snapshot.py: one bulk read of a drawing into frozen
  WCS records (OCS translated through backends/ocs.py, text decoded, an
  INSERT boxed by its block geometry without its attribute texts). A DXF is
  read with ezdxf after the size check; the headless engine's document is
  read under its own lock; the live engine exports the active document with
  Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted
  afterwards - and a DWG is opened read-only, exported and closed. Measured on
  AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as
  snap.DXF, model space, paper space and unused block definitions all reach
  it, and the selection set does not narrow a DXF export.
- engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke
  and MTEXT format runs all read as one diameter spelling; sizes are kept as
  drawn and never equated; Cyrillic look-alike capitals fold into Latin tags;
  panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in
  EN / TR / RU / NL / DE. A power that is not written stays None.
- engineering/understand/vocab.py: layer classification with a confidence and
  the deciding keyword, supply / return words, room labels and block kinds in
  five languages. A CIP layer without a direction word is not guessed.
- MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal
  names the size and the variable. New `office` extra (openpyxl), also in
  `full`; uv.lock regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:38:59 +03:00
Umut CANandClaude Opus 5.5 f0c50d772f docs(spec,plan): track H states what it does with xrefs
The spec promised that the first live task would measure xrefs. It never
did: the plan measures model space, paper space and block definitions. 1.6
does not read an xref's own entities on either engine; the host drawing is
what gets read. The snapshot docstring and drawing_understand's docstring
now say so, and nothing is claimed about the live engine that was never
measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:30:38 +03:00
Umut CANandClaude Opus 5.5 9b45243bdd docs(plan): track H task 11 reads units, plan copies, scale and rooms once
The reconcile pass left one real defect for the reviewer. Groups U and N are
built side by side, so the takeoffs carry their own unit bands, cluster grid,
scale statistic and room faces, and nothing ever retires them. Two readers of
one drawing can disagree. On Task 6's own inches-over-millimetres layout,
infer_units without room faces ties three ways and falls back to the header,
while the takeoff's bands say mm.

Task 11 gains Steps 15-20, committed on their own before the README figures
are retaken:
- describe.drawing_units becomes the one unit call, used by describe,
  scale_check and the takeoffs;
- the takeoffs read plan copies, scale and rooms through group U's modules;
- the stand-ins are deleted;
- tests/test_understand_one_reading.py pins one unit, one plan copy, one
  verdict and one set of rooms per drawing.

Tasks 3, 4 and 6 now point at those steps. Review Focus 1 names
parse_diameters, the function every task uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:29:53 +03:00
Umut CANandClaude Opus 5.5 7f82766d38 docs(plan,spec): track H examples carry invented values only
A second pass compared every tag, number and literal in the plan with the
field files, locally. Two borrowed values remained: a motor rating and one
motor tag. Both are now invented (18,5 kW, M82), and the dependent sums and
section rules were checked against them. The rest of the tank series was
renumbered to keep its old sort order, since several assertions compare
sorted tags: T4101-T4103, T4500 and T4600.

Docstrings no longer tie example layer names to a particular drawing. The
fixture's claim now says exactly what is invented (every tag and number) and
what is ordinary drafting vocabulary (layer and block names). The spec's
grounding paragraph now says "process plant" and no longer names the sector.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 11:17:53 +03:00
Umut CANandClaude Opus 5.5 77db39804a docs(plan): track H examples use synthetic equipment tags
The skeleton's examples (and the tests drafted from them) borrowed equipment
tag numbers seen in the field drawings. They identify nothing on their own,
but the plan's own rule is that every example and fixture is synthetic, so
they are replaced by invented tags of the same shape and sort order
(T41xx-T44xx, M41A, M47.1, M82, CP-M82), Cyrillic spellings included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 10:44:57 +03:00
Umut CANandClaude Opus 5.5 023eac7a51 docs(plan): track H — 12 TDD tasks in three groups
The skeleton's header, constraints, file map and pinned interfaces now carry
their twelve tasks, in order:

- main (Tasks 1-2): the snapshot, vocabulary and label parsers, MAX_DXF_BYTES
  512 MB and the office extra; the synthetic plant pair with its truth.
- U (Tasks 3-4): drawing_understand and drawing_scale_check; SECTION 26 opens.
- N (Tasks 5-8): the line network, orthogonal lengths and pipe rows, the
  takeoff workbook with pipe_takeoff, then cable_takeoff; SECTION 27, pack plant.
- D (Tasks 9-10): drawing_diff and drawing_topology_check with the three
  topo_* focuses, on D's own copy of SECTION 26.
- final (Tasks 11-12): packs, lean 65, aliases, golden queries and counts;
  benchmark v7, five correctness checks, the live smoke, the field-test
  script and the docs.

Interface pass: the U and N texts now say what Task 6 really does with the
scale check (it takes one through pipe_rows(scale=...) and otherwise keeps its
own stand-ins), and Task 11 moves D's block above SECTION 27 after the merge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 10:43:08 +03:00
Umut CANandClaude Opus 5.5 4cd5603725 docs(plan): track H skeleton — 12 tasks in three groups, interfaces and review focus pinned
Header, global constraints (confidentiality, read-only by default, nothing
invented, inferred units), the five real-world inputs no happy-path test
exercises (AutoCAD text codes, Cyrillic look-alike tags, repeated tags,
double-drawn pipe, a layout whose INSUNITS lies), file structure, the
interface block, execution with the worktree fast-forward rule, and the merge
notes. Task sections are drafted next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 09:39:33 +03:00
Umut CANandClaude Opus 5.5 18995bf854 docs: track H (understanding foreign drawings) design spec
Read a drawing somebody else made and turn it into a deliverable: a one-call
drawing report (units that lie, stray extents, plan copies, multilingual layer
meaning), a scale check between a P&ID and its layout, pipe and cable takeoffs
whose topology comes from the P&ID and whose lengths come from the layout, a
revision diff by geometric signature, and topology checks. Grounded in a real
client project read locally (never committed): its P&ID proved to be
unscaled against its layout, which no tool had flagged.

Six tools (+ pack plant), three topo_* focuses, the office extra, a live DXF
snapshot through Document.Export (measured: the document is not renamed), and
synthetic fixtures that reproduce the field findings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-24 09:32:31 +03:00
Umut CANandClaude Opus 5.5 0abbe55041 bench: matrix v6 adds arch_roundtrip; four architectural correctness checks; the live arch smoke
The evidence for track F, in the shape tracks A, E and B + G used.

- benchmarks/tasks_v6.py: 19 tasks, DEFAULT_MATRIX = "v6"; v5 to v2 stay
  addressable. arch_roundtrip draws the example plan with
  arch_plan_from_spec and reads it back without the spec: rooms_detect must
  find each labelled room within 0.1 % of its label and of the net floor
  computed by hand (4825 x 5750 and 3825 x 5750), the schedules must list
  D1, D2, W1, W2, and the plan is gated on a zero-issue critique and a
  finalize score of at least 90. A test removes one room label and the gate
  fails naming arch_room_unlabelled.
- Four correctness checks, every number worked out by hand in the docstring:
  L / T / X junction corners and wall areas, a 4 x 5 m room labelled 20.00 m2
  rather than its 21.84 m2 axis area, a door interrupting both faces, and a
  plan of plain lines read as 20 and 15 m2 at confidence 0.6. The published
  A/B report is re-run over 43 checks: 26 / 43 -> 43 / 43, zero regressed.
- scripts/smoke_arch_com.py runs the whole plan once on a live seat and
  closes every document it opens; the CHANGELOG records what it returned.
- The token suite measures a TOOL_PACKS=core,arch row; check_doc_numbers
  derives the task count from the v6 matrix; CLAUDE.md, both READMEs and
  the spec status line say what shipped.
- fix(com): the first live smoke exited 1 - the room reader found 0 wall
  segments. Measured on AutoCAD 2026 (2026-09-23): with a makepy cache,
  ModelSpace.Item(i) returns the gen_py IAcadEntity wrapper
  (type(item).__name__ == "IAcadEntity") and item.StartPoint on an
  AcDbLine raises AttributeError, so every entity_list row lost its
  type-specific properties (28 wall LINEs, 0 with start/end). _entity_info
  now re-dispatches exactly that wrapper through _com_unnarrow; after the
  fix 38 of 53 rows carry start/end and the smoke exits 0 with both rooms
  at 27 743 750 / 21 993 750 mm2 and 0 critique issues. Re-dispatch
  measured at ~1.7 ms per entity (dynamic vs gencache alike).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:35:46 +03:00
Umut CANandClaude Opus 5.5 6e004cc658 feat(arch): arch_plan_from_spec, three arch_* critique focuses, the arch pack, lean 64 and the counts
The three track-F branches are merged; this is the integration that makes the
twelve SECTION 25 tools one surface.

- engineering/arch/spec.py: a whole plan from one document - walls with their
  openings, stairs, room labels measured off the walls just drawn, the
  exterior chains and the schedules - inside one transaction. Everything the
  model, the wall engine or the stair symbol can refuse is refused before the
  transaction opens, by path; a room point in no closed face rolls it back,
  and so does a cancelled request, under the anyio shield of pid/spec.py.
  EXAMPLE_SPEC's rooms measure 4825 x 5750 and 3825 x 5750 - the net floor,
  computed by hand in the tests.
- engineering/arch/critique.py: arch_room_unlabelled, arch_wall_gap (50 mm,
  one issue per pair of walls, measured to the wall body so an L corner and a
  T stem both read as joined) and arch_opening_clash. One index per critique
  run; a drawing without an ACADMCP_ARCH record yields nothing. Each focus
  has a positive and a negative fixture.
- TOOL_PACK_NAMES gains "arch" (the twelve SECTION 25 tools); lean gains
  arch_wall, arch_opening, arch_room and arch_plan_from_spec and reaches 64;
  the arch tag ranks after mech, so SECTION 25 files as one `architecture`
  group - 241 tools in 26 groups.
- Six golden tuning cases, two of them Turkish, the arch_plan_from_spec alias
  record, the shared-command allowlist reconciled with what the merged records
  claim, the inventory regenerated, the registry snapshot retaken, and the
  README figures taken from check_doc_numbers rather than typed.
- Two more Turkish golden cases close the reconciliation's alias gaps
  ("vaziyet planı çiz" -> arch_plan_from_spec, "oda alanlarını hesapla" ->
  arch_rooms_detect); the arch records carry vaziyet / alan / oda / kat planı
  in diacritic and ASCII-folded spellings, because the search tokenizer splits
  on a non-ASCII letter. BOUNDARY and AREA stay with boundary_trace and
  analysis_measure_entity on purpose, recorded in the arch alias comment.
  Tuning top-1 73/84, top-3 83/84; holdout top-3 15/15.
- tests/test_token_suite.py: TOOL_PACKS=core,mech now drops the arch pack
  too (measured 193 = 236 - 9 - 22 - 12).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:20:05 +03:00
Umut CANandClaude Opus 5.5 ad5a32a34a merge: reconcile counts after track F groups
The walls branch pinned the architecture group to its own four tools; after
the merge SECTION 25 files eleven there, so the test checks the four are
among them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:09:03 +03:00
Umut CANandClaude Opus 5.5 4c5c8227ef merge: track F group C
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:05:27 +03:00
Umut CANandClaude Opus 5.5 18db0b46c0 merge: track F group R
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:03:13 +03:00
Umut CANandClaude Opus 5.5 4c91d3da92 merge: track F group W
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:00:54 +03:00
Umut CANandClaude Opus 5.5 cd1ae3529c feat(arch): SECTION 25 opens - walls, openings, stairs and exterior chains as tools
engineering/arch/draw.py is the only module of the track that touches a
backend. It draws through draw_prims(role_layer=ARCH_ROLE_LAYER) and the
generic contract members, so it runs on both engines as written, and it writes
the plan onto the drawing: each element's ACADMCP_ARCH record on one of its own
entities (a wall's first face line, a door's leaf, a window's frame, a stair's
first riser) and, beside it under ACADMCP_ARCH_H, the handles that element
owns. read_plan reads the records back and never modifies the drawing.

arch_wall computes the whole network before and after the new walls and
redraws only the drawn walls whose outline changes - erasing exactly the
entities they own - and says which under redrawn. arch_opening hosts a door or
a window in a drawn wall found by id and redraws that wall cut; its tag
defaults to the next D1/W1 (K1/P1 in Turkish). A new wall that would leave a
drawn opening uncuttable, and an opening that runs into a junction, are refused
before anything is erased. arch_stair reports the Blondel value; it does not
enforce it.

exterior_chains gives each side up to three rows, nearest first: every jamb in
that facade, both faces of every wall meeting it, and the overall - from the
wall engine's own faces. A row that would only restate a farther one is not
emitted (ISO 129-1: each measurement once). The 10 mm / 8 mm row spacing is a
declared drafting default times the plot scale, not a standard value.

The arch tag is ranked after mech so the four tools file under a new
architecture group; alias records carry WALLADD/MLINE, DOORADD/WINDOWADD and
STAIRADD with English and Turkish shop words. Inventory regenerated; README
snapshot 233 tools (228 advertised) in 26 groups; registry snapshot
architecture: 4.

No new ActiveX member is called. Measured on AutoCAD 2026 in a scratch
document through ComBackend: shell + teed partition, D1 + W1, a straight 17-riser stair
and the bottom chains gave the same 20 wall LINEs (identical endpoints),
9 poche HATCHes, 1+1 door, 4 window lines, 11 dimensions and a read_plan
round trip as the headless engine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 23:00:05 +03:00
Umut CANandClaude Opus 5.5 9168d61397 feat(task-6): door, window and room schedules read from the drawing
engineering/arch/schedule.py turns the plan records into schedule rows: doors
by tag with width, height, swing, hand and wall; windows by tag with width,
height, sill and wall; rooms by number with name and the measured area the
label carries, plus a total. Tags compare as numbers (D2 before D10). An
opening without a tag gets the next free D1 / W1 - K1 / P1 in Turkish - in
wall order; an explicit tag is never renumbered and a generated one never
repeats it. A value the model does not carry is an empty cell, never a default,
and Turkish output writes decimal commas.

draw_schedule reads the ACADMCP_ARCH records off the drawing through the one
reader rooms.py already has and draws a real TABLE the way the ISO 7573 parts
list does - top-left insertion, the table growing down, the parts list's 7 mm
rows and 2.5 mm text at the plot scale, the engine's representation reported.
Nothing to schedule, an unknown kind or language and more rows than one TABLE
takes are refused before anything is drawn.

arch_schedule joins arch_room and arch_rooms_detect under SECTION 25's rooms
marker (banner 3 tools), shares the TABLE command with entity_create_table and
bom_table, and the counts follow: 232 tools in 25 groups.

Live fix in com_backend.entity_create_table. MEASURED on AutoCAD 2026: IAcadTable
has no TextHeight property. pywin32 raised AttributeError on
`table.TextHeight = h`, the old code swallowed it, and every live table kept
the Standard style's heights (title 6.0, header and data 4.5), which is 0.1 mm
on paper at 1:50. The member that works is SetCellTextHeight(row, col, h),
probed on a scratch document: every cell read back 125.0 via
GetCellTextHeight. The row heights stayed 350.0 and GetBoundingBox matched the
reported bbox (10000,3250)-(15750,5000). Then draw_schedule ran end to end
through ComBackend on a scratch document: a native TABLE on A-SYMB-T-N for
doors (5 rows) and rooms/tr (6 rows, total 45,00 m²), and the document was
closed afterwards. The new fake in tests/test_table_leader.py has only the
measured members.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:56:05 +03:00
Umut CANandClaude Opus 5.5 b68fe7ef66 feat(arch): door and window symbols, straight/L/U stairs and the Blondel check
openings.py draws what stands in the gap the wall engine cuts, from the same
numbers - the wall axis, its face offsets and model.segment_of - so the symbol
cannot drift off its gap. A door is drawn open at 90 degrees: its leaf as long
as the opening is wide, square to the wall from the hinge on the swing-side
face, and a quarter arc from the closed position to the open one. swing=in is
the left of the axis (the inside of a CCW perimeter); hand names the hinge jamb
seen from the swing side facing the wall. All four swing/hand pairs are tested
against hand-computed hinges and arc angles. A window is a frame line on each
face and two glazing lines at the thirds of the thickness.

stairs.py draws every riser once as a line across its flight, the outline, a
walking line up the middle with an open arrow at the top, and the language's
'up' word on the first tread at 2.5 mm times the plot scale. An L or a U turns
on a square landing; its first flight carries (risers + 1) // 2 risers. The
riser count and every dimension are refused by name before any geometry.

blondel() reports 2R + G against 600-650 mm - the step-length rule attributed
to Francois Blondel (1675), reported as a design rule and never as a building
code: no national stair regulation is checked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:51:41 +03:00
Umut CANandClaude Opus 5.5 a2996b0c95 fix(task-5): skip what the room reader cannot read, and one face carries one room
read_segments trusted any row with `points`. On a live seat an old-style
AcDb2dPolyline's Coordinates are x, y, z triples (measured on AutoCAD 2026,
2026-09-23: [200,200,0, 4200,200,0, ...]) which the backend reads two at a
time, so three closed ms.AddPolyline outlines on WALLS reported points like
[[0,0],[0,8600],[0,0],[8600,5400],...] and rooms_detect answered five faces of
2.6-6.9 m2 for two 20 m2 rooms. Headless, the same POLYLINE arrives with no
points and was dropped without a word; so were walls inside a block reference.
The reader now reads LINE and the lightweight polyline only (LWPOLYLINE, and
the live AcDbPolyline reported POLYLINE), and lists every other entity that
could bound a room in `skipped` with its handle, type and reason: arcs and
bulged edges as before, old-style polylines (2DPOLYLINE/3DPOLYLINE live, a
vertex-less POLYLINE headless), block references (INSERT / BLOCKREFERENCE)
and anything else not a line. Text, dimensions, leaders, points and hatches
bound nothing and are passed over. Re-run live on AutoCAD 2026 through the
real com_backend._entity_info rows: count 0 with the three polylines in
skipped (was 5 wrong rooms); with a lightweight pair and a block added, count
1 at 20 000 000 mm2 plus the block reference in skipped. The backend's stride
is left alone (shared file, other readers); this reader no longer trusts it.

label_room refused a reused id but not a reused face: a second label in a
labelled room (a retry after a transport timeout) wrote a second record with
the same area, which a schedule total counts twice. It is now refused before
anything is drawn, naming the record and label already there. rooms_detect
reports `labels` (every record in the face; `label` stays the first) and
`label_conflicts` for a face holding more than one.

No new ActiveX member is called.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:48:36 +03:00
Umut CANandClaude Opus 5.5 9e9138f22d fix(task-2): order-independent T cuts and bounded near-straight joins
A T recorded its cut on the crossing segment as a distance from that
segment's start; when the start was carried onto a third wall later in the
same pass the cut went stale, so [c,b,a] drew a free-end jamb inside wall a,
an uncut face and a double-hatched overlap where [c,a,b] was right. Cuts are
now stored as points and measured against the final axis.

A near-straight join whose faces are offset (a thickness or justification
change, or a rounding kink between such walls) mitred at offset/sin(kink):
faces 1e8 mm long and a clockwise region for a 0.01 mm rounding. A
straight-through pair whose mitre lies more than _MITRE_REACH (2) thicknesses
from the node now steps on the line bisecting the join - both sides of a
two-end node together, so the pieces tile without a sliver - and every piece
and hub ring is refused unless counter-clockwise, not only when it crosses
itself.

Pure geometry: no ActiveX member is touched, so no live probe applies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:46:39 +03:00
Umut CANandClaude Opus 5.5 f5f11178a9 feat(task-8): SECTION 25 grid and symbols - arch_grid, arch_symbol
engineering/arch/symbols.py is pure: grid_prims() draws each structural axis on
the grid role - its CENTER linetype comes from the A-GRID-E-N row, no primitive
sets one - with a 5 mm (paper) bubble and its label touching both ends, on the
symbol role so the circle does not print dashed. Numbers run along x and
letters along y (A..Z, AA, ... none skipped) unless labels names them;
positions are sorted, duplicates and label-count mismatches refused by path.

symbol_prims() draws the four plan symbols: the north arrow (a circle, an
arrow turned CCW from +Y, the vocab(lang) north letter upright beyond the tip),
the section mark (the cut line, a labelled bubble past each end and a viewing
arrow at each end, p1/p2 relative to the origin), the level mark (the triangle,
its line and the value formatted +3.00 / ±0.00, with a decimal comma in
Turkish) and the elevation mark (a circle whose pointers are the tangents from
r*sqrt(2) touching it at +/-45 degrees). An unknown kind is refused with the
list, a parameter the kind does not take with the ones it does. Every size is
a declared paper-mm drafting choice times the plot scale, not a standards value.

arch_grid and arch_symbol draw through draw_prims with ARCH_ROLE_LAYER; alias
records (COLUMNGRIDADD, English and Turkish synonyms), a regenerated inventory,
the README snapshot at 233 tools and the branch-local group snapshot
(entity_creation 21) complete the registration.

Beyond the plan: grid_axes refuses a labels value that is not a dict and a
labels.x / labels.y that is not a list (a string was split into one label per
character, a number raised a bare TypeError). No new ActiveX member: a live run
on AutoCAD 2026 (scratch document, closed after) drew the grid and all four
symbols through draw_prims - A-GRID-E-N created with CENTER, bubbles, texts and
symbols on A-SYMB-T-N, the Turkish north letter K and the level "+3,00".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:42:42 +03:00
Umut CANandClaude Opus 5.5 afd42356b5 feat(task-7): SECTION 25 catalogue - arch_catalogue_list, arch_catalogue_insert
engineering/arch/catalogue.py is the standard-parts pattern of track B for an
architectural plan: twenty authored plan symbols in two families - thirteen
furniture items (single and double bed, wardrobe, three-seat sofa, armchair,
chair, dining table with four chairs, desk, kitchen counter run, fridge,
bookshelf, nightstand, coffee table) and seven sanitary items (WC, bidet, wall
basin, shower tray, bathtub, kitchen sink, washing machine). Every size is a
nominal catalogue dimension and says so in SIZE_BASIS, in each row and in both
tool docstrings; no standard is cited because none fixes these sizes.

Each item is typed block_define primitives on layer 0 inside [0, w] x [0, d],
origin at the back-left corner with the wall side along +X - a test holds all
twenty to their footprint. catalogue() searches name, family and the English
and Turkish labels, ignoring case and Turkish diacritics (buzdolabi finds
buzdolabı). insert_item() defines ARCH_<NAME> once, creates the furniture or
sanitary layer from its ARCH_LAYERS row when missing (live ActiveX refuses a
missing layer), inserts with an invisible ITEM attribute, and refuses an
unknown name with the nearest catalogue names before any write.

SECTION 25 opens on this branch with the two tools, alias records (INSERT,
ADCENTER, TOOLPALETTES and English/Turkish synonyms), a regenerated inventory,
the README snapshot at 231 tools, and the branch-local group snapshot
(entity_query 10, entity_creation 19) that the track F merge recomputes.

No new ActiveX call: insert_item composes the existing layer_list,
layer_create, block_list, block_define and block_insert members. Run once live
through ComBackend on AutoCAD 2026 (scratch document, closed after): all
twenty items defined and inserted, a second double_bed insert reported
defined=false, ITEM read back as double_bed, 20 ARCH_* blocks, 21 INSERTs on
A-FURN-E-N / A-SANR-E-N, both layers created at ACI 5 / 0.18 mm.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:35:44 +03:00
Umut CANandClaude Opus 5.5 67d578e09a feat(task-5): planar faces, measured room labels and the foreign-plan reader
engineering/arch/faces.py finds the closed faces a set of straight segments
bounds: endpoints within tol merge, every segment splits where another crosses
or touches it (the T-junction a partition makes), dangling edges are pruned,
and the half-edge walk turns to the next edge clockwise so every bounded face
comes out counter-clockwise. A piece nested inside a face of another piece is
a hole in it, so a ring of walls measures its body and a room with a column
measures its floor. Areas come from engineering.measure.polygon_area_perimeter,
the arithmetic analysis_measure_entity reports.

engineering/arch/rooms.py labels a room with the area of the face its point
lies in - a 4000 x 5000 room between 200 mm walls reads 20.00 m2, never the
axis area - and writes the room record onto the name text. Opening records
close door and window gaps along the wall faces, so two rooms either side of a
door stay two rooms. rooms_detect reads any plan's lines, drops faces under
min_area and thin wall bodies (mean width under 600 mm, a declared heuristic),
reports confidence 1.0 for faces enclosed only by our wall layer and 0.6
otherwise, lists arcs as skipped rather than flattening them, and never
modifies the drawing.

Two hardenings beyond the plan text, each with a test: read_records pages the
current space once and filters to the arch layers locally (the plan paged it
once per arch layer - twelve full scans, each a cross-process call per entity
on a live seat), keeping the same layer-then-drawing order; and an opening
record whose offset falls outside its wall (a stale record) is listed in
omitted instead of raising out of the read-only detector.

No new ActiveX member is called: both tools compose entity_list,
entity_get/set_xdata, layer_list and draw_prims, whose COM paths are already
live-verified.

SECTION 25 opens on this branch with arch_room and arch_rooms_detect under
the rooms marker; until Task 9 gives arch its group they file under
entity_creation and entity_query. Alias records, a regenerated inventory, the
README snapshot at 231 tools in 25 groups and the frozen group-size snapshot
follow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:33:37 +03:00
Umut CANandClaude Opus 5.5 dae48d9037 feat(arch): the wall engine - L/T/X junctions, opening gaps and material poche
engineering/arch/walls.py turns a wall network - axis polylines with a
thickness, a justification and a material - into clean outlines. One rule
resolves every junction: at each node the segment ends are sorted by angle and
the left face of each meets the right face of the next one counter-clockwise.
That mitres an L, stops a T's stem on the crossing wall's near face (cut there,
while the far face stays one line) and cuts all four faces of an X. A T is an
axis end lying inside another wall: it is carried onto the crossing axis and
the crossing segment is split. The hub a three- or four-way node leaves is a
region of its own, so the poche has no hole.

Openings remove their width from both faces and are closed by jambs; a free
end is closed by a jamb. Each solid piece is one CCW region and one HatchArea
in the wall's material. The two face lines across each opening are kept as
thresholds - never drawn - so room_segments hands the face finder closed rooms
instead of rooms merged through their doorways.

Refused by name before any geometry: a duplicate id, an unknown material or
justification, a zero-length segment, a junction below 5 degrees, a corner
inside another wall off its axis, an end whose axis meets the other wall past
its end, a segment lying wholly inside the wall it joins, and one too short
for its junctions (engineering.measure.is_self_intersecting on its outline).
An opening that runs into a junction is returned in omitted, never dropped.

Every corner in the tests is hand-computed: two 200 mm walls at the origin
meet at (100, 100) inside and (-100, -100) outside.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:33:31 +03:00
Umut CANandClaude Opus 5.5 d5c5e90406 feat(arch): the plan model and its foundation - layers, materials, EN/TR, two generalisations
Track F starts here; the three group branches are cut from this commit.

- engineering/arch/model.py: Wall, Opening, Stair and Room as frozen
  dataclasses, and every refusal of spec 4 before anything is drawn, by path
  (walls[2].thickness): finite positive numbers, an axis of at least two
  distinct points with no zero-length segment, an arc segment refused by name
  rather than approximated, an opening on an unknown wall, past its wall,
  across an axis vertex or overlapping another (both ids named), an unknown
  material, and a typed room area - areas are measured, never typed. The
  plan travels as ACADMCP_ARCH XDATA; an opening's door/window kind and a
  stair's straight/l/u kind ride beside the payload kind as opening_kind /
  stair_kind, because one key cannot hold both.
- engineering/arch/layers.py: the arch set - thirteen ISO 13567-style layers
  with ISO 128 lineweights only (0.05 mm on the construction scratch layer,
  as every set in this repository) - and ARCH_ROLE_LAYER, the one place a
  role becomes a layer. Registered in engineering/layers.py last, which is
  what keeps the engineering.layers <-> engineering.arch import cycle closed.
- engineering/arch/materials.py: eight wall materials, one pattern each,
  every row citing its source: ANSI31 for brick (ISO 128-50 general hatching,
  acadiso.pat's own "Iron, Brick, Stone masonry"), AAC as ANSI31 at twice the
  spacing (a repository convention, and it says so), AR-CONC / AR-SAND as
  AutoCAD patterns, and the JIS A 0150 RC / stone / wood symbols. Reinforced
  concrete cannot be ANSI31 and AR-CONC in one HATCH, so it takes the one
  symbol that names it. Periods on a 1:50 sheet are recomputed from ezdxf's
  own pattern definitions by the tests. normalise_material is the one reading
  of a material name, shared by the model and wall_hatch, so the model never
  refuses "Reinforced Concrete" that the hatch table resolves.
- engineering/arch/lang.py: EN / TR vocabulary, D/W and K/P tag prefixes, and
  the decimal comma for lang="tr" (24,50 m2).
- draw_prims(role_layer=) and the XDATA codec's app_id= / kinds=: both
  generalised with the mechanical defaults, and the mechanical suite passes
  unchanged against them - that is the regression gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 22:27:00 +03:00
Umut CANandClaude Opus 5.5 b012fdf1be docs(plan): track F — 10 TDD tasks in three groups
Task 1 (main) lays the foundation every group branches from: the arch
layer set and role map, wall materials, EN/TR vocabulary, the plan model
with its ACADMCP_ARCH payload (opening_kind / stair_kind beside the record
kind), draw_prims(role_layer=) and the codec's app_id= / kinds=.

- Group W (Tasks 2-4): the wall engine (L/T/X junctions, opening cuts,
  poche), doors, windows and stairs, the async layer with read_plan and the
  exterior chains; SECTION 25 opens with four tools.
- Group R (Tasks 5-6): the planar-face finder, measured room labels, the
  foreign-plan reader and the door/window/room schedules; three tools.
- Group C (Tasks 7-8): the furniture and sanitary catalogue, the grid and
  the plan symbols; four tools.
- Final (Tasks 9-10, main after the W -> R -> C merge): arch_plan_from_spec,
  the three arch_* critique focuses, the arch pack and lean 64, then the
  v6 matrix with arch_roundtrip, four correctness checks, the live smoke
  and the docs.

Consistency pass: group W's poche now goes through draw_prims with the
arch role map (Task 1 resolves wall materials there), and the hedges on
Task 1's payload spelling and Task 5's rooms_detect shape are replaced by
the shapes those tasks pin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 16:04:06 +03:00
Umut CANandClaude Opus 5.5 3248bfbfea docs(plan): track F skeleton — 10 tasks in three groups, interfaces pinned
Header, global constraints, file structure, the interface block every task
builds against, the group/ownership table, the SECTION 25 banner protocol and
the merge notes. Task sections are drafted next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 14:50:07 +03:00
Umut CANandClaude Opus 5.5 83f192ac2f docs: track F (architectural 2D) design spec — the plan model
One plan model — a wall network with hosted openings — and one engine that
resolves L/T/X junctions, cuts openings, hatches cut walls by material and
computes rooms as the faces the walls enclose, so areas are measured, never
typed. A pure planar-face finder reads rooms from foreign plans on both
engines (the live engine has no boundary_trace). Furniture/sanitary catalogue,
grid, symbols, exterior dimension chains, schedules from the model.

12 tools (SECTION 25, pack arch), English by default with lang="tr", poché by
material, three arch_* critique focuses, benchmark arch_roundtrip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 14:40:34 +03:00
Umut CANandClaude Opus 5.5 2bba18d0f7 fix(com): hatch islands on a live seat — typed edges become temporary entities
hatch_add_boundary was refused on COM (hatch_edge_paths), so a transverse
section of any hollow part — a bushing, a flange bore — could not be drawn in
AutoCAD at all: draw_prims asked the capability map and refused up front.

ActiveX takes a loop as whole objects, which is not the same as "cannot take a
typed edge": each edge now becomes a real temporary LINE, true ARC or ELLIPSE
in the hatch's own space, appended with AppendInnerLoop, evaluated and
deleted. A loop that is only chained lines becomes one closed polyline — one
COM call instead of 180 for a flattened bore. The edge list is validated in
full first with the headless engine's messages; a loop AutoCAD refuses is
taken back out with its temporaries.

Measured on AutoCAD 2026 (25.1s), in scratch documents closed unsaved:
- a two-arc island leaves exactly 10000 - 100*pi (9685.8407) of a 100 x 100 hatch;
- the OD40/ID20 sleeve's transverse section B-B, drawn through the view engine
  and draw_prims, hatches 942.2864 mm2 — the engine's declared 180-gon;
- an open chain is refused (E_FAIL) and the entity count is unchanged.

hatch_edge_paths is now supported on both engines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 13:51:35 +03:00
Umut CANandClaude Opus 5.5 e6f685643d fix(com): a DXF type filter finds what ActiveX names differently
block_find_references asks entity_list(type_filter="INSERT"), and ActiveX
calls a block reference AcDbBlockReference, so on a live seat the filter
matched nothing and every block reported zero references. Measured on
AutoCAD 2026 (25.1s): AcDbBlockReference, AcDbPolyline, and one class per
dimension kind (AcDbRotatedDimension, AcDbAlignedDimension,
AcDbDiametricDimension, AcDbRadialDimension, AcDb2LineAngularDimension,
AcDbOrdinateDimension).

entity_list and entity_count now let the DXF name find them: INSERT matches
BLOCKREFERENCE/MINSERTBLOCK, LWPOLYLINE matches POLYLINE, and DIMENSION every
*DIMENSION class. The reported type stays the ActiveX spelling, so a caller
already filtering by it is unaffected. Verified live in a scratch document:
two inserts, one dimension and one line are found by INSERT, DIMENSION and
LINE (the INSERT filter found 0 before).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 13:39:41 +03:00
Umut CANandClaude Opus 5.5 898e775f93 bench: matrix v5 adds mech_assembly; the correctness suite grows with the mechanical and sheet checks
The evidence for tracks B and G, in the shape tracks A and E used.

- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
  addressable so an earlier report reproduces exactly rather than being only
  described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
  built from the part model and the sheet standard, through the code the
  tools call (apply_titleblock, draw_part/add_view/dimension_part,
  insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
  two numbers that cannot be negotiated: drawing_critique(focus=None) returns
  zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
  the v5 matrix). The adapter returns the focuses that fired, so a
  regression names itself, and a test drops one balloon to prove the gate
  fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
  that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
  20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
  through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
  410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
  40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
  k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
  quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
  (25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
  4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
  the drawing, a linked balloon, an xref attached / listed / detached, a
  31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
  every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
  the first call after Documents.Add is refused) and tracks its documents by
  the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
  code:
  * ezdxf dimension_diameter put the text beyond the SECOND chord point;
    ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
    from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
    front-view diameter stood on its own chain dimension. ezdxf now follows
    ActiveX.
  * draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
    missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
    every target layer before the first entity.
  * bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
    (ObjectName AcDbBlockReference) - the live parts list was empty.
  * COM _entity_info reported no dimension text_position: ModelSpace.Item
    narrows to IAcadEntity, which has no TextPosition (measured
    AttributeError); read through _com_unnarrow (TextPosition measured on
    AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
    diameter collided with its own bore.
  * pid graph raised KeyError 'start' mid-critique on a LINE whose point read
    AutoCAD refused while busy; such a line is left out.
  * the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
  re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
  the task count from the v5 matrix, the token table is remeasured with a
  TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
  collected-tests figure is retaken (3990).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 13:30:02 +03:00
Umut CANandClaude Opus 5.5 3b033033f9 feat(mech): six mech_* critique focuses, the mech pack, lean 60, aliases and the counts
The four track-B/G branches are merged; this is the integration that makes the
25 new tools one surface instead of four.

- engineering/mech/critique.py: the six focuses of spec 13, dispatched the way
  the P&ID ones are. One index per critique run, shared through run_critique's
  context, so six focuses cost one read of the drawing; a drawing with no
  ACADMCP_MECH payload yields nothing at all, which is what keeps focus=None
  cheap for a P&ID sheet or a plain DXF. Each focus has a positive and a
  negative fixture.
  Two narrowings are in the module docstring rather than hidden:
  mech_view_misaligned checks the projection axis and not the quadrant,
  because the pinned payload carries no projection angle to check it against,
  and mech_duplicate_dimension catches two dimensions in the same place rather
  than a semantic restatement, because no engine exposes what a dimension
  measured.
- The index reads what the tools actually write, not only the plan's fixture
  shape. mech_part_draw anchors its part payload on a POINT (layer MECH) and
  lists every drawn view under "views"; the plan's index read INSERT/CIRCLE
  only and one "view" per payload, so it was silent on every part the server
  draws. It now reads POINT too, expands "views" into one entry per drawn view
  (the section check looks in the section's own WCS box, the alignment check
  compares frame_bbox centres), and pages entity_list instead of one call.
  Test: a part drawn by draw_part + add_view is read and passes all six; the
  same part with its hatch erased is reported.
- Live type names, measured on AutoCAD 2026 (2026-09-23, scratch document,
  closed): ObjectName AcDbBlockReference -> BLOCKREFERENCE, AcDbRotatedDimension
  -> ROTATEDDIMENSION, AcDbAlignedDimension -> ALIGNEDDIMENSION, AcDbPoint ->
  POINT with GetBoundingBox = the point. The index reads BLOCKREFERENCE as an
  INSERT and any *DIMENSION as a dimension; a wrapper test reports the ezdxf
  entities under exactly those measured names.
- mech_thread_unrepresented uses the view engine's own MINOR_RATIO, and checks
  the thin line ISO 6410 draws: the minor diameter of an external thread, the
  major of an internal one.
- mech_bom_balloon_mismatch: identical parts share one balloon (ISO 6433
  balloons a parts-list row), so a second M12x60 is covered by the first one's
  balloon instead of reported.
- TOOL_PACK_NAMES has "mech" (the 14 tools of SECTIONS 21-23). SECTION 24
  stays core: a frame, a title block, a parts list and an xref are universal
  drafting, not a vertical. Lean gains five and reaches 60 (54 with
  TOOL_PACKS=core).
- _GROUP_TAG_PRIORITY gains sheet directly after mech; the mech group label
  becomes "mechanical". SECTION 24's last four tools (xref_attach,
  xref_manage, image_attach, drawing_export_dwg) now carry the sheet tag, so
  group, pack and SECTION are one list: 229 tools in 25 groups, and
  engineering/blocks/entity_creation/drawing return to their pre-SECTION-24
  sizes.
- Ten golden tuning cases, two of them Turkish (with the diacritic spellings
  added to the alias records next to the ASCII-folded ones). The shared
  AutoCAD command set needed no change: the merge had already resolved TABLE,
  REVCLOUD, SECTIONPLANE, INSERT, ARRAY and SAVEAS, and nothing is stale.
- Inventory regenerated, the frozen group-size snapshot retaken from the live
  registry, and the README release-snapshot / pack / lean figures taken from
  scripts/check_doc_numbers.py rather than typed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 12:48:44 +03:00
Umut CANandClaude Opus 5.5 a6c9c0d927 merge: tracks B+G group G
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 12:25:45 +03:00
Umut CANandClaude Opus 5.5 76e8647e1b merge: tracks B+G group A
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 12:25:44 +03:00
Umut CANandClaude Opus 5.5 36dab4cec6 merge: tracks B+G group S
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 12:25:44 +03:00
Umut CANandClaude Opus 5.5 5b70533505 merge: tracks B+G group M
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 12:21:09 +03:00
Umut CANandClaude Opus 5.5 5388440c27 fix(task-7): rotation honoured end to end, each view dimensioned once, refusals that name their path
- rotation turns the whole sheet of views about `at` as one rigid body: the
  prims, the recorded WCS `bbox` (plus the sheet-frame `frame_bbox` the next
  view is laid out against), the detail marker, and every dimension intent
  (points, text position, and the linear measuring direction + rotation).
  Measured headless: rotation=90 at (100,100) now draws (70,100)-(100,172),
  records exactly that box, and leaves only the 2 diameter chord defpoints
  off-geometry (was 4, up to 47.5 mm away).
- dimension_part dimensions only views whose record is not `dimensioned`,
  takes `views=[indices]`, refuses a view already dimensioned by name
  (ISO 129-1), writes the flag back, places the hole table once, and refuses
  a fit no target view claims before drawing anything. A view the dimension
  layer emits nothing for (a detail) is reported in `skipped` and left
  unflagged. mech_dimension_part gains the `views` argument.
- draw_prims checks hatch_add_boundary's {"ok": False}: the hatch is removed
  and the failure raised, never left solid through the bore. An engine whose
  capability map says `hatch_edge_paths` is unsupported (the live one) is
  refused before any prim of the view is drawn.
- a view scale other than 1.0 is refused by name in draw_part and add_view
  (views are drawn full size; the scale would only have been recorded).
- add_view's collision step compares real rectangles on both axes and steps
  along the projection axis layout_origin chose, so a third-angle view is
  never shoved back onto the front view and a top view keeps its alignment.
- draw_from_spec validates `at` / `rotation` into the `parts[i]:` path and a
  transaction that cannot open is a ValueError (a ToolError at the tool).

No new ActiveX member is called: the change composes existing contract
members (dimension_linear rotation, entity_delete, capabilities()).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 12:20:25 +03:00
Umut CANandClaude Opus 5 301610620c fix(task-14): detach an xref out of every space, and stop the live engine inventing facts
Three blocking findings on the refs group, all of them the same shape: a
number or a refusal the caller was entitled to read as measured.

1. ezdxf xref_manage: the victim scan and the `inserts` count both walked
   `doc.modelspace()` only, then dropped the definition with
   `delete_block(safe=False)` -- the flag that suppresses the in-use check.
   An xref shown on a paper-space sheet was reported as 1 insert where the
   drawing held 2, and detaching it left that sheet INSERT with no BLOCK
   behind it: `ezdxf.recover` reports `FIX 103 UNDEFINED_BLOCK: Deleted
   entity INSERT(#34) without required BLOCK definition` and the repo's own
   drawing_audit silently deletes the sheet geometry. The scan now walks
   `doc.blocks`, which is every entity space (model space, each paper-space
   tab, every block definition) and is exactly what ezdxf's own safe check
   searches with `doc.query('INSERT[name==...]i')` -- hence the
   case-insensitive name match. `delete_block` is now `safe=True`, as the
   plan's Interfaces block pins it, so a future miss raises instead of
   writing a corrupt file.

2. COM xref_manage(action='list') reported `kind: "attach"` for every row,
   a literal constant. Re-measured on this seat's registered type library
   (C:\Program Files\Common Files\Autodesk Shared\acax25*.tlb, "AutoCAD
   2025 Type Library", installed with AutoCAD 2026): IAcadBlock's xref
   members are exactly IsXRef, Path, Name, Reload, Unload, Bind, Detach,
   XRefDatabase -- no overlay indicator exists on the interface, so an
   overlay was reported to the client as an attachment. The row now says
   `kind: None`, the same honesty the adjacent `inserts` field already had,
   and the contract plus the tool docstring say so.

3. COM xref_attach had no duplicate-block-name guard, although
   contracts/refs.py and the tool docstring both promise the refusal and
   ezdxf implements it. Two different part.dwg files, or a re-attach after a
   repath, silently re-pointed or half-wrote the definition while the
   payload claimed the new path. Added `_block_names(doc)` (Blocks.Count /
   Blocks.Item(index) / block.Name -- the members xref_manage already walks)
   and the refusal fires before AttachExternalReference is dispatched, with
   the same message the headless engine gives. Lower-cased, because AutoCAD
   symbol-table names are case-insensitive.

Tests: five new ones, each failing against the unfixed backends -- an xref
shown on Layout1 is counted and detached with the drawing, the detached file
audits clean through ezdxf.recover, delete_block's in-use check is pinned,
the live listing reports kind None (against a fake that models only the
measured members, so it cannot enshrine an invented one), and the live attach
refuses a held name (xref and plain block, either case) without dispatching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 23:35:44 +03:00
Umut CANandClaude Opus 5 8b14d4ef8d feat(task-7): SECTION 21 - the mechanical part model as six tools
engineering/mech/draw.py is the only module in the package that touches a
backend: it turns the pure engine's primitive descriptions into entity_create_*
and dimension_* calls, places them in WCS, and writes the part model back onto
the drawing as ACADMCP_MECH XDATA on an anchor POINT. It is not a contract
member and neither are the tools - exactly like gear_draw_* and
titleblock_apply_iso_a3, it composes the generic members, so it is written once
and works on both engines by construction and no capability key is needed. No
new ActiveX call was written, so there was nothing to probe on the live seat;
measured instead that every one of the 23 backend members draw.py calls exists
on ComBackend and that ComBackend has no abstract methods left.

mech_part_draw takes one model - a turned profile or a plate outline plus typed
features - and draws its views in first-angle positions (third on request),
dimensions them and records the placement. mech_view_add reads the model back
out of the drawing and adds a side, top, section (full/half/offset/revolved) or
detail view months later without the caller re-describing the part.
mech_dimension_part lays out ISO 129 dimensions with no overlapping text,
resolves ISO 286 fits against the measured nominal, and moves the holes into a
table past a threshold. mech_hole_pattern exposes the same expansion for a
drawing that was never built from the model - with its own diameter guard,
because Hole.validate needs a part and a standalone pattern has none.
mech_part_inspect reads the payload back and never modifies the drawing.

mech_part_from_spec draws a whole sheet inside one transaction. Every part is
validated before the transaction opens, so a refusal leaves the drawing
untouched and names the path (parts[1]: segments[0].d_outer: ...), and the
rollback is shielded with the shape engineering/pid/spec.py documents: anyio
cancellation is level-triggered, so a bare await in the handler would be
cancelled before the rollback body ran and leave half a sheet inside an open
transaction.

fits goes through dimension_intents(fits=...), the API Task 6 shipped, rather
than being stamped on at draw time: that is the path that validates a key by
name and refuses a fit on a reference dimension or on a hole that moved into
the table. It is scoped per view first - a fit on segment[0] names nothing in
the end view, and refusing there would make a correct three-view sheet
undrawable - and any key no view claimed is handed back to dimension_intents so
the refusal is written in its own vocabulary.

SECTION 21 registers the six tools in the new mech pack, with alias records
(SECTIONPLANE, ARRAY plus shop-floor synonyms in English and Turkish - mil,
kademeli mil, kama, pah, kesit, gorunus), a regenerated tool inventory, the
README release snapshot at 210 tools in 24 groups, and the frozen group-size
snapshot gaining mech: 6. Lean is deliberately untouched here: spec section 11
puts three of these on the lean surface together with group G's two sheet tools,
and Task 15 makes that edit once across all four branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 23:17:29 +03:00
Umut CANandClaude Opus 5 bd73b3a854 fix(task-6): a reachable ISO 286 route, a tolerance schema that refuses, a reference dimension
Three review findings, all root causes in `engineering/mech/dimension.py`.

1. The ISO 286 fit path was unreachable from the module's own pipeline.
   `resolve_tolerance` resolved `intent.fit` correctly, but nothing produced a
   DimIntent carrying one: no feature and no segment has a fit, and
   `dimension_intents` constructed every intent without `fit=`. Only a
   hand-built DimIntent - which is what the tests used - reached the code, so
   the spec's `mech_iso286_on_dimension` and the `mech_assembly` benchmark's
   "ISO 286 fits on the bearing seats" had no route.

   `dimension_intents(..., fits={key: code})` now stamps the code onto the
   intent its key names, and the part's own diameters became addressable one
   at a time through the new `segment_key(i, bore=...)` -> "segment[1]",
   "segment[1].bore"; a feature's stay addressable by id. A key that names
   nothing is refused with the names that exist, a malformed code is refused
   where it is attached rather than at draw time, a fit on a reference
   dimension or on one that already carries a tol is refused, and a fit on a
   hole whose diameter moved into the hole table (which has no tolerance
   column) is refused by name instead of vanishing. Task 7's planned
   `dimension_part(..., fits=None)` passes straight through - the argument is
   additive and keyword-only, so every call written against the pinned
   signature keeps working.

   The fit also now reaches the *text*: `intent_text` expands the `<>`
   placeholder and appends the code, so `text_rects` measures the string that
   is actually drawn and a hole that already names itself ("⌀6") keeps its own
   text and gains the code.

2. An explicit tolerance was silently discarded when `tol` omitted "mode".
   `mode = tol.get("mode", "none")` and `build_dim_override(0.05, 0.05,
   "none", None)` returns `({}, None)` without raising, so a ±0.05 resolved,
   echoed its own numbers back in the payload, and drew a dimension with no
   DIMTOL/DIMTP/DIMTM at all. `DimIntent.tol` now has a schema - {"mode",
   "upper", "lower"} and nothing else - validated by `_explicit_tol`: an
   unknown key, a missing "mode", a non-dict, and deviations under a mode that
   writes none are each refused by name. The mutual-exclusion guard reads
   `tol is not None` rather than truthiness, so `tol={}` is refused too.

3. The ISO 129-1 "no measurement twice" test was vacuous and the property was
   false. The old test built the key `_dedupe` uniquifies as the last step of
   `dimension_intents`, so it could not fail; meanwhile `style="chain"` emitted
   20, 40, 15 *and* 75 as four plain DIMENSIONs. `redundant_linear` now finds
   the span that shorter ones tile end to end on the same axis and level, and
   `dimension_intents` marks it as an auxiliary (reference) dimension - the new
   `REFERENCE_FORMAT` "(<>)", which renders "(75)". The plan's own test still
   holds (the 75 is present); it is no longer a fourth plain dimension. The
   error is one-sided on purpose: a tiling that is found is real, a tiling the
   greedy walk misses leaves the dimension plain, which is the direction the
   repository's critique rule wants. Baseline and ordinate parenthesise
   nothing, and two horizontal chains at different heights are not each other's
   pieces.

Tests: the vacuous gate is replaced by four that fail against the pre-fix code
- `redundant_linear` on the shaft's plain intents returns (3,) there and ()
here; the parametrised "a tolerance is either refused or reaches the dimension
variables" gate resolves the mode-less shape to an empty DIMVAR override
there; and `fits=` is a TypeError there. `_dedupe` is now exercised through a
duplicate fed in via the view's own dims, which the collector does not
generate itself. 46 tests in tests/test_mech_dimension.py (was 23).

Live AutoCAD: no finding was measured live and no new ActiveX call was
written - this module is pure Python with no backend, no async and no DXF. A
DIMTOL probe against the running seat was attempted twice and
`GetActiveObject("AutoCAD.Application")` blocked past the ~90 s contention
window both times, so the live engine was unavailable; no scratch document was
created (the probe never got the application object) and none was left open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 23:04:25 +03:00
Umut CANandClaude Opus 5 3ef316d520 feat(refs): xrefs, raster underlays, data extract and real DWG output
SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.

The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:

  IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
      Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
  IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
      RotationAngle)
  IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
      not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
      XRefDatabase (get)
  IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
  AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
      ac2013_dwg=60 ac2018_dwg=64 (=acNative)

The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.

Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.

Three capability keys, declared in both maps:

  dwg_write  -- measured absent here (odafc.is_installed() is False), so the
                ezdxf entry is a runtime re-evaluation like viewport_render's
                matplotlib check rather than a hardcoded False: installing the
                ODA File Converter starts the call working with no edit to the
                map. The refusal names the install route and drawing_export_dxf.
  xref_live  -- reload and bind need a live seat's xref manager and are refused
                headlessly; list, detach and path are headless and are not
                behind the key.
  xlsx_write -- openpyxl is optional and absent here; CSV is always available,
                so the refusal never blocks the deliverable and says so.

Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 23:03:57 +03:00
Umut CANandClaude Opus 5 de8dc8f813 fix(task-13): read revision/balloon state from the space it is drawn on, and never at 200
Three findings, one family: a guard that reads the WRONG SET silently stops
guarding and the drawing gets a duplicate or a wrong number with no refusal.

1. Layout-scoped state was read from the wrong space. add_revision called
   read_revisions and add_balloon called read_balloons BEFORE enter_layout;
   both read entity_list, which follows the current space, so the guard
   inspected Model while the rows and balloons lived on the layout -- and the
   layout is the sheet workflow (sheet_frame and titleblock_apply both take
   one). Measured headlessly: two revision_add(rev="B", layout="SHEET1") calls
   both returned created=True, handles 46 and 5A, both bbox
   {min [230,273], max [410,287]} -- a second heading band and a second B row
   drawn exactly on top of the first, twelve TEXTs on the tab instead of six.
   Both reads now happen inside the entered layout, and the whole body sits in
   the try/ so the caller's tab comes back on the refusal path too.

2. bom_extract truncated the parts list at 200 INSERTs and printed a wrong QTY
   into a real TABLE. extract_records passed its own limit=200 default straight
   to entity_list, the tool exposed no limit, and nothing in the payload said
   the list had been cut: 250 bolts reported [("ISO 4014 - M12x60", 200)].
   all_entities now pages entity_list (offset loop, PAGE_SIZE 1000, plus a
   no-new-handles guard so a backend that ignored offset cannot spin) and
   extract_records(limit=None) reads the whole drawing. bom_extract exposes
   limit (0 = no cap) and reports truncated measured, by asking for one record
   past the cap.

3. The balloon clash guard did not fire in two routine cases. (a) targets is
   optional in the tool, so the clash test compared () != () -- False -- and
   two untargeted balloons carried item 1 with no refusal. The renumber arm
   above has already returned for the balloon that stands for these targets, so
   anything still carrying the number is a clash, whatever its targets; the
   message names the handle and says "no targets" when it has none. (b)
   read_balloons and read_revisions took entity_list's 200-entity default, so a
   drawing whose first 200 CIRCLEs are a hole pattern read back no balloons at
   all and every guard passed blind -- and index = len(existing) restarted at
   0, stacking revision bands. Both readers now go through all_entities.

Evidence, headless (ezdxf), each finding's own repro before -> after:
  1  created True/True, handles 46+5A, 12 TEXTs -> created True/False, one
     handle 46, 6 TEXTs; renumbered False/False -> False/True, same handle 33
  2  records 200, rows [(..., 200)] -> records 250, rows [(..., 250)]
  3a two balloons handles 2F+35, no refusal -> refused, naming balloon 2F
  3b read_balloons() == () with 206 circles -> the balloon past the 200th
     circle is found, the rerun renumbers, the item-1/BBB clash is refused
  3c 200 notes + rev A twice + rev B -> created True/False/True, row 1 then
     row 2 (was: every band at row 1, on top of the last)

Six regression tests added: one per finding plus the layout-scoped twin of
each, which is what would have caught all three.

The live engine was UNAVAILABLE for this round:
win32com.client.GetActiveObject("AutoCAD.Application") did not return within
90 s against the running acad.exe (PID 40068), and the ComBackend probe timed
out on its first call at both COM_CALL_TIMEOUT=60 and 180. No second AutoCAD
was launched and no document was opened. No new ActiveX call is introduced by
this fix -- it only reorders existing calls and pages entity_list(offset=),
which both engines already implement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 22:39:10 +03:00
Umut CANandClaude Opus 5 01213fc5f5 feat(task-6): dimension intents, ISO 286 fits and an overlap-free layout
engineering/mech/dimension.py collects what the features asked to have
dimensioned, adds the part's own sizes (chain, baseline or ordinate for the
axial lengths, a diameter per segment and per bore, the two overall sizes of a
plate), and removes the duplicates - ISO 129-1 says each dimension appears once,
and a test asserts no measurement is placed twice.

Fits and tolerances are resolved through the modules that already own them:
engineering.fits for an ISO 286 code and engineering.tolerances for an explicit
ISO 129 tolerance, including build_dim_override's sign convention (the lower
deviation is passed as the magnitude of the minus side, exactly as
server.py::_fit_to_tolerances does). A H7 bore at 40 mm resolves to +0.025/0 -
the correctness check the spec names. A fit together with an explicit tolerance,
a fit outside the authored ISO 286 range, and a fit on an intent with no
measurable nominal are each refused by name rather than guessed.

The layout is the part that had to be measured rather than asserted: every
intent's text is turned into a rectangle by the same helper the test uses, and
an intent is placed on the lowest level whose rectangle is free, with the level
pitch clamped to clear the text whatever the caller passed. The test then
asserts the placed rectangles are pairwise disjoint - including on a crowded
plate and with a deliberately too-small step. That is the same property the
repository's dim_overlap critique focus checks on the finished drawing.

The plan's layout clamped that pitch to the text HEIGHT on every axis, which is
only the stacking direction for the row of dimensions under the view. Beside the
view the levels march in x, so the extent they have to clear is the text WIDTH:
measured here, three diameter labels are 9.45 mm wide (9.45, 5.6) at a 8.0 mm
pitch, so a shaft's three segment diameters - all on the y = 0 centreline, hence
all on different levels - overlapped by 1.45 mm. The pitch and the within-level
span are now both taken per axis from the same _text_size helper text_rects
uses, which makes the disjointness a property of the algorithm rather than of
the label lengths that happened to be in the test.

Over hole_table_threshold holes the per-hole diameters leave the view and become
a table instead, grouped by diameter, thread, counterbore and depth, one tag per
group and the quantity on every row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 22:27:09 +03:00
Umut CANandClaude Opus 5 978c1075ad fix(task-5): cut faces on the real chord, cross sections that see features, a section with no dashed cut edge
Three defects, all of them the same mistake in three places: the cut face was
built from something other than the geometry it claims to show.

1. A prismatic cut face removed a feature by its BOUNDING BOX. `Hole.removal_box`
   is a square, so the gap punched in the cut face was the full diameter
   wherever the plane passed. On the plan's 80x50x8 plate with a Ho10 hole at
   (40, 25): a plane at y=29.9 grazes the hole (true chord 1.990) and the
   section still showed an 8 mm gap through solid metal - 560.0 mm2 hatched
   against a true 624.08, a 64 mm2 error in the hatched area AND a wrong drawn
   outline. `Slot` had the same obround-as-box. Features now answer
   `cut_spans(part, p1, unit)` with their own exact chord: `_circle_chord` for a
   hole, the union of two circles and a rotated rectangle for a slot, the
   polygon's own crossings for a pocket, and an exact slab clip of
   `removal_box` as the default. `_box_span` (the corner projection, which is
   the box's shadow and not a chord at all) is gone.

2. A transverse revolved section ignored every feature and the honesty rule
   never fired. A cross section through a DIN 6885-1 keyway came out as a plain
   hatched disc with `omitted == ()` - the commonest mechanical cross section
   there is, hatched straight over the 12x5 keyway. The face is now built by
   `_transverse_face`: its radii come from `radial_profile` (so a groove, an
   undercut, a bore or an ISO 6410 internal thread is in the face already,
   which the raw segment diameters could not report), a non-axisymmetric
   feature is asked for its own `cross_section` notch record, and one the plane
   really passes through with no such record is listed in `omitted` with its
   reason rather than hatched over. `Keyway._notch` is the one calculation the
   end view and the cut face both come out of.

3. A section drew its own cut boundary as a HIDDEN (dashed) line, because the
   longitudinal branch reused `silhouette_prims` and the silhouette draws the
   bore dashed - correct for an un-cut view, wrong for a section, where that
   line IS the hatch loop's boundary. ISO 128-50 bounds a surface lying in the
   cutting plane with a continuous wide line and ISO 128-3 leaves hidden detail
   out of a sectional view; via ROLE_LAYER['hidden'] this would also have put
   the cut boundary on the HIDDEN layer in Task 7. New `section_profile_prims`
   emits the cut outline all-continuous, and `half=True` keeps the dashed bore
   on the -Y half only, which is the half a half section does not cut.

And the claim the last commit made without earning it: the hatched area and the
drawn outline are now literally one calculation. The longitudinal loop is the
*emitted* outline flattened (`_flatten_path`), so a chamfer or fillet that
substitutes a silhouette corner is cut out of the hatch exactly where it is
drawn - a 2 mm 45 deg chamfer takes its 2 mm2 off the 1000 mm2 half face
instead of being hatched over.

Eleven new tests, every one of them red on the parent commit: the hole chord at
0/4/4.9 mm off centre, a tangent plane leaving the face whole, the 45 deg slot,
the keyway cross section against its own analytic area, the feature a cross
section cannot project, the feature the plane misses, the groove read off the
profile, the continuous cut boundary, the half section's dashed half, and the
chamfered cut face.

No new ActiveX member is called: all three fixes are pure geometry in the
backend-free `engineering/mech` package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 22:16:47 +03:00
Umut CANandClaude Opus 5 72bd17d024 feat(task-5): sections, cut faces, material hatch and detail views
engineering/mech/views.py grows the two remaining view kinds. A section's cut
faces come out of the same radial profile the silhouette does, so the hatched
area and the drawn outline are one calculation and cannot disagree: a solid
shaft's half-section loop measures exactly the sum of its segment rectangles,
and a bore closes the loop on itself rather than being hatched over.

ISO 6410's section convention falls out of the model rather than being special-
cased: an internal thread's radial_edit already raises the bore to the major
diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the
feature - a pocket marked no_section_hatch is drawn and left unhatched, while
the rest of the cut face is hatched with the part's ISO 128-50 material.

Four styles, each either implemented or refused by name with the alternative
rather than approximated: full and half on a longitudinal plane through a
revolved axis; full and revolved on a transverse plane (the annulus, flattened
at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than
hidden); full and offset on a prismatic part, where through features split the
cut face and pockets reduce it to the remaining thickness. A plane that misses
the part, or that grazes an outline vertex so its crossings cannot be paired, is
refused instead of producing a guessed cut face.

Details scale a circular region of a parent view, emit the ISO 128-34 circle and
letter for the parent and the "A (5:1)" label for the detail itself, and report
every feature that falls outside the circle in omitted - the honesty rule again.

Three test assertions were corrected to the measured truth rather than the
engine bent to them, each verified by running the code:

* the ISO 6410 test asked for the whole cut-face loop's minimum to be the M20
  major (r=10). It is not, and must not be: over the 5 mm that is drilled but
  not threaded the loop closes on the real 16 mm drill (r=8). The loop is
  ((0,10),(0,25),(25,25),(30,25),(30,8),(30,8),(25,8),(25,10),(0,10)). The test
  now pins both spans, which is the convention it meant to assert.
* the transverse-annulus areas were pinned at rel=2e-4. An inscribed regular
  180-gon is exactly 1 - (n/2pi) sin(2pi/n) = 2.0307e-4 under the circle, so
  2e-4 rejects the very flattening CIRCLE_SEGMENTS declares. The tolerance is
  now 2.1e-4 and the test computes the deficit and pins it, so the number is
  derived rather than guessed. CIRCLE_SEGMENTS' docstring now states both
  figures: 0.015% radial (the 2 degree chord's sagitta) and 0.0203% of area.
* tests/test_mech_views.py pinned VIEW_KINDS == ("front","side","top"), which
  this task deliberately grows. It now pins the three orthographic kinds as the
  first three and the full five-kind tuple.

_is_longitudinal takes only the plane; the plan's signature also took the part
and never read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 21:40:29 +03:00
Umut CANandClaude Opus 5 499b642fbd feat(sheet): revisions, the ISO 7573 parts list and ISO 6433 balloons
SECTION 24 grows to six tools: revision_add, bom_extract, bom_table,
balloon_add.

bom_extract reads. It walks the INSERTs through the generic members already in
the contract -- entity_list, block_get_attributes, entity_get_xdata -- and
returns parts-list rows from the ACADMCP_MECH std_part payload, falling back to
the block's own ATTRIBs and reporting which of the two each row came from. A
block with neither is skipped rather than guessed at, and the tool never writes
to the drawing.

bom_table draws the rows as a real TABLE, 180 mm wide (ISO 7573: the item list
is as wide as the title block), growing upwards from directly above it with the
heading row against the block and item 1 the row above -- which is what ISO 7573
asks for on a drawing. A TABLE is drawn downwards from its insertion point, so
the upward list is inserted a table height above its anchor with the heading as
the last row; one table_layout feeds both the real TABLE and the pure
table_prims, so the two renderings cannot disagree.

balloon_add draws the ISO 6433 reference -- numeral, leader, dot on the item --
and writes the balloon payload naming its targets. A rerun over the same
targets renumbers that balloon instead of drawing a second one, and an item
number already used for different targets is refused by name with the other
balloon's handle.

revision_add appends a row to the revision block, is idempotent per revision
code (a repeat draws nothing at all), and optionally clouds the changed regions
and tags them with the triangle. Clouds are refused up front, carrying
capability "revcloud", on an engine that has no revcloud member -- the COM
backend -- rather than leaving half a revision behind.

Declared: ISO 7200 does not standardise the revision block's geometry and
ISO 7573 does not fix the item list's column widths or row height, so both
reuse this repository's 180 mm / 7 mm ruling and say so in their SOURCE
docstrings. ISO 6433's "numeral at least twice the dimension character height"
is met by the numeral being 1.4x the balloon radius: the 4 mm default clears
2.5 mm dimension text, and balloon_add's docstring tells a sheet dimensioned at
3.5 mm to pass radius=5.0.

Three corrections to what the plan wrote, each measured rather than assumed:

* extract_records read `block_get_attributes(...)["attributes"]`. Both engines
  return the flat {tag: value} mapping itself (ezdxf_backend.py:6687 builds it
  from `ent.attribs`, com_backend.py:5703 from `ref.GetAttributes()` as
  {TagString: TextString}), so every attribute fallback would have been
  silently empty -- measured headlessly, where a PLATE insert carrying only a
  DESIGNATION ATTRIB extracted to nothing.
* draw_bom_table now reports `entity_type` / `representation` /
  `child_handles`. `entity_create_table` returns EntityInfo(type="TABLE") whose
  handle is the first CHILD of a composite on the headless engine, so reading
  that handle back gives a rule LINE, not an ACAD_TABLE -- the contract
  tests/test_table_leader.py already pins. A caller that means to edit the
  table afterwards has to be told which of the two it got.
* add_revision reports one `tags` entry per tag asked for ({at, handles}), not
  one per entity: a tag is two primitives, so a flat handle list claimed twice
  as many tags as the caller requested.

Discovery: `bom_extract` / `bom_table` / `balloon_add` each entered the index
heavy with "parts"/"list" tokens and took top-1 for the bare query "parts list"
away from `entity_create_table` (a BM25 tie broken alphabetically). Narrowed on
my side only -- the reader speaks of the bill of materials, the drawer names a
table, the balloon names an item -- never by touching the golden case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 20:48:50 +03:00
Umut CANandClaude Opus 5 b2f858212f fix(task-4): the end-view occlusion rule, the double-drawn groove, the left view
Three defects in the orthographic view engine, all one missing idea: the engine
had no visibility rule and no single owner for the silhouette.

1. `_end_circles` inverted the hidden-line rule. Walking the bands from the
   viewing end it drew a circle only when its radius EXCEEDED everything nearer,
   and roled every such circle `hidden` unless it was the first band - so the one
   edge that CANNOT be occluded (a step face larger than the material in front of
   it) shipped dashed on HIDDEN, and the circle that really is covered was not
   drawn at all. `_front_max` is now the rule: an edge of radius r at station x is
   continuous when r is larger than every outer radius between x and the observer,
   dashed when it is not, and it is always drawn. Bores follow the mirror image -
   you see down a bore only while it stays at least as wide. Each distinct radius
   is emitted once, because two coincident circles are a duplicate entity.
   The same rule now roles a feature's axis-centred end-view circle, whose axial
   station comes from `removal_box`; a feature that reports none keeps its own
   verdict rather than having one guessed.

   Ground truth by ray cast along the axis of the same solid of revolution
   (segments 20x25, 40x30, 15x20; observer at +X, probe just inside each edge):
     r=10   at x=75 -> first surface x=75 -> VISIBLE  (was visible, kept)
     r=15   at x=60 -> first surface x=60 -> VISIBLE  (was HIDDEN, fixed)
     r=12.5 at x=20 -> first surface x=60 -> HIDDEN   (was not drawn, fixed)

2. Every axisymmetric groove was drawn twice - once as the notch
   `radial_profile` cuts into the silhouette, once by the feature's own
   `prims()`, which `_feature_layer` appended unconditionally. Six exactly
   coincident LINEs per grooved shaft, which is precisely what
   `critique.py::_check_duplicate_entities` matches, so premium rule 7 could
   never be met on a part carrying a retaining or O-ring groove. `_profile_owned`
   now drops a feature primitive that the body already emitted, and drops a
   revolved front view's `visible` silhouette line from any feature that edits
   the radial profile: the spec's rule is that grooves cut a notch in the
   silhouette, and the notch belongs to the one calculation. Suppression is
   "the profile already drew this", not "this feature edits the profile", so a
   blind bore keeps the bottom line the profile cannot express, and an internal
   thread keeps its ISO 6410 limit line.
   A groove straddling a step was worse than duplicated: the feature reads one
   surface radius for a span that has two, so it drew a groove floor through
   solid material and left the +Y path with four odd-degree nodes. It now has
   two - its two ends on the axis.

3. `build_view` validated `side` and put it in ctx, but the prismatic branch
   ignored it: `side="left"` returned the right-hand view, with every feature on
   the wrong side of the sheet and a symmetric outline rectangle to hide it. An
   observer at -X sees screen-right = -Y, so the feature layer is now mirrored
   about the outline's mid-Y. The top view is always seen from above and does
   not move with `side`; that is now said in the docstring and pinned by a test.

Tests: the two that pinned the inverted rule are rewritten around the ray cast,
and one of them contradicted its own name. Seven tests added, and the prismatic
side-view test's hole moved off the y=25 mirror axis, where it could not tell a
left view from a right one. Nine of them fail against the previous `views.py`
and pass against this one.

Live AutoCAD was unavailable for a SOLPROF cross-check: the running AutoCAD 2026
(pid 40068) reports Responding=False and `GetActiveObject("AutoCAD.Application")`
never returned inside 100 s, so no scratch document was created and none is left
open. No new ActiveX member is called by this change - views.py is pure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 20:38:57 +03:00
Umut CANandClaude Opus 5 34079ad0b4 fix(task-11): hatch the cut face, not its chord, and take the scaffolding with it
draw_material_hatch's handles branch read only the traced loop's `points`
and dropped the parallel `bulges`, so a cut face bounded by an arc was
hatched as the chord polygon - exactly the failure README.md and the
comment at backends/ezdxf_backend.py:1172-1176 name. MEASURED headless on
a 40x10 box closed by a 180 degree r=20 arc: boundary_from_entities ->
area 1028.318531, draw_material_hatch -> {'ok': True, 'points': 4} and
entity_measure(hatch) -> {'area': 400.0, 'exact': True}. 628.3 mm2, 61% of
the face, silently unhatched, and the payload stated no accuracy.

entity_create_hatch takes a vertex list on both engines, so an arc edge has
to reach it as vertices; the bug was dropping the curve, not the fact of
vertices. Each bulged segment is now expanded onto its real arc by
_flatten_bulge, spaced so no chord departs from it by more than the
declared HATCH_FLATTEN_SAGITTA (0.01). Same repro after: points 53, area
1027.905195 against the exact 1028.318531 - an error of 0.413 (0.040%),
and the payload says so rather than claiming exactness: `accuracy`
"flatten_tolerance" or "exact", `flatten_tolerance`, `curved_edges`,
`area` (the polygon actually written) and `boundary_area` (the loop's own
exact area) so the two can be compared instead of trusted. `accuracy` is
decided by comparing those two numbers, so a backend that reports no
`bulges` at all is still reported approximate rather than assumed exact. A
straight-sided face reads `exact` / 400.0 as before.

The handles branch also had no area guard - the "at least three points"
refusal only ever covered `boundary` - so a LINE plus a 180 degree ARC
traced to a two-vertex loop and wrote a HATCH of area 0.0 with ok: True.
That case now hatches the real semicircle (37 points, 156.880337 of
157.079633), and a loop that genuinely encloses nothing (three collinear
segments, boundary_from_entities area 0.0) is refused by name before
anything is written: "the hatch boundary encloses no area (measured 0)".

Second: boundary_from_entities does not merely compute a loop, it *writes*
one. The traced LWPOLYLINE was left in the drawing on layer 0 and named
nowhere in the result, so the caller could neither find nor delete it, and
drawing_critique / drawing_finalize scored it against the drawing under the
layer-discipline rule. MEASURED headless on a 40x10 rectangle of four
LINEs: 4 entities before, 6 after, by_type {'LINE': 4, 'LWPOLYLINE': 1,
'HATCH': 1}, the polyline on layer '0'. After: 5 entities, {'LINE': 4,
'HATCH': 1}, and the result carries traced {'handle': '34', 'removed':
True} - the boolean is reported rather than assumed, so a delete that
failed is visible. The delete runs in a `finally:`, so the refusal paths
above do not leave scaffolding either (3 entities before and after).

Third: the module SOURCE block asserted the cutting-plane line's thin
middle was "the `center` role (CENTER, 0.25 mm)" and that the pair gave
"the ISO appearance" at 2:1. engineering/layers.py:19 defines CENTER at
0.18. MEASURED headless after draw_section_line on a fresh EzdxfBackend:
layer_list reports GEOMETRY lineweight 50.0 and CENTER 18.0, i.e. 0.50/0.18
= 2.78:1. A fabricated number in a standards SOURCE block is the one thing
these blocks exist to prevent, so the block now states the measured pair,
names the ratio as 2.78:1, and does *not* quote the standard's own
wide:narrow ratio, which this module cannot transcribe. The wrong figure in
the previous commit message (64a57d7) stands uncorrected in history; this
paragraph is the correction.

Tests (all four fail against the reviewed code, verified by re-running them
with _flatten_bulge and _discard_traced_loop stubbed back to the old
behaviour: 3 failed, and the SOURCE-block assertions checked against
HEAD:engineering/mech/marks.py -> [False, False, False]):
  - test_a_bulged_segment_is_expanded_onto_its_real_arc - every emitted
    vertex on the circle, every chord sagitta inside the tolerance
  - test_an_arc_bounded_cut_face_is_hatched_on_the_arc_not_its_chord -
    and entity_measure agrees with the payload
  - test_a_straight_sided_face_is_still_reported_exact
  - test_the_traced_outline_is_removed_and_reported_not_left_on_layer_0
  - test_a_loop_that_encloses_no_area_is_refused_and_leaves_nothing_behind
  - test_the_polygon_area_helper_is_orientation_blind
  - test_the_section_line_source_block_quotes_the_layer_set_it_draws_on -
    reads ENGINEERING_LAYERS, so the sentence cannot drift again
  - test_the_cutting_plane_line_really_lands_on_those_two_lineweights

No new ActiveX call was written, and nothing changed here can reach a live
seat: boundary_from_entities is capability-refused on COM
(backends/com_backend.py:1488, boundary_trace
FeatureCapability(False, "no_activex_member_command_only")), so the
handles branch is headless-only; the `boundary` branch is unchanged but for
the added payload keys. The live seat could not be reached to re-measure it
anyway - GetActiveObject("AutoCAD.Application") blocked without returning
on three attempts over ~370 s while acad.exe was running (sibling
contention), so the live engine was unavailable rather than absent.

server.py's hatch_material docstring now states the accuracy keys and the
traced-outline deletion; the tool summary is unchanged, and
scripts/generate_tool_inventory.py reproduces docs/tool-inventory.json
byte-for-byte (209 tools), so no registration moved.

tests/test_mech_marks.py: 25 passed (17 -> 25). Gates: ruff check clean
over the tree, ruff format clean, test_tool_registry, test_tool_inventory,
test_capability_contract, test_discovery_aliases, test_boundary_trace,
test_measure and test_release_consistency all pass. Full suite:
3351 passed, 24 skipped, 1 xfailed; the same 6 pre-existing failures
(test_benchmark_v4, test_capabilities, test_layouts_solids, test_page_setup,
test_sprint2_fixes, test_viewport_ops) verified identical with this change
stashed, and test_release_consistency.py::test_document_numbers_match_the_tree
deselected as Global Constraints instructs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 20:12:11 +03:00
Umut CANandClaude Opus 5 bf8b3baf74 fix(task-9): put the fastener plan in projection with its elevation, and refuse a thread size that is not d
Two wrong-drawing bugs in SECTION 22.

1. _hex_points defaulted to start_deg=0, putting vertices at 3 and 9 o'clock,
   so the plan of an M12 hex head measured 18 mm (across flats) across the
   vertical while the elevation drew that same head 20.78 mm tall (across
   corners e, silhouette edges at +/- e/4 - the projection of a hexagon with
   vertices top and bottom). Place the two blocks in projection and the two
   views of one part disagree. The default is now 30 deg, named HEX_START_DEG
   and documented on both the constant and _hex_points as the convention the
   elevation is the projection of, so a future fourth caller cannot pick the
   other one by accident. The three top builders (_hex_fastener_top,
   _hex_nut_top, _socket_head_top) inherit it.

2. _thread_feature read the pitch from params['size'] via the ISO 261 table
   while drawing the geometry from params['d'], never cross-checking the two.
   feature_spec('thread', {'d': 8.0, 'length': 20.0, 'size': 'M20'}) drew an M8
   minor diameter and returned dims['pitch'] = 2.5 with ISO 261 cited as the
   source - one thread's pitch on another thread's geometry, a silent wrong
   number carrying a standards citation. A mismatch is now refused by name,
   like every other check in the module. std_feature_draw's params description
   says so.

Guard tests: the old top-view test asserted only len(points) == 6 and never a
vertex coordinate, so the orientation was untested. Added the vertex
coordinates, the plan-vs-elevation silhouette-height invariant across ISO 4014
/ 4017 / 4032, the socket screw's dk invariant, and the refused thread pair.

No ActiveX member is added or changed by either fix; both are pure geometry and
validation, so no live measurement is cited. The live AutoCAD could not be
probed for a confirmation run - GetActiveObject blocked past 150 s under
sibling contention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 20:02:06 +03:00
Umut CANandClaude Opus 5 48eb057273 feat(task-4): the orthographic view engine - silhouette, hidden lines, layout
engineering/mech/views.py projects a part model onto a view plane and returns
primitive descriptions, so a test asserts geometry rather than a screenshot.

The one calculation everything comes out of for a revolved part is the radial
profile: the part cut into bands of constant bore and constant (or linearly
tapering) outside, with every feature's radial_edit applied. The front view is
that profile walked as a path and mirrored about the axis; the section cut face
of the next task is the same profile walked as a closed loop, which is what
stops the outline and the hatched area ever disagreeing. Chamfers and fillets do
not draw over the silhouette - they substitute their corner in it, so the
overshoot the critique focus looks for cannot be produced by construction, and a
corner edit that finds no corner is reported rather than dropped.

A band's radii are read off the segment the band's MIDPOINT lies in and the
taper is evaluated at the band's own two ends. Sampling a hair inside the band
to dodge the segment-boundary ambiguity - the way the plan's draft did - writes
the taper slope times that inset into every coordinate the silhouette is built
from: a 30 -> 10 mm cone over 20 mm then reports 14.99999 for a radius that is
exactly 15, and nothing downstream can tell that it is wrong.

The end view applies a real hidden-line rule: walking the bands from the viewing
end, a diameter larger than everything nearer than it is drawn hidden, a bore
behind material is hidden, and the near end's own circles are visible. Looking
from the left reverses it. A revolved part's top view is refused, because it
repeats the front.

Prismatic parts get the outline in true shape with corner fillets and chamfers
substituted in, and side/top thickness rectangles - the case where a hand-drawing
model reliably forgets the hidden line per through feature, which here comes out
of the feature itself.

The honesty rule of spec section 6 is enforced in one place: a feature that emits
no primitives and edits neither the radial profile nor a corner is listed in the
view's omitted tuple with its own reason. Nothing is silently dropped.

layout_origin places views in first-angle positions by default (the view from the
right goes to the left, the view from above goes below) and flips both for third
angle. It takes one additive keyword beyond the pinned signature, child, because
placing a view to the left depends on that view's own width; without it the child
is assumed the parent's size and every pinned call still works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:56:04 +03:00
Umut CANandClaude Opus 5 ffa8327770 fix(task-12): keep the plan's task-12 body in step with the shipped fix
The plan embeds Task 12's module and tests verbatim, so it still carried the
`_current_space` read and the derivation that put the third-angle cone's short
side away from the circles. Both replaced with what shipped, so a re-run of the
task cannot transcribe the defects back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:51:00 +03:00
Umut CANandClaude Opus 5 e57ae8b3ac fix(task-12): restore the caller's tab from the layout listing; source the projection symbol
enter_layout read the caller's space from `backend._current_space`, which only
EzdxfBackend has. On ComBackend the getattr fell through to "Model", so
`sheet_frame` / `titleblock_apply` with a `layout=` dropped a live caller onto
the Model tab instead of putting them back -- the opposite of what the
docstring and both tool docs promise, and a dual-engine parity break in new
shared infrastructure. Measured on the live seat (AutoCAD 2026, scratch
document from drawing_new(), closed with discard):

    scratch document: Drawing24.dwg
    layouts: ['Layout1', 'Layout2', 'Model']
    B) current BEFORE: Layout2
    B) the value the OLD code read (backend._current_space):
       <<absent -> getattr default 'Model'>>

`layout_list()` already reported the right value two lines earlier on both
engines (ezdxf from $TILEMODE / the active layout, COM from
`doc.ActiveLayout.Name` -- read live as "Layout2" above), so `previous` now
comes from `listing["current"]`.

The projection-angle symbol was justified by an in-code derivation that cannot
decide the question, and the test restated the implementation. The derivation
was also applied inconsistently: the third-angle symbol mirrored the cone as
well as the end view, leaving the trapezoid's short side pointing AWAY from the
circles -- which is the first-angle arrangement. Transcribed instead from two
independent reproductions of the ISO 5456-2 figure, both downloaded and
measured (ISO's own figure is paywalled and was NOT read; the module says so):

  * FreeCAD's ISO 5457 sheet template A3_Landscape_ISO5457_advanced.svg,
    `first_angle_trapezoid` = "m 389,222 -10,2.5 v 5 l 10,2.5 z" -> 5-unit side
    at x=379, 10-unit side at x=389; circles r2.5/r5 at cx=396.
  * Wikimedia Commons "Convention placement vues dessin technique.svg", both
    symbols side by side, labelled FR and US: FR 35.856 @ x=13.343 / 66.135 @
    x=81.869, circles x=148.005; US 35.856 @ x=380.275 / 66.135 @ x=448.801,
    circles x=314.140.

Both draw the trapezoid the same way -- short side left -- and move only the
end view: first angle puts it right of the cone, third angle left. Equivalently
the short side points away from the circles in first angle and towards them in
third, which is how every source states it in words. `third` now follows.

Tests: the reference figures are transcribed into the test as data, and the
symbol is asserted to land its three landmarks in the order each figure does,
so the test cannot pass by agreeing with the code. The layout restore is pinned
through a proxy that hides `_current_space` from the real backend rather than a
fake that owns members ComBackend does not have. Both new tests fail on the
previous code (measured: "assert 16.0 < 8.0" for the mirrored cone; "- SheetB
+ Model" for the tab).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:49:28 +03:00
Umut CANandClaude Opus 5 457c53644f fix(task-3): keyway end-view arc, the machined face, and the chamfer's segment bound
Three defects in the feature catalogue, all faithfully transcribed from the
plan text and all wrong in shipped code.

1. Keyway.prims(view="side") emitted the arc the keyway REMOVES. `Arc` is CCW
   start -> end, and the emitted (right wall -> left wall) is the ~31 degree
   cap over the slot opening, not the ~329 degrees of shaft that survives it.
   Measured live on AutoCAD 2026 (Documents.Add scratch doc, closed in a
   finally): ModelSpace.AddArc(center, radius, start_rad, end_rad) -- angles in
   RADIANS, CCW start -> end -- gives ArcLength 8.098 mm for the emitted
   74.534 -> 105.466 deg and 86.150 mm for the reversed 105.466 -> 74.534 deg,
   against a 94.248 mm full circle at r = 15; StartPoint/EndPoint of the
   emitted arc are exactly the notch walls at x = +/-4. Swapped, so the arc now
   runs left wall -> right wall the long way round, and the side view's bbox
   goes from (-4, 11, 4, 15) to (-15, -15, 15, 14.457).

2. Hole and Pocket cut the plate from OPPOSITE faces in the same view frame:
   Hole ran z = 0 -> depth, Pocket z = thickness -> thickness - depth, and
   neither takes a face parameter. A plate carrying both showed the pocket
   milled from one side and the blind hole drilled from the other, silently --
   omission() returns None for both, so nothing reported it. The frame comment
   now names the machined face (v = thickness, the face the front view looks
   at), and Hole drills from it: the blind floor, the counterbore step and the
   through bore all hang down from there, as Pocket already did.

3. Chamfer.validate bounded the axial leg against the whole part, never against
   the segment the chamfer sits on. A shallow lead-in passed validation and was
   drawn straight through a shoulder at a radius that segment does not have
   (size 30 at 10 deg on a 15 mm end segment: radial 5.29 < 10 so the radial
   guard was happy, yet `before` landed at x = 45, two segments away). New
   helper _anchor_segment names the segment a corner actually cuts into -- at a
   step, the adjacent one carrying the larger radius -- and the leg is bounded
   by its length, refused by name with the step it would have crossed.

Tests: the keyway test asserted only primitive counts and two x coordinates, so
it passed with the arc reversed; it now pins the sweep, the endpoints and the
bbox. The hole test exercised only a through hole, which is symmetric; added a
blind-hole-and-pocket test that pins both to the same face, and a counterbore
test. Added three chamfer tests (two refusals, one that must still be
accepted). Each fix was mutation-checked: reverting it fails its test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:48:00 +03:00
Umut CANandClaude Opus 5 84c1ef42fb feat(task-9): SECTION 22 - std_part_list, std_part_insert, std_feature_draw
engineering/mech/stdparts.py turns the Task 8 tables into real blocks. catalogue()
searches 83 transcribed parts by designation, standard, size or family;
block_spec() emits the typed primitives and DESIG/STD/SIZE/MAT ATTDEFs
block_define takes, in side/top for fasteners and ISO 8826-1 simplified /
ISO 8826-2 detailed for bearings; insert_std_part() defines the block once,
inserts it and writes the std_part ACADMCP_MECH payload the skeleton pins, so
the parts list is a read of the drawing rather than a naming convention.
Threads follow ISO 6410 - minor at 0.8 x major, a thread-length limit line to
the major, a three-quarter minor arc in the end view.

std_feature_draw() adds a thread, a DIN 509 undercut, a DIN 471/472 groove, a
DIN 332 centre hole or an ISO 3601-2 O-ring groove to geometry that already
exists. Its dimensions come from the shared standards registry, never from this
module: an absent table, a size outside coverage or a row missing a dimension is
refused by name, and the DIN 509 relief is drawn from the profile its table
publishes rather than reconstructed here. DIN 332 form R is refused outright.
An unknown designation is refused with the nearest catalogue entries; nothing is
drawn approximately.

SECTION 22 registers the three tools in the new mech pack, with alias records
(INSERT plus shop-floor synonyms in English and Turkish), a regenerated tool
inventory, the README release snapshot at 207 tools in 24 groups, and the frozen
group-size snapshot gaining mech: 3.

Two plan corrections, both verified by running the code:

* the plan's @cad_tool summaries for std_part_insert (118 chars) and
  std_feature_draw (130) overran discovery.serialize.MAX_SUMMARY_CHARS = 110, so
  tests/test_tool_registry.py::test_no_authored_summary_is_long_enough_to_be_truncated
  failed - they would have shipped rendered as "...". Both are shortened to 99
  and 104 characters with the same content.
* the plan's file list omitted tests/test_tool_packs.py, which pins the pack
  registry, the tool_packs report and the group count. It now carries MECH_TOOLS
  and distinguishes ALL_PACKS (declaration order, what "available" reports) from
  ALL_PACKS_ENABLED (sorted, what "enabled" reports) - those two lists stopped
  being the same list when "mech" was appended after "settings".

No new ActiveX member is called: the module drives block_list / block_define /
block_insert / entity_set_xdata / entity_create_* only. The COM path was still
executed live once on AutoCAD 2026 in a scratch document created and closed by
the probe: the bolt block defined with layers CENTER+GEOMETRY, attributes read
back as DESIG/STD/SIZE/MAT = ISO 4014 - M12x60 / ISO 4014 / M12x60 / 8.8, the
ACADMCP_MECH std_part payload round-tripped through entity_get_xdata,
STD_ISO_15_6205_SIMPLIFIED inserted, and the M20 thread's three primitives
landing on GEOMETRY.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:26:49 +03:00
Umut CANandClaude Opus 5 64a57d74a0 feat(mech): SECTION 23 completes with centre_marks, section_line, hatch_material
centre_marks draws ISO 128-23 centre marks or full centre lines and reads
each circle's real centre and radius back out of the drawing through
entity_get instead of being told them - a radius restated from memory is
the silent wrong number this repository keeps refusing to ship. A handle
that is not a CIRCLE or ARC, and a circle in a plane tilted out of WCS XY
(which has no radius in this frame), are both refused by handle before
anything is written. The dash proportions are not transcribed here: the
prims are plain lines on CENTER, whose linetype already carries them.

section_line draws the ISO 128-40 cutting-plane line - wide segments at
the ends on GEOMETRY, the thin long-dash-dot middle on CENTER, an arrow at
each end whose head sits on the line end and points the way the section is
viewed, the same letter beyond both arrows - and returns the five-key
plane dict {p1, p2, label, direction, style} that mech_view_add(kind=
"section") consumes, so the label on the plan and the label on the view
are the same string by construction. A viewing direction parallel to the
plane is refused rather than drawn.

hatch_material hatches through standards.materials.hatch_for, so the
ISO 128-50 map and its refusal stay in one place; scale multiplies the
material's own pattern scale and angle replaces its angle. An unknown
material is refused with the list before any entity is written.

DEVIATION - one defect in the plan's own draw_material_hatch, fixed here
with two tests that fail against the plan's code. It called
entity_create_hatch with ROLE_LAYER["hatch"] without creating that layer,
the same omission review found in Task 10's draw_annotation_prims.
MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(),
doc.Close(False) in a finally): on a scratch document whose layer table is
['0'], msp.AddHatch(0, "ANSI31", False) + AppendOuterLoop + Evaluate()
leaves the AcDbHatch in model space, and the very next statement
hatch.Layer = "HATCH" - which is the one line _apply_entity_attrs runs -
raises com_error (-2147352567, ..., 'Key not found', ..., -2145386476);
model space then still reports [('AcDbHatch', '0')] and the layer table is
still ['0'], so the refusal leaves an orphan hatch on layer 0. After
doc.Layers.Add("HATCH") the same assignment succeeds. drawing_open runs no
bootstrap, which is exactly when a cut face has no HATCH layer to land on.
draw_material_hatch now calls ensure_annotation_layers before creating the
hatch and reports layer / layers_created, as draw_annotation_prims does.
The whole task re-measured live afterwards through ComBackend on a fresh
scratch document (layer table ['0']): hatch_material -> {'ok': True,
'handle': '82', 'pattern': 'ANSI31', 'layer': 'HATCH', 'layers_created':
['HATCH']} with the entity read back as HATCH on HATCH; centre_marks on a
live CIRCLE r=12.5 -> sources [{'handle': '84', 'center': [80.0, 0.0],
'radius': 12.5}]; section_line -> 9 entities and the five-key plane; layer
table after ['0', 'CENTER', 'GEOMETRY', 'HATCH', 'TEXT']; document closed.

tests/test_mech_marks.py: 17 passed. Gates: ruff check clean, inventory
regenerated to 209 tools, test_tool_registry (engineering 12 -> 15, total
206 -> 209), test_discovery_aliases, test_tool_inventory,
test_capability_contract and test_release_consistency all pass. Full
suite: 3343 passed, the 6 pre-existing failures verified identical with
this change stashed, and
test_release_consistency.py::test_document_numbers_match_the_tree
deselected as Global Constraints instructs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:25:13 +03:00
Umut CANandClaude Opus 5 c607e75b75 fix(task-10): ensure annotation layers live, and put the weld symbols and their dimensions where ISO 2553 puts them
Three blocking review findings in engineering/mech/annotate.py.

1. draw_annotation_prims assigned ROLE_LAYER[role] without creating the
   layer. EzdxfBackend._apply_attrs adds a missing layer; ActiveX does not.
   MEASURED live on AutoCAD 2026 (probe: GetActiveObject, Documents.Add(),
   doc.Close(False) in finally): on a scratch document whose layer table is
   ['0'], draw_surface_texture(ComBackend(), at=(0,0), ra=3.2,
   machining='required', lay='perpendicular', allowance=0.5) raised
   "AutoCAD COM error (-0x7ffdfff7)" and left one orphan AcDbLine on layer 0
   — a partial write with no rollback, on exactly the foreign-drawing path
   the spec names (drawing_open runs no bootstrap). The same probe after the
   fix: {'ok': True, count 6, 'layers_created': ['DIM','TEXT']}, model space
   ['AcDbLine' x4 on DIM, 'AcDbText' x2 on TEXT], layer table ['0','DIM',
   'TEXT']. New helper ensure_annotation_layers() creates every target layer
   up front, before the first entity, with its ENGINEERING_LAYERS colour /
   linetype / lineweight; the result reports layers_created.

2. side='both' drew the two elementary symbols 0.4h apart — the other-side
   one still stood on the (correctly omitted) identification line, so a
   double-V butt weld came out as two detached halves that were not even
   symmetric about the reference line. ISO 2553 places them back-to-back on
   the continuous reference line: the other-side baseline is now 0.0 when
   there is no identification line. tests/test_mech_annotate.py asserted the
   wrong apex (-0.4*h), so the gate certified the defect; that test is
   rewritten to assert the mirror pair, plus a double-fillet case.

3. For side='other' the size and length were written above the reference
   line while the symbol hung below the identification line — 'a6' sat where
   an arrow-side throat dimension sits, on a joint with no arrow-side weld.
   Both texts now sit beside their own symbol (0.25h clear of the line it
   stands on, on the symbol's side). New test covers side='other' with size
   and length, and pins arrow/both unchanged.

All five new or rewritten tests fail against the pre-fix module (the layer
one with the measured 'Key not found' refusal, via a fake that models only
that measured behaviour) and pass after. tests/test_mech_annotate.py: 29
passed. Full suite: 3326 passed, 6 pre-existing failures unrelated to this
change (verified identical on the branch tip with the change stashed),
test_release_consistency.py::test_document_numbers_match_the_tree
deselected as instructed. No tool registration changed, so
docs/tool-inventory.json regenerates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:15:33 +03:00
Umut CANandClaude Opus 5 ac6c4075ba feat(task-3): the feature catalogue and its standards tables
engineering/mech/features.py is the nineteen typed features of spec section 5,
each answering the same four questions - is my placement legal, what do I
contribute to this view, to a cut face, and what dimension do I want - plus the
five structural hooks the view engine needs: removal_box, radial_edit,
corner_edit, outline_edit and omission. Pure arithmetic; a test asserts emitted
primitive tuples, not a screenshot.

Nothing restates a table the repository already owns: Keyway takes its width
and depth from engineering.keyway (DIN 6885-1) and is refused by name outside
that table's bore range; GearTeeth takes its outline from engineering.gear's
involute generator and is refused when its tip diameter disagrees with the
segment it sits on.

standards/threads.py transcribes ISO 261 Table 1 and Table 2 - first-choice
M1.6-M64 plus second-choice M14/M18/M22/M27/M33/M39/M45/M52/M60 - and computes
ISO 724's basic minor and pitch diameters from ISO 68-1's own formula, pinned
against the published values (M20x2.5 -> 17.294 / 18.376). The remaining
second-choice diameters, every third-choice diameter and fine pitches below M8
are not transcribed and are refused by name, never interpolated. The drawing
rules are ISO 6410: minor at 0.8 x major, a thread-length limit line across the
major, a three-quarter circle in the end view, and an internal thread whose
section hatch runs to the major diameter.

standards/{undercuts,rings,centres,orings}.py ship DIN 509, DIN 471/472,
DIN 332-1 and ISO 3601-2 with their SOURCE, their row-key contract and an
**empty** row set. That is deliberate and is the track's table rule taken at its
word: a row that could not be verified against the standard is not shipped,
because a wrong relief depth or groove diameter is a wrong workshop drawing
while a narrow table is merely narrow. Every lookup is refused by name and says
what to transcribe and where; the features accept explicit dimensions so the
geometry is fully exercised, and DIN 332 form R is refused outright rather than
approximated by the straight 60 degree cone of forms A and B. Filling any of
these tables later is a data edit with no code change, and a shape test already
fails any row that lands without a column the drawing code reads.

Three deviations from the plan text, each measured rather than assumed
(the merge agent should keep exactly one copy of the first):

1. standards/__init__.py (Task 1, on main) refused a registration with no rows
   ("registered no rows"), which would have made the four not-transcribed
   tables impossible to register at all - and registering them is what keeps a
   later transcription a data edit. register() now refuses a rows argument that
   is not a dict and accepts an empty one; two tests in tests/test_mech_standards.py
   pin both halves. No existing test asserted the old refusal.

2. ORingGroove's housing kind is carried by `housing`, not `kind`. A dataclass
   field named `kind` overwrites the discriminator _Base.to_dict() writes:
   measured, Probe(id='og').to_dict() == {'kind': 'radial', 'id': 'og'}, so
   feature_from_dict would then refuse its own payload as an unknown feature
   kind and no part carrying an O-ring groove could round-trip through XDATA.
   A new test pins the round trip.

3. The plan's end-chamfer test asserted after == (75.0, 10.0) - the corner
   itself - and before == (72.0, 8.268), a point on neither surface. The real
   edit is before (72.0, 10.0) on the cylinder and after (75.0, 8.268) on the
   end face, consistent with the 45-degree start chamfer the same file pins.
   The test was corrected to the measured geometry; the behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:12:00 +03:00
Umut CANandClaude Opus 5 6024723d6b fix(task-8): pin every transcribed standards row, not a sampled subset
The module docstring claimed "every value asserted here is a named row of a
named table", but the converse did not hold: 27 of the 83 shipped rows had a
dimension no test compared against a literal. ISO 4014/4017 M5 (s,k), ISO 4032
M5 (m), ISO 7089 M5 (d1,d2,h), ISO 4762 M4/M6/M10/M16 (dk,s,b) and D/B for 20
of the 33 ISO 15 bearings were constrained only by inequality tests (series
growth) and by the bore-number rule - never by the transcribed number. For a
task whose worst failure is "a standards table row that is not in the
standard", those rows were exactly where such a row would hide.

Measured before the fix with a pytest plugin that rewrote five rows in memory
(M5 hex bolt s=k=99, M5 nut, M5 washer, M16 socket screw dk=s=b=99, 6307 as
78x20 instead of 80x21): 32 passed in 0.07s. All five now fail.

Each of the five tables is pinned by an ISO*_ROWS dict literal carrying every
dimension of every shipped size, parametrized one case per size/designation,
plus a completeness test asserting the table's key set equals the literal's -
so a row added later without a pin is red too (verified: a fabricated M14 head
and 6011 bearing fail the completeness gate). 32 -> 93 tests, 0.06 -> 0.13 s.

No production code changed; no ActiveX member is involved, so no live probe was
required for this finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:07:53 +03:00
Umut CANandClaude Opus 5 56e6ff7206 feat(sheet): ISO 5457 frames and ISO 7200 title blocks for A4-A0
SECTION 24 opens with sheet_frame and titleblock_apply.

engineering/sheet/frames.py transcribes ISO 216's trimmed sizes, ISO 5457's
20 mm filing margin and 10 mm edges, the grid reference system (an even number
of divisions per side, that side of the frame over 50 mm; letters from the top,
numbers from the left), the centring marks (trimmed edge to 5 mm inside the
frame) and the 10 x 5 mm corner trimming marks. Every one of those numbers is
pinned by a test against the clause it comes from, because a wrong margin is
visible on every drawing this server makes.

engineering/sheet/titleblock.py draws the ISO 7200 block -- 180 mm wide, which
is exactly the drawing-space width of a portrait A4, which is why one block
fits every size -- and carries the ISO 128-30 projection-angle symbol. Which
symbol is which is derived rather than remembered: first-angle projection puts
the view seen from the left on the right, and the view of the cone from its
small end is the small circle inside the large one, so first angle draws the
cone left and the circles right.

The old A3 module folds in. apply_iso_a3_titleblock now forwards to
apply_titleblock(size="A3", projection=None, zones=False, marks=False), which is
the geometry it always drew, and tests/test_engineering_titleblock.py passes
unchanged -- that is the regression gate for the fold.

No new ActiveX member is called: both tools reach the drawing only through
contract members the A3 title block has used on both engines since v1.0
(entity_create_polyline / _line / _circle / _text, layout_list,
layout_set_current), so there was nothing to probe live.

Declared, not glossed: the zone divisions drawn here are equal, so ISO 5457's
allowance for shorter corner fields is not implemented; the division counts,
which are what a zone reference quotes, are the standard's. ISO 7200's full
optional-field list is not transcribed, only the fields this block has a cell
for. Coverage is A4-A0; an unknown size is refused by name with the five it has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:03:14 +03:00
Umut CANandClaude Opus 5 a4ce46ba9b feat(mech): SECTION 23 opens with surface_texture and weld_symbol
ISO 1302:2002's seven transcribed symbol-proportion rows (text height ->
line width d', short-leg height H1, minimum height H2) drive the 60 deg
vee, the machining-required bar at H1, the machining-prohibited circle
inscribed in the vee, and the all-around circle at the kink; the Ra/Rz
values, the process, the lay symbol and the machining allowance land
around the extension line, which grows with the widest of them. A text
height outside the seven rows is refused by name rather than
interpolated, and ISO 1302 is accepted as an alias designation for
drawings still issued under it - the graphical symbol is the same one.

ISO 2553:2019's reference line, dashed identification line (omitted for a
symmetrical weld, as the standard permits), size written before the
symbol, length and pitch written after it, field-weld flag, all-around
circle and process tail. Six elementary symbols are transcribed - square,
single-V, single-bevel, single-U, single-J, fillet. The rest of the
elementary table is deliberately not shipped: a weld kind outside the six
is refused with the list of the six, because a guessed weld symbol is a
wrong instruction to a welder.

Both tools place the symbol on a plain leader - a line plus an arrowhead
with the proportions the backend's own mleader already draws - rather
than through leader_create_mleader, which refuses empty text and would
hang an MTEXT label off every symbol.

Proportions neither standard dimensions (the all-around circles, the
flag, the identification line's dashes, the text pitch and the character
width estimate) are named constants documented as this implementation's
declared choices, never as transcribed values.

No new ActiveX member is called: both tools compose LINE / ARC / CIRCLE /
LWPOLYLINE / TEXT through the entity-creation contracts both engines
already implement, so no live COM probe was needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 18:55:57 +03:00
Umut CANandClaude Opus 5 49b972f007 feat(task-8): ISO fastener and ISO 15 bearing tables, transcribed and pinned
engineering/mech/standards/fasteners.py carries ISO 4014:2011 / ISO 4017:2014
head dimensions (s, k), ISO 4032:2012 nut heights, ISO 7089:2000 washer
d1/d2/h and ISO 4762:2004 socket head cap screws, plus the ISO 261:1998 coarse
pitch column. bearings.py carries the ISO 15:2017 deep groove boundary
dimensions for 6000-6010, 6200-6210 and 6300-6310.

Only the ISO 261 first-choice sizes ship. M14/M18/M22/M27/M33, everything above
M36 (above M24 for ISO 4762), the 618x/619x/16xx bearing series, bore numbers
above 10 and every suffixed bearing variant are refused by name rather than
interpolated: a narrow table is merely narrow, a wrong row ships a wrong
workshop drawing. Across-corners e is derived geometry (2s/sqrt(3)), so no e
column had to be transcribed at all, and the ISO 8826-1 cross and ISO 8826-2
element-symbol fractions are declared drawing conventions, not table values.

No ActiveX member is called by this task, so no live AutoCAD probe was needed;
the two modules are pure tables and pure arithmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 18:53:17 +03:00
Umut CANandClaude Opus 5 04d269318a feat(task-2): the mechanical part model - RevolvedPart, PrismaticPart, build_part
engineering/mech/part.py is the model the whole track draws from: a revolved
stack of segments along +X, or a closed prismatic outline plus a thickness, each
carrying a list of typed features. Pure arithmetic - no backend, no async, no
DXF - which is what lets the view engine be tested without a drawing.

Every refusal fires before anything is drawn and names the offending path the
way pid_from_spec does: a non-finite or non-positive number as
segments[2].d_outer, a bore that is not smaller than its outside (a bore that
changes diameter is two segments), an outline with fewer than three distinct
vertices, an outline that crosses itself (engineering.measure.is_self_intersecting
- the shoelace cancels crossed lobes rather than failing, so a bowtie would
otherwise measure 0.0), an unknown material, an unknown segment key, a duplicate
feature id.

Two gates are the reason this is a model rather than a dict: a feature whose
placement is off the part is refused by its own name and by the range it missed,
and two features that would remove the same material are refused with both names
and the overlapping box. Neither is clamped.

part_to_dict round-trips through build_part, which is what lets mech_part_draw
write the model onto the drawing as ACADMCP_MECH XDATA and mech_view_add read it
back months later without the caller re-describing the part.

Two fixes to the plan's own code and test list, both recorded in deviations:
_features_from imported engineering.mech.features unconditionally, so a
featureless part - every fixture in this module, and both round-trips - died on
ModuleNotFoundError until Task 3 lands; the import now happens only when there
is a feature dict to build. And the plan's test file omitted
test_a_duplicate_feature_id_is_refused while its own gate deselects it by name,
so the duplicate-id refusal the commit message claims went unpinned; the test is
written here and Task 3 turns it green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 18:52:39 +03:00
Umut CANandClaude Opus 5 2bb8044144 feat(mech): shared foundation - primitives, XDATA codec, standards registry, ISO 128-50 materials
engineering/mech/ opens with the four modules every track-B/G branch imports,
so the four worktrees can be cut from this commit and none of them has to
invent a name the others will conflict with.

- primitives.py: the typed descriptions the pure modules emit (Line, Arc,
  Circle, Poly, HatchArea, Text, DimIntent) with bbox / translate / rotate /
  scale, and ROLE_LAYER - the one place an ISO 128 line role becomes a layer
  name, so no module hardcodes a layer. The arc bbox sweeps its quadrants: a
  0-180 arc reports y=+r, not its endpoints, which is what keeps the view
  layout from stacking two views on top of each other.
- xdata.py: the ACADMCP_MECH codec in Track A's shape - a 1001 application
  marker, 1000 chunks of at most 255 ASCII characters, the 16 KB per-entity
  ceiling enforced before anything is written, and a foreign application, a
  corrupt chunk stream, a wrong version or an unknown kind refused with
  ValueError instead of decoded into something plausible.
- standards/__init__.py: the table registry. A size outside the coverage is
  refused against the coverage; a size inside it that is not a listed row is
  refused against the rows. Nothing is interpolated, an ambiguous variant is
  never resolved by taking the first row, and a registration whose SOURCE does
  not name the table it was transcribed from is refused.
- standards/materials.py: the section-hatch map for nine materials. ISO
  128-50 standardises only the general hatching (narrow continuous lines at 45
  degrees) - that is acadiso.pat ANSI31 and what engineering/gear.py already
  draws for steel. The distinguishing symbols are ASME Y14.2M's section lining
  as shipped in AutoCAD 2026's acadiso.pat, read off the installed file; cast
  iron's departure from ASME and the absence of any mechanical symbol for
  concrete and wood are stated in the rows themselves. Angles are 0.0 because
  the patterns already carry 45 degrees, and the two architectural patterns'
  scales are derived from the ANSI31 line spacing and recomputed by the test
  rather than chosen.

No tool is registered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 17:04:59 +03:00
Umut CANandClaude Opus 5 a8482a5f0d docs(spec): align tracks B+G with the written plan
The five gaps the plan's reconciliation found, each resolved in the spec:
part placement lives in draw_part(at=, rotation=) so the dataclasses carry no
origin; engineering/sheet/refs.py is gone with the contract correction;
image_attach takes no clip and titleblock_apply no position (the ISO 7200
corner is fixed by the standard) — both recorded as out of 1.6; balloon_add
places one balloon per call against the item numbers bom_extract already
assigned.

Records the four feature tables that ship empty (DIN 509, DIN 471/472,
DIN 332-1, ISO 3601-2): no row could be verified against the standard here, so
the structure, the row-key contract and the refusal ship without rows, and the
features draw from explicit dimensions until the tables are transcribed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 16:52:23 +03:00
Umut CANandClaude Opus 5 5497dc01d9 docs(plan): tracks B+G — 16 TDD tasks in five groups
Assembles the skeleton and the five drafted parts into one plan, Task 1
through Task 16, each a TDD task with its failing test, its implementation
and its gate.

Groups:
- (main) Task 1 — primitives, the ACADMCP_MECH codec, the standards registry
  and the ISO 128-50 material map; the shared foundation the four branches
  all consume.
- M (Tasks 2-7) — the part model, the feature catalogue with its DIN/ISO
  tables, the view engine (orthographic, then sections and details),
  dimensioning, and SECTION 21's six tools.
- S (Tasks 8-9) — the ISO 4014/4017/4032/7089/4762 and ISO 15 tables, and
  SECTION 22's three tools.
- A (Tasks 10-11) — ISO 21920-1 surface texture, ISO 2553 welds, centre
  marks, the ISO 128-40 cutting-plane line and the material hatch:
  SECTION 23's five tools.
- G (Tasks 12-14) — ISO 5457 frames and ISO 7200 title blocks A4-A0,
  revisions, the ISO 7573 parts list and ISO 6433 balloons, xrefs, images,
  data extract and DWG: SECTION 24's eleven tools and the four contract
  members.
- F (Tasks 15-16) — the six mech_* critique focuses, the mech pack, lean,
  aliases and golden queries; then the v5 benchmark, the correctness checks,
  the live COM smoke and the docs.

Interface pass over the assembled file: View.dims is tuple[DimIntent, ...] as
the skeleton pins it; every XDATA writer calls Task 1's to_values rather than
re-deriving the chunk split from encode; stdparts' five standard features are
STD_FEATURE_KINDS so they do not collide with features.FEATURE_KINDS; the
revision block carries its own ACADMCP_SHEET_REV application instead of a
fourth ACADMCP_MECH kind that the pinned KINDS would refuse; and Task 16
states 39 correctness checks, which is what it registers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 16:50:33 +03:00
Umut CANandClaude Opus 5 c97be8fc40 docs(plan): tracks B+G skeleton — 16 tasks in five groups, interfaces pinned
The header, global constraints, file structure, the interface block every task
builds against, the group/ownership table and the merge notes. Task sections
are drafted next.

Also corrects the spec's architecture section: the engineering tools compose
the generic contract members the way gear/keyway/titleblock already do, so only
four operations (xref_attach, xref_manage, image_attach, drawing_export_dwg)
need contract members and an implementation per engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 14:56:37 +03:00
Umut CANandClaude Opus 5 5ff66b641f docs: tracks B+G (mechanical 2D and the sheet standard) design spec
One part model — a revolved or prismatic profile plus typed features — and one
view engine that projects it into front/side/section/detail views with hidden
lines, cut faces and dimensions, instead of a generator per part shape. Around
it: the standard-parts catalogue as code inserted as blocks with attributes,
the ISO annotation symbols, and the sheet standard (ISO 5457 frames A4-A0,
ISO 7200 title blocks, revisions, ISO 7573 parts list, balloons, xrefs, DWG).

25 tools in four sections, new pack `mech`, three capability keys, six mech_*
critique focuses, benchmark task `mech_assembly` and six correctness checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 14:45:24 +03:00
Umut CANandClaude Opus 5 39e6e2f9e4 docs(settings): track E shipped — CLAUDE.md sections 18-20, README, changelog with the live COM smoke record
CLAUDE.md gains items 18-20 (styles, page setup & templates, environment),
the grown drawing_settings facade on item 11, lean 55 / 52 and
TOOL_PACKS=all|core,pid,settings, and three conventions: layer states are
ours (ACADMCP_LAYERSTATES XRECORD, mode "xrecord"), page-setup evidence is
the PDF's own /MediaBox, nothing interprets coordinates in UCS.

README token table re-measured on this tree with benchmarks/token_suite.py
(ratio/v1 estimates): full 199 tools / 59,315 tokens, core,settings 190 /
55,593, core 168 / 48,825, lean 55 / 16,360, search 2 / 356; corpus
re-derived from discovery.aliases: 186 AutoCAD command names, 933 synonym
phrases, 204 tools. Styles & standards row, page setup & templates row,
final environment wording, and "Known limitations of the settings track".

CHANGELOG Unreleased: the track E intro, Added (styles, page setup &
templates, environment, drawing_settings growth, benchmark evidence),
Fixed (the Track A hardening list), Changed (three groups, packs, measured
idle cost, release snapshot 204 · 8 · 5 · 3284). Spec status: implemented.

Where the plan's text and the tree disagreed, the tree wins:
mleaderstyle_create is implemented on both engines through the
ACAD_MLEADERSTYLE dictionary and no `mleaderstyle` capability key exists
(spec §4, capability maps), so the docs say so instead of calling it
ezdxf-only; the Task 7 fix (tolerant ACADMCP_PID payloads, crossings
tested against the arc) joins the hardening list the plan left it out of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:38:14 +03:00
Umut CANandClaude Opus 5 18c7c69da6 fix(task-25): page_setup_truth and settings_pdf_mediabox_a3 move the sheet to ANSI B before proving A3
A fresh Layout1 is already ISO A3 landscape (ezdxf R2018 default: 420 x 297,
plot_rotation 0), so an A3-only /MediaBox read-back passed with
page_setup_apply stubbed to a no-op: the A/B gate row and the v4 matrix task
could not fail on the setter. Both now apply ANSI B (432 x 279, a size no
fresh document has; spec section 9 names it) and read that PDF first, then
apply ISO A3, read its PDF, and require the setter's changed.size_mm to have
moved from B to A3. The stub fails at the first PDF.

The moved-sheet comparison is +/-0.5 mm, the spec's tolerance, not exact:
measured live on AutoCAD 2026 (scratch Documents.Add(), closed unsaved), the
COM engine reports ANSI B as 431.8 x 279.4 and the DWG To PDF.pc3 file's
MediaBox as 431.8 x 279.4 then 420.158 x 297.039 for A3; the task passes
there with changed.size_mm [[431.8, 279.4], [420.0, 297.0]]. Headless it is
[[432.0, 279.0], [420.0, 297.0]].

tests/test_benchmark_v4.py gains the mutation test the finding describes
(setter stubbed -> both rows must fail; both passed on the old code) and a
run of the gate row itself; the headless task test asserts the B stage.
README rows and the tasks_v4 docstring say what the task now does; the README
collected-tests figure follows the two new tests (3282 -> 3284). The
regenerated A/B report against v1.5.1 is byte-identical (26 -> 32/32).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:27:17 +03:00
Umut CANandClaude Opus 5 c2942d3c94 bench: matrix v4 adds page_setup_truth; correctness suite grows to 32 with three settings checks (A/B vs v1.5.1: 26 -> 32 / 32)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00
Umut CANandClaude Opus 5 02dbb133b5 feat(settings): live COM smoke on AutoCAD 2026 builds the five .dwt twins and records the run
scripts/smoke_settings_com.py --build-dwt exited 0 on AutoCAD 2026 (25.1s),
2026-09-22: five templates/*.dwt written through drawing_template_save
(SaveAs code 66, each file opens with AC1032), page setup plotted and the
PDF's own /MediaBox read back as 420.158 x 297.039 mm, iso_a3_mech.dwt
reopened as source "bundled" with ISO-25 current, layer state / named view /
UCS / preferences / SummaryInfo / prompt line all through the COM engine.
The record is in CHANGELOG.md under Unreleased. The smoke keeps a ledger of
the documents it opens and closes them on the way out, so a failed run never
leaves scratch documents on the operator's seat. Two of its expectations
were corrected to the merged tree: mleaderstyle_create is implemented on COM
(spec: no `mleaderstyle` key), and plot_style_list marks catalogue rows with
`installed`. --interactive was not run unattended on the shared seat (an
unanswered GetPoint holds the command line); Task 23 measured the pick live.
*.dwt / *.dwg are binary in .gitattributes; AutoCAD's plot.log is ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00
Umut CANandClaude Opus 5 b54271f4ac fix(com): four live defects the settings smoke measured on AutoCAD 2026 — rejected calls after a document switch, typelib spelling of color/Lineweight, narrowed layer-state dictionary, layer 0 Freeze put
Measured 2026-09-22 by scripts/smoke_settings_com.py --build-dwt against the
running AutoCAD 2026 (25.1s), each on a scratch Documents.Add() document:

1. RPC_E_CALL_REJECTED (-2147418111) on Documents.Count straight after
   Documents.Open, after Close(False) inside document_close, and in
   _ensure_document_state: AutoCAD's own message filter refuses incoming
   calls while it is still switching documents. pywin32 exposes no
   CoRegisterMessageFilter, so _wait_out_rejected_call waits the window out
   for read-only callables (0.25 s -> 1 s pauses, 20 s budget, inside
   COM_CALL_TIMEOUT) at those sites and after Documents.Add/Open; any other
   com_error is not retried.
2. layer_list raised "'IAcadLayer' object has no attribute 'Color'. Did you
   mean: 'color'?": acax25enu.tlb spells IAcadEntity/IAcadLayer `color` and
   `Lineweight`. Late-bound dispatch resolved .Color/.LineWeight
   case-insensitively; with a makepy cache present (this machine's gen_py was
   regenerated on 2026-09-17, and every object a method returns is then a
   generated wrapper) the lookup is exact. All 17 sites now use the typelib
   spelling, which works on both wrappers; the fakes model the measured names.
3. layer_state_restore raised "'IAcadObject' object has no attribute
   'Count'": Dictionaries.Item and IAcadDictionary.Item are declared
   IAcadObject* (the narrowing _com_unnarrow already handles for MLEADER
   styles). The save path went through the typed Dictionaries.Add, so only
   the reopen paths failed. Both now un-narrow; the fakes narrow like pywin32.
4. The Freeze put on layer 0 raises 'Invalid layer' whatever the value, even
   False while thawed (color/Linetype/Lineweight/Plottable/LayerOn/Lock/
   ActiveLayer all fine on it), so a state saved with 0 current restored with
   two warnings and 0 unapplied. Freeze is written only when it changes the
   layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 13:07:31 +03:00
Umut CANandClaude Opus 5 0514f8f15d chore(templates): rebuild the five bundled DXFs on the merged tree (styles via dimstyle_create/textstyle_create, iso_a1_arch annotation scale 1:50)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:40:47 +03:00
Umut CANandClaude Opus 5 d072df9fd4 fix(task-24a): apply_standard decides each unit write on a read taken after the dimension style switch
f7528b2 moved the five unit reads of apply_standard ahead of the style
stage so a refused read is a refusal with nothing written -- and then
decided the writes on those reads. Making a dimension style current
restores every DIM* variable from the style (ezdxf: _sync_header_to_dimstyle
on dimstyle_set_current; AutoCAD: ActiveDimStyle), so on a drawing whose
ISO-25/ANSI already carried another scale the gate read (1.0) equalled
the target, the DIMSCALE write was skipped and the drawing ended at the
style's scale while the report said `changed: {}` -- against the spec's
"LTSCALE 1, DIMSCALE 1" and the tool docstring. The pre-f7528b2 loop ran
after the switch and got this right; this is a regression of f7528b2.

The gate read stays (readable before anything is written); each variable
is read again after the style stage and the write is decided on that
value, so `changed` says what the write moved.

Measured on the running AutoCAD 2026 (25.1s, 2026-09-22, scratch
Documents.Add(), Close(False), real ComBackend after connect()):

    ISO-25 modified to DIMSCALE 2.0 (dimstyle_modify -> {'DIMSCALE': [1.0, 2.0]}),
    Annotative current, doc.GetVariable('DIMSCALE') -> 1.0
    before: apply_standard(iso, layers=False)
            -> {'changed': {}, 'applied': True}; ActiveDimStyle ISO-25;
               doc.GetVariable('DIMSCALE') -> 2.0
    after:  -> {'changed': {'DIMSCALE': [2.0, 1.0]}, 'applied': True};
               ActiveDimStyle ISO-25; doc.GetVariable('DIMSCALE') -> 1.0

ezdxf, same setup (dimstyle_create ISO-25 {DIMSCALE: 2.0}, Standard
current): before `{'INSUNITS': [6, 4]}` and DIMSCALE 2.0 afterwards;
after `{'INSUNITS': [6, 4], 'DIMSCALE': [2.0, 1.0]}` and DIMSCALE 1.0.

Tests: one regression per engine (both fail on the f7528b2 apply.py);
test_com_apply_standard_creates_both_styles_and_sets_units now pins the
gate reads first *and* the re-read-then-write sequence after CopyFrom,
instead of enshrining the stale-read order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:37:33 +03:00
Umut CANandClaude Opus 5 f7528b2fa5 fix(com): every system variable is read and written on the ActiveX document, not the Application
AcadApplication has no GetVariable/SetVariable members; they live on
AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22,
scratch Documents.Add(), Close(False)):

    hasattr(app, 'GetVariable')  -> False
    hasattr(app, 'SetVariable')  -> False
    app.GetVariable('INSUNITS')  -> AttributeError: ... IAcadApplication
                                    instance ... has no attribute 'GetVariable'
    doc.GetVariable('INSUNITS')  -> 4
    doc.GetVariable('MEASUREMENT') -> 1
    doc.GetVariable('FILEDIA')   -> 1; doc.SetVariable('FILEDIA', 0) -> None,
                                    reads back 0, restored to 1
    doc.GetVariable('AUDITCTL')  -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> ''

Fifteen call sites in backends/com_backend.py still went through the
Application inside `try/except ... log.debug`, so on a live seat they
silently degraded: MEASUREMENT defaulted every seat to metric,
drawing_info reported units from a failed INSUNITS read, drawing_audit
never set AUDITCTL, the CMDACTIVE guards of system_run_command /
system_run_lisp never fired and run_lisp never read USERS1 back. All are
rerouted through `_acad_doc()`; the try/except stays only where an
optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a
host that lacks it, USERS1 after the expression ran) and the log line
says so.

Linetype loading, measured the same way: `SendCommand("_-LINETYPE _LOAD
CENTER acadiso.lin\n\n")` loads nothing with FILEDIA=0 and leaves the
command at `Enter an option [?/Create/Load/Set]:` (LASTPROMPT), which
makes the whole seat answer RPC_E_CALL_REJECTED to every client until
ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File"
dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and
the table grows 3 -> 4; a repeat raises 'Duplicate record name', an
unknown name 'Undefined linetype', a missing file 'File system error'.
`_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load`
(MEASUREMENT still read on the document to pick the .lin), touch no
FILEDIA, and report a failure with the English excepinfo description.

`apply_standard` reads every variable it will write before the first
style is created, so a read the engine refuses is a refusal with nothing
written (it used to fail after ISO-25 / ISOCP were created and current).

Fakes: every application fake refuses GetVariable/SetVariable
(tests/test_styles.py's COM fixture carried them on the Application,
enshrining the wrong shape); the members exist on document fakes only.
Regression tests per rerouted site: linetype load via Linetypes.Load with
MEASUREMENT on the document, drawing_info units from the document,
AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document,
apply_standard refusing before any write.

Live end-to-end with the real ComBackend on a scratch document:
apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt,
created: false) and ISOCP (created: true) current, second call created:
false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA
stays 1, repeat already_loaded, 'NOSUCHLT' refused by name;
_ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm';
drawing_audit AUDITCTL 0 -> 0; system_run_command ok;
system_run_lisp('(+ 40 2)') -> '42'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 12:25:11 +03:00
Umut CANandClaude Opus 5 70afd57831 feat(server): settings pack — 22 environment tools behind TOOL_PACKS, lean grows to 55, three groups, golden set +10
Task 24 (merge sanity) on main after the five track E groups merged.

- TOOL_PACK_NAMES = ("core", "pid", "settings"); PACK_TOOL_NAMES["settings"]
  is exactly the 22 SECTION 20 tools. Styles and page setup stay in core.
- LEAN_TOOL_NAMES += page_setup_apply, batch_plot (53 -> 55).
- _GROUP_TAG_PRIORITY / _GROUP_TAG_LABELS: "style" moved up next to "pid",
  "pagesetup" -> page_setup and "environment" -> environment added; the 28
  SECTION 19/20 decorators gain their section tag, so _tool_groups() reports
  styles 10 / page_setup 6 / environment 22 (23 groups, 204 tools) and
  pack == group == section for the settings pack.
- SECTION headers, backends/base.py composition, contracts/__init__.py
  exports and the layer_states feature entry were already correct after the
  merge (read and confirmed, nothing edited).
- Pack text: config.py, .env.example, README config row; README <sub> line
  204 tools in 23 groups; release snapshot 3259 collected tests (measured).
- docs/tool-inventory.json regenerated; registry snapshot re-derived.
- tests: test_tool_packs.py replaced (11 tests), test_capability_contract.py
  +3 (composition, the agreed capability flags, layer_states honesty),
  golden set +10 tuning cases (81 = 66 tuning + 15 holdout); all ten route
  in the top 3 without alias edits.

Deviation from the plan text: TRACK_E_KEYS carries five keys, not six. The
plan's "mleaderstyle" (ezdxf True / com False) contradicts spec §8.1, which
retracted that key because the ActiveX route exists (§4.2); measured on the
tree: capability_keys() == {boundary_trace, chspace, interactive_prompt,
live_application, preferences, revcloud, wipeout}, and
ComBackend.mleaderstyle_create is a native implementation
(backends/com_backend.py:6752, is_capability_default -> False). The test now
also asserts the key is absent.

No new ActiveX call was written in this task, so no live probe was needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:35:00 +03:00
Umut CANandClaude Opus 5 90c1194f7f merge: track-e group V
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:18:03 +03:00
Umut CANandClaude Opus 5 8e05b5e8eb merge: track-e group P
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:14:02 +03:00
Umut CANandClaude Opus 5 3278586c6e merge: track-e group C
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:12:15 +03:00
Umut CANandClaude Opus 5 0a581a8da3 merge: track-e group S
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:10:22 +03:00
Umut CANandClaude Opus 5 6b3477081c merge: track-e group H
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:10:17 +03:00
Umut CANandClaude Opus 5 d313da783a docs(analysis): Track A review backlog — every minor finding with a disposition
Waves 1-4 of the Track A review produced ~170 minor findings that lived
only in the orchestrator's scratchpad. Filed de-duplicated, one line each:
fixed-in-task-N (this track's hardening tasks), fixed-in-track-A (with the
commit), accepted-limitation (with the reason) or deferred-1.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:08:04 +03:00
Umut CANandClaude Opus 5 efcc8bbba1 fix(task-7): crossings test the exact arc, so a line through a jump's apex counts
flatten_bulges chops a semicircle into an even number of chords (36 for
r=50, 12 for r=5), so a flattened vertex lands exactly on the apex — the
axis of symmetry, the one place a P&ID line most plausibly meets a
crossing jump. segments_cross treats a vertex hit as a junction, so both
neighbouring chords dropped it and the arc contributed 0: the (45,0)->(55,0)
bulge -1 jump reported crossings 0 at x=50 and 1 at x=50.3 or x=49, the
plan's own (0,0)->(100,0) semicircle 0 at x=50, and the mirrored frame
(extrusion -Z) 0 at x=-50.

count_crossings now takes bulges= (per-chain DXF bulge lists, zero-padded)
and tests every bulged edge as its exact arc through the new
segment_arc_crossings — circle/segment intersection restricted to the
sweep — with the same junction rule as the straight case: a hit within
_EPS of the segment's ends, of the arc's own two ends, or a tangent, is
not a crossing. Nothing along the sweep is a vertex any more. The arc
parametrisation is factored into _arc_of_bulge and shared with
flatten_bulges, which keeps its promised signature and its ezdxf pin but
no longer feeds the crossing count. draw_line passes the vertices and
bulges through unflattened.

Pinned in tests: the apex case on the semicircle (x=50, y 20->60), the
r=5 jump at x=50 / 50.3 / 49 on +Z and -Z frames, the junction cases
(T-touch on the arc, through the arc's endpoint, chord side, tangent
along the apex, in-and-out at y=4.9 counts 2), and 4x400 seeded random
segment/arc pairs against ezdxf's ConstructionArc.intersect_line (a
20 000-case run in the worktree found 0 mismatches). The eight new
assertions fail on the flattened rule and pass on the exact one.

Headless finding, headless evidence; no new ActiveX call was written, so
the shared live AutoCAD was not touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 11:06:04 +03:00
Umut CANandClaude Opus 5 d3ef7b45a2 feat(discovery): style golden queries; dimstyle/textstyle set_current and drawing_apply_standard join the lean profile
Five tuning cases for the styles surface (DDIM, the ISO-25 set-current
paraphrase, the one-call drafting-standard paraphrase, the isocp text
style, and the dimstyle counting question) — measured against the live
catalog every one ranks top-1, so no alias vocabulary changed. The
docstring count moves to seventy-one (56 tuning + 15 holdout); the
holdout set is untouched.

Lean decision (spec §8.2): dimstyle_set_current, textstyle_set_current
and drawing_apply_standard join LEAN_TOOL_NAMES (50 -> 53); the seven
authoring/list tools stay full-only and all ten stay in core (no pack
claims them). Pinned by the new profile test in tests/test_styles.py,
written async like the rest of that module (its module-wide asyncio
mark warns on a sync test).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 10:51:21 +03:00
Umut CANandClaude Opus 5 9cb4bee587 fix(pid): existing line payloads are read tolerantly and crossings follow arcs, not chords
A foreign or hand-edited ACADMCP_PID payload (from: 'garbage', seq: True)
made the next pid_line_draw raise TypeError; payload_port_refs /
payload_seq now accept only well-formed values and a reader result never
raises. An existing polyline with a bulge was tested as its chord, so a
line through the arc reported crossings 0; flatten_bulges (pure, pinned
against ezdxf.math's flattening) feeds count_crossings on both engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 23:05:54 +03:00
Umut CANandClaude Opus 5 a5d166f365 feat(styles): drawing_apply_standard — ISO or ANSI styles, units and layers in one idempotent call
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:49:12 +03:00
Umut CANandClaude Opus 5 b458923e19 feat(templates): drawing_template_save on both engines — dwt_write is a declared capability
Live AutoCAD writes a real .dwt through SaveAs(path, ac2018_Template = 66) and
puts the description into the summary Comments; headlessly a .dxf template is
written through drawing_save_as and a .dwt request is refused with capability
dwt_write, naming the DXF route and the COM route. drawing_save_as's "dwt"
entry was 5 (acR13_dxf): a DWT request wrote an R13 DXF under a .dwt name.

Probed live on AutoCAD 2026 (typelib: ac2018_Template = 66, acR13_dxf = 5,
acTemplateDwg absent; the saved file starts AC1032, the document is rebound,
Comments takes). SECTION 19 is now 6 tools; SAVEAS joins SHARED_ACAD_COMMANDS;
templates/README.md documents the two files per template.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:48:31 +03:00
Umut CANandClaude Opus 5 cfb5d5082c fix(task-6): the reader no longer hands a signal line the neighbouring pipe's label
pid_graph step 5 ran the TEXT label fallback for every edge without a
number. Once signal lines stopped carrying a fabricated number (3c0babf),
every pneumatic/electric run drawn by pid_line_draw entered it, and a
transmitter-to-controller run 12 mm above a labelled header came back as
line_number="100-P-1", number_source="label" — on both engines, with no
confidence field on edges to flag it.

The fallback now skips an edge that carries its own record (an
ACADMCP_PID payload: its `number: null` is the answer) and any
signal-class edge, payload or layer-classified (`signal_unknown`
included): LINE_NUMBER_RE is the pipe grammar, so a match near a signal
line always belongs to a neighbouring pipe. A foreign process line still
takes its label; a verbatim number on a signal line still reads back
from XDATA.

Tests: the finding's layout in test_pid_graph (engine-drawn signal beside
a labelled pipe, verbatim signal number, foreign electric and
signal_unknown lines beside a labelled foreign pipe) and the line_list
row in test_pid_deliverables — three of the four fail on the old reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:44:35 +03:00
Umut CANandClaude Opus 5 021a4eb43d fix(task-11): dimensions_using_style is one filtered selection set, not a per-entity COM walk
_com_dimensions_using walked every entity of every layout and, since the
un-narrowing fix, re-dispatched each one through a dynamic proxy before
reading ObjectName (~8-10 ms per entity), which put dimstyle_modify past
the 60 s COM_CALL_TIMEOUT on a ~6k-entity drawing after its SetVariable +
CopyFrom had already landed. It is now a single
SelectionSet.Select(acSelectionSetAll, (0 . "DIMENSION") (3 . <style>)),
which answers from AutoCAD's own index whatever the drawing size.

Measured live (AutoCAD 2026, scratch doc, 1000 LINEs + 14 dimensions across
model and paper space): walk 12.7-15.8 s, selection 0.02-0.09 s, identical
handle sets; the whole dimstyle_modify tool now returns in 0.07 s.

The group-3 filter value is a wcmatch pattern, so the style name is
backtick-escaped (_wcmatch_literal): unescaped, "A.B" also selected the
dimensions of "A_B", "N#1" those of "N21", "P[1]" those of "P1", "S@X"
those of "SAX" (measured). Handles are returned in ascending handle order,
Select failures propagate, and the selection set is deleted either way.

tests/test_styles.py: the fake document carries a SelectionSets that
emulates ssget "X" over every layout with wcmatch-literal semantics and
narrowed IAcadEntity items, refusing an unescaped wildcard; three tests
cover no-per-entity-read, escaping, and cleanup-on-failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:39:08 +03:00
Umut CANandClaude Opus 5 17f7228fd6 fix(task-19): a .dxf template really reaches the live seat; the fallback dimvars land on Standard
Documents.Add(<file>) accepts only a genuine DWT. Measured on AutoCAD 2026:
Add(templates/iso_a3_pid.dxf) returned the default acadiso drawing (layers
['0'], Layout1/Layout2) exactly as Add() and Add(<missing.dwt>) did, so
drawing_new(template=<bundled name>) reported ok:true with source
'bundled_dxf' over a blank document, and the explicit .dxf path route was
just as blank. ComBackend.drawing_new now converts any non-.dwt template
through AutoCAD itself (Documents.Open read-only -> SaveAs ac2018_Template,
older constants tried next -> Close(False)), caches the .dwt in the temp
folder keyed on path/size/mtime, hands Add the .dwt, and refuses a missing
template file instead of letting Add default silently. The tool result
carries template.dwt / template.dwt_cached. Re-measured end to end through
server.drawing_new on the real ComBackend: ansi_b_mech -> 11 layers, tab B,
DIMTAD 0 / DIMTIH 1 / DIMEXO 1.5; iso_a3_pid -> 17 layers, tab A3, second
call reuses the cached .dwt (0.8 s conversion once). Fake-ActiveX tests pin
the call shape and that the conversion document never stays open.

Headlessly the build's fallback wrote spec 4.1 only as header DIM variables,
and the renderer folds six of them, so DIMTAD/TIH/TOH/EXE/EXO/GAP/LUNIT/LWD
were a no-op: ansi_b_mech dimensioned with ISO text placement while
system_get_variable and AutoCAD said ANSI. The fallback now also stores each
value on the Standard dimstyle; the five DXFs are rebuilt (--check passes)
and a test reads the effective values off a DIMENSION drawn from each
template and asserts header and style agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:31:44 +03:00
Umut CANandClaude Opus 5 3c0babfef1 fix(pid): signal lines carry no fabricated number; a bubble exits towards the first waypoint
An ISA-5.1 signal line has no pipe line number, yet every pneumatic /
electric line got the bare sequence as its number and a label, and ate a
process sequence number. Signal classes now leave line_number None (a
verbatim one is still honoured), seq None, no label. A radial port aimed
at the far end even with waypoints, so the first segment cut through the
bubble; aim_points (lines.py) aims at the first/last waypoint for both
draw_line and the dry run.

Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file)
had its two _task_pid_roundtrip assertion lines moved to the new truth —
the signal row is unnumbered, the connector row is 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:24:03 +03:00
Umut CANandClaude Opus 5 03a5067d83 fix(task-11): live text styles write TrueType by typeface, refuse missing fonts and arrowhead blocks before any write, un-narrow the dimension walker
Four review findings against the COM styles block, all measured on AutoCAD 2026
(scratch documents from Documents.Add, closed without saving):

1. textstyle_create: `TextStyle.fontFile = "arial.ttf"` raises 'Filer error' for
   every bare TrueType name (bundled presets included) after TextStyles.Add has
   run, leaving a stub with an empty font that a retry meets as "already exists";
   a full path is accepted but the STYLE record then carries the machine path.
   `SetFont("Arial", bold, italic, 0, 0)` — the STYLE dialog's own route — stores
   the clean `arial.ttf`. The font is now located before Add (`_com_locate_font`:
   support path, then the Windows font folders), a TrueType is written by the
   family/subfamily read from its `name` table (`_truetype_face`), an SHX by the
   name given, an unlocatable file is refused with nothing written, and a write
   ActiveX still refuses after Add deletes the entry it added.
2. _ensure_com_textstyle goes through the same helper, so DIMTXSTY / text_style
   ARIAL and ISOCPEUR are created on the first call with a real font instead of
   failing once and then silently binding a fontless style.
3. dimstyle_create / dimstyle_modify check a user DIMBLK/DIMBLK1/DIMBLK2 against
   doc.Blocks before any write (`_com_require_arrowhead_blocks`, the headless
   `_require_arrowhead_blocks` twin, same message and table-spelling rewrite);
   a write that fails after DimStyles.Add restores the previous ActiveDimStyle
   (discarding the unsaved overrides) and deletes the half-made style and any
   preset text style the call created.
4. _com_dimensions_using un-narrows each `Layouts.Item(i).Block.Item(p)` —
   declared IAcadEntity*, so `.StyleName` raised AttributeError under a makepy
   cache and the blanket except reported `[]` — and swallows nothing.

The fake in tests/test_styles.py now models the measured ActiveX rules (bare
TrueType fontFile refused, SetFont by installed typeface, SetVariable refusing an
unknown arrowhead, IAcadEntity narrowing on block items, Delete) so the old code
cannot pass; nine tests cover the four findings. SECTION 18 docstrings state the
live engine's refusal rule for fonts and the pre-write arrowhead check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:17:30 +03:00
Umut CANandClaude Opus 5 dda11755cf fix(task-16): custom keys follow AutoCAD's simple case compare, and on COM AutoCAD routes the write
4fe2d96 matched custom keys with str.casefold(), but AutoCAD's key compare
is a simple one-to-one case compare, not Unicode full case folding.
Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the
first on a scratch document, Close(False)): Project/PROJECT, Grün/GRÜN,
é/É, ÿ/Ÿ, ł/Ł, σ/Σ, я/Я and dž/DŽ are one key ('Duplicate key'), while
Straße/STRASSE, ẞ/ß, kelvin/Kelvin-sign, µ/Μ, σς/ΣΣ and Dž/DŽ are two keys
(both stored) -- every one of those six is casefold()-equal. So with
'Straße' present, `custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': ...}`
was routed to SetCustomByKey, AutoCAD answered 'Key not found', and the
request raised with subject and BEFORE applied and AFTER never written --
the half-write class the fix claimed to close; a delete of 'STRASSE' over
'Straße' raised the same way; headlessly 'STRASSE' overwrote the 'Straße'
value instead of becoming a second tag; and the validator refused
{'Straße', 'STRASSE'} with a reason AutoCAD contradicts.

COM no longer routes from a Python approximation. An exact spelling goes
to SetCustomByKey; anything else is AddCustomInfo, and its 'Duplicate key'
(excepinfo scode -2145386475, measured) falls back to SetCustomByKey. A
delete is RemoveCustomByKey, and its 'Key not found' (scode -2145386476)
is reported as not deleted. Any other com_error keeps propagating. The
scode is matched before the English description because the HRESULT text
is localised.

The headless engine and the validator match on `custom_key_fold`: a
per-character fold to lowercase only when lower()/upper() are single
characters that round-trip and the character is one of the two; it never
changes string length and reproduces every measured pair (ß, ς, µ, the
Kelvin sign, Dž, ı and İ stay as they are). Docstrings in settings.py and
server.py now state that rule instead of 'case-insensitive'.

Tests: parametrized fold over the measured SAME / DISTINCT table (plus a
pin that casefold() merges the six DISTINCT pairs, which is why 4fe2d96
regressed), validator acceptance of each DISTINCT pair, headless
side-by-side storage and deletion of each pair, the fake SummaryInfo now
matches by the same fold and raises pywintypes.com_error with the
measured excepinfo tuples, the COM half-write scenario for each pair
(no SetCustomByKey guess, 'Key not found' delete tolerated, rest of the
request lands), the 'Duplicate key' fallback, and an unrelated
SummaryInfo failure still raising. The scodes were measured live before
the change; AutoCAD stopped answering COM (blocked in another agent's
command or a modal dialog) before the post-change re-run, so that run
is recorded as not done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:04:22 +03:00
Umut CANandClaude Opus 5 a76d9e08a3 feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name)
SECTION 19 grows to five tools with drawing_template_list; drawing_new takes a
bundled name (iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch)
or a .dwt/.dxf path and reports template: {name|path, path, source}.

scripts/build_templates.py builds templates/*.dxf through EzdxfBackend
(layer set, header DIM variables until group S's styles land, drawing_settings,
sheet tab, ISO 5457 frame on A3, page_setup_apply) and --check pins them
reproducible. One drift the plan's same-process measurement could not see is
fixed at the source rather than masked: ezdxf orders CLASSES by set iteration,
so two processes disagreed on LAYOUT / ACDBPLACEHOLDER; the section is re-keyed
(required list first, then by name) before the save.

The root .gitignore ignores *.dxf as generated output; templates/*.dxf is
un-ignored there, since the bundled set is the one DXF that is committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 22:03:41 +03:00
Umut CANandClaude Opus 5 86c5d6ad65 fix(task-18): the headless Model plot is doc.modelspace(), not the current tab
EzdxfBackend.drawing_export_pdf routed layout=None / layout="Model" through
_msp(), which follows the current tab, so with a sheet current the "Model"
PDF was that sheet's paper-space content with no refusal — the same defect
finding 3 named on COM, still live on the headless engine. Measured: circle
in model space, 390x270 rectangle on an A3 sheet, sheet current — the Model
row's /MediaBox was 176.1x121.9 mm (the rectangle) instead of 121.9x121.9
(the circle). The model target is now doc.modelspace() whichever tab is
current, on both entry points.

The test that vouched for this only asserted the row's shape (paper is None,
true by construction). It is replaced by two tests that prove content: the
Model row's mediabox with a sheet current equals the one with Model current
and carries the circle's square aspect, and drawing_export_pdf(path) with no
layout does the same. Both fail without the backend change. plot.py's
docstring now says which engine does what.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:47:05 +03:00
Umut CANandClaude Opus 5 2e101b95e2 fix(task-5): a rollback that fails after a cancellation no longer kills the session, and the COM rollback goes back to an UNDO Mark instead of prompting
Two review findings on 32821c6.

1. Once the shielded rollback body runs after a real client cancellation it
   can fail (the COM hard timeout, _safe_send_command's own deadline, an
   ezdxf quarantine refusal). That RuntimeError escaped run_spec as an
   ordinary error and the mcp lowlevel server, which had already answered
   the cancelled request, tried to respond a second time: `assert not
   self._completed` in mcp/shared/session.py escaped the server task group
   and the whole session died (measured through the real transport: the next
   system_status was never answered and Client.__aexit__ re-raised the
   AssertionError). run_spec now decides by the exception it is handling:
   after a cancellation the rollback error is logged and the cancellation is
   what propagates, so the SDK's cancellation suppression applies; after any
   other error the rollback error propagates as before, with the original
   chained as its context.

2. The docstring promised the rollback on both engines, but on the live
   AutoCAD 2026 ComBackend.transaction_rollback sent `_UNDO B` with no UNDO
   Mark in the stack (StartUndoMark/EndUndoMark form an undo *group*, not a
   Mark), AutoCAD answered "This will undo everything. OK? <Y>", SendCommand
   blocked at that prompt, _run gave up after 60 s, the entities stayed and
   every COM client was rejected until a human pressed ESC. transaction_begin
   now types `_.UNDO _M` before StartUndoMark (refused with ok:false while
   CMDACTIVE, where the Mark would be swallowed as input), and
   transaction_rollback ends the group and sends `_.UNDO _B`, which returns
   exactly to that Mark - also on an empty transaction, where `_UNDO 1` would
   be one step too many. A rollback with no transaction is refused the way the
   headless engine refuses it, because an unmarked Back is that prompt.
   Measured with the real ComBackend in a scratch Documents.Add() document on
   the running AutoCAD 2026 (closed with Close(False)): begin 31 ms ->
   entity_create_line + circle -> rollback 63 ms -> 3 entities back to 1,
   CMDACTIVE 0, no prompt; an empty begin/rollback leaves the older line;
   begin/commit unchanged.

Tests: the two failure modes on run_spec (anyio scope cancellation with a
raising rollback completes the task group and logs; a ValueError with a
raising rollback still raises the rollback error with the ValueError as
context), the real-transport case (client.cancel mid-run, rollback raises,
the next request is answered, cleanup through the real rollback), the COM
shape now pins `_.UNDO _M, StartUndoMark, EndUndoMark, _.UNDO _B`, and
test_com_sprint3 grows a Mark-keeping fake document whose unmarked Back
fails the way the prompt does: begin before the group, rollback consumes the
Mark, rollback without a transaction sends nothing, begin refuses on
CMDACTIVE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:46:08 +03:00
Umut CANandClaude Opus 5 a60f801205 feat(server): SECTION 18 styles — dimstyle/textstyle/mleaderstyle tools with DIMSTYLE/STYLE/MLEADERSTYLE aliases
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:44:02 +03:00
Umut CANandClaude Opus 5 4fe2d963c0 fix(task-16): custom keys mirror AutoCAD's full AddCustomInfo rule, deletes are exempt, keys match case-insensitively
The 2b9791f fix mirrored only the characters the first review happened to
measure ('=' and ';'), so `custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}`
still raised from AddCustomInfo('A:B') on the live engine after subject and
AAA were applied -- the same half-write for 11 other keys. Re-measured on
AutoCAD 2026 by sweeping every character 0x20-0x7E through a scratch
document: exactly thirteen printable characters are 'Invalid key' anywhere
in a key (" * , / : ; < = > ? \ ` |); control characters, internal spaces,
a no-break space and unicode are accepted; leading/trailing whitespace
(space, tab, NBSP) is refused. `_CUSTOM_KEY_FORBIDDEN` is now that set,
the internal-NBSP refusal (wrong: measured accepted) is gone, and the
validator / tool docstrings state the measured rule instead of a closed
enumeration that was false.

A delete (`{key: None}`) is exempt from the AddCustomInfo syntax rules:
RemoveCustomByKey never validates syntax (measured: 'Key not found', never
'Invalid key'), and 2b9791f ran the check before the None branch, so a key
an ezdxf script wrote ('A=B', ' PAD ') showed in drawing_properties_get and
could never be removed. The line-break rule still applies both ways.

Also measured: custom keys are case-insensitive in AutoCAD (AddCustomInfo
over an existing case-variant is 'Duplicate key'; SetCustomByKey and
RemoveCustomByKey match either spelling and keep the stored one). The COM
routing was case-sensitive, so `{'PROJECT': ...}` over 'Project' went to
AddCustomInfo and failed mid-write -- the same class. Both engines now
match existing keys casefolded (update in place under the stored spelling,
never a second tag), and a request naming two case-variants is refused.

Tests: parametrized validator sweep over all thirteen characters, the
accepted set (control chars, NBSP, unicode, the other printables), the
delete exemption headlessly on a DXF carrying such keys and on the fake
SummaryInfo, case-variant refusal, and case-insensitive routing against a
fake that raises 'Duplicate key' / 'Key not found' like AutoCAD. Confirmed
live through the real ComBackend on a scratch document (Close(False)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:37:44 +03:00
Umut CANandClaude Opus 5 f6951e2597 fix(task-18): the COM plot is foreground, by layout name, and confirmed on disk; plot_style_list reads PrinterStyleSheetPath
Three review findings against SECTION 19 on the live engine, each measured
on AutoCAD 2026 in a scratch document with the operator's BACKGROUNDPLOT=2:

- plot_style_list read Preferences.Files.PrintStyleSheetPath; the ActiveX
  member is PrinterStyleSheetPath, so the AttributeError was swallowed and
  every row came back installed=None. Renamed in the backend, the server
  docstring and the test fake (which faked the same wrong name). Live: 13
  rows, all 9 catalogue ctb files installed, four .stb files as installed.
- ComBackend.drawing_export_pdf returned ok before any file existed under
  background plotting, so batch_plot died on path.stat() with an unhandled
  FileNotFoundError and a later foreground plot raised E_FAIL. The export
  now sets BACKGROUNDPLOT=0 around PlotToFile (restored after), honours the
  method's bool and reports ok only with the file on disk (bytes included).
  batch_plot turns a backend RuntimeError or a phantom ok into a per-row
  ok:false, keeping the rows already plotted.
- With a sheet current, the Model row was that sheet plotted again: the COM
  export switched tabs only when a layout was named. layout=None now makes
  Model current (restoring the tab), and batch_plot names every layout,
  Model included, so both engines plot the tab the row is labelled with.
  Live: Model row /Title (Model) at 210x297 beside the A3 sheet, the active
  tab and BACKGROUNDPLOT restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:36:07 +03:00
Umut CANandClaude Opus 5 2052915f34 fix(task-23): user_select reports the picked point in WCS — GetEntity answers in the current UCS, so translate acUCS→acWorld before returning
Measured on AutoCAD 2026: under a UCS at (100,50) rotated 90 deg, a pick on a
circle at WCS (105,70) came back from IAcadUtility.GetEntity as (20,-5,0),
while Utility.GetPoint already answered in WCS. A model doing ucs_set ->
user_select -> entity_create_* at `picked` would place geometry at a silently
wrong position. The point goes through Utility.TranslateCoordinates(acUCS,
acWorld) packed as a VT_ARRAY|VT_R8 VARIANT (the plain tuple GetEntity hands
back is refused as "Invalid argument Point"). The fake Utility now models the
UCS frame and the VARIANT requirement; a new test pins the measured figures
and checks GetPoint stays untranslated. Docstrings state WCS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:34:26 +03:00
Umut CANandClaude Opus 5 266b6fb838 fix(task-10): un-narrow the ACAD_MLEADERSTYLE dictionary route so mleaderstyle_list/create work on the live engine
acax25enu.tlb declares IAcadDictionaries.Item, IAcadDictionary.Item and
IAcadDictionary.AddObject as returning IAcadObject*. With a makepy cache for
the AutoCAD type library present (it is, on any machine where gencache ever
ran against AutoCAD), pywin32 wraps each result in the gen_py class of the
declared type, which exposes only IAcadObject members: .Count/.Item/.GetName
on the dictionary and .ArrowSize/.LandingGap/.TextHeight/.TextStyle on a
style all raised AttributeError, so the previous round's removal of the
@capability refusal turned a typed UnsupportedCapabilityError into a raw
AttributeError on AutoCAD 2026.

`_com_unnarrow` re-dispatches an object on its raw IDispatch through
win32com.client.dynamic.Dispatch (no gencache side effect, unlike CastTo),
and the dictionary route applies it to the dictionary, to every Item(i) and
to the AddObject result. Verified live on AutoCAD 2026 (scratch document,
closed afterwards): list reads Standard = 0.18/0.09/0.18/Standard, create
writes ISO-LDR = 2.5/1.0/2.5/ISOCP, the duplicate is refused, and an
independent late-bound read of the dictionary agrees.

The fake in tests/test_styles.py now models the narrowing: Dictionaries.Item,
Dictionary.Item and Dictionary.AddObject hand back a wrapper that answers only
IAcadObject members and raises on everything else (reads and writes alike)
until unwrapped through _oleobj_. Five COM tests fail against the previous
backend and pass now; a new test pins the missing-dictionary path
(Dictionaries.Add before AddObject).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:54:43 +03:00
Umut CANandClaude Opus 5 2b9791fbcd fix(task-16): custom properties refuse pre-R2004 headlessly, line breaks, and AutoCAD's invalid keys before any write
Three header-only-write / half-write classes in drawing_properties_set:

1. Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after
   $LASTSAVEDBY, which does not exist before R2004, so on an R12 (AC1009) or
   R2000 (AC1015) document the write reported ok, drawing_properties_get
   echoed it from memory, and drawing_save_as (version kept) wrote nothing --
   the class f3ff703 removed for CANNOSCALE. The headless engine now refuses
   with a ValueError naming the DXF version and "save as R2004 or newer",
   before custom_vars is touched; the dwgprops capability reason no longer
   claims custom_properties_supported unconditionally.

2. A line break in a custom key or value passed validation, ezdxf wrote it
   unescaped, and the saved DXF failed to reopen (DXFStructureError) -- the
   whole drawing lost, reported as success. Refused by name in the shared
   validator.

3. Measured on AutoCAD 2026: AddCustomInfo rejects keys with leading/trailing
   whitespace and keys containing '=', ';' or a no-break space as 'Invalid
   key' -- mid-write, after the summary and earlier keys were applied, as an
   unnamed COM error -- while the headless engine wrote them. The shared
   validator now mirrors those rules, so both engines refuse before writing
   (internal spaces, tabs, unicode and 300-char keys stay accepted, as
   AutoCAD accepts them).

Pinned by save+reopen tests on R12/R2000 (refused, file reads back empty),
R2004 (round-trips), the newline case (refused, the file still opens), and
the fake-SummaryInfo COM test (no ActiveX call reaches SummaryInfo).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:45:02 +03:00
Umut CANandClaude Opus 5 f25421709f feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:39:34 +03:00
Umut CANandClaude Opus 5 b83a74e969 feat(environment): system_launch, preference whitelist and operator prompts on COM; declared refusals headlessly
user_pick_point / user_select answer {cancelled: true} on ESC (DISP_E_EXCEPTION)
and {timed_out: true} past COM_CALL_TIMEOUT; preferences_set refuses read-only,
unknown and out-of-range keys before any ActiveX call.

The read-only key is Files.PrinterStyleSheetPath, not the spec's
PrintStyleSheetPath: IAcadPreferencesFiles has no member of that name
(measured live on AutoCAD 2026 / R25.1), and a misspelt key would make
preferences_get() raise on every live call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:34:09 +03:00
Umut CANandClaude Opus 5 32821c6410 fix(task-5): pid_from_spec shields its rollback so a real client cancellation rolls the sheet back on both engines
The MCP SDK cancels a request by cancelling an anyio scope, and anyio
cancellation is level-triggered: task.cancel() is re-issued on every loop
iteration while the task is inside the cancelled scope. run_spec's
`except BaseException: await backend.transaction_rollback()` was therefore
cancelled at its first await, before the rollback body ran. Measured through
the real transport (fastmcp.Client + notifications/cancelled): five symbols
left in an open transaction on ezdxf; on the COM shape EndUndoMark / _UNDO B
never sent while the backend's `finally` already cleared _transaction_active,
so the next transaction_begin nested a StartUndoMark. A native Task.cancel()
(what the plan's Step-4 note reasoned about) is delivered once and clears,
which is why raising CancelledError from a mock passed trivially.

The rollback now runs inside anyio.CancelScope(shield=True). The COM hard
timeout (asyncio.wait_for inside ComBackend._run) still fires inside the
shield, measured at 0.62 s with a 0.5 s deadline, so a hung rollback cannot
freeze the server.

Tests: the mock-raise test is replaced by real cancellations that fail on the
unshielded code — a cancelled anyio task-group scope (parametrized alongside
the native Task.cancel() case), the real transport via Client.cancel(request
id) polled mid-run, and the COM shape (real ComBackend._run, a real
single-thread executor and the real transaction_rollback over a recording
ActiveX document, with a call in flight when the scope is cancelled) which
asserts StartUndoMark, EndUndoMark, _UNDO B land and the next begin is fresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 17:01:17 +03:00
Umut CANandClaude Opus 5 10a7372ce7 feat(settings): drawing_properties_get/set — SummaryInfo on COM, custom properties on both engines
Summary fields are refused headlessly with capability dwgprops before any
write; custom properties are $CUSTOMPROPERTYTAG/$CUSTOMPROPERTY pairs and
survive save + reopen. Validation is shared, by name, and never str()-coerces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 16:59:19 +03:00
Umut CANandClaude Opus 5 54bbf72312 fix(task-17): an inch sheet's fit reports paper units per drawing unit, not the mm ratio times 25.4
`_paper_frame` computed `paper_per_unit = mm_per_unit * unit` where `unit`
is millimetres per paper unit, so an extents+fit plot on an inch sheet
reported `effective_scale: "607.06:1"` for a fit that is `1:1.06276` (off
by 25.4 squared). The render was right (`units_per_mm` is unit-free); only
the label was wrong, and it flowed into `drawing_export_pdf(layout=...)`.
The mm sheet could not see it because there `unit` is 1.

`test_extents_plot_fits_the_printable_area_and_centres` is parametrized
over ISO A3 (mm, width-bound) and ANSI B (inches, height-bound); the ink
position is derived from the centred frame so both cases check the same
invariant. The inch case fails without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 16:55:05 +03:00
Umut CANandClaude Opus 5 e204f227b4 fix(task-10): $DIMBLK header carries the canonical arrowhead name; MLeaderStyle is authored on COM through the ACAD_MLEADERSTYLE dictionary
Two review findings, both root causes.

1. `_sync_header_to_dimstyle` copied the DIMSTYLE entry's ezdxf-internal
   arrowhead spelling (OBLIQUE) into the DXF-facing $DIMBLK/$DIMBLK1/$DIMBLK2
   header, so the saved file named a block it did not hold (the blocks are
   _OBLIQUE / _DOT) and `system_get_variable("DIMBLK")` answered "OBLIQUE"
   headlessly against "_OBLIQUE" on COM and in the same engine's own
   `dimstyle_list` row. The header write now goes through
   `reported_arrowhead` — the same read-side rule `_dimstyle_value` applies.
   Pinned by header, `system_get_variable`, save/reopen (`ezdxf.readfile`
   header equals the block the file holds) and the Restore-to-Standard
   empty-string case in `test_builtin_arrowheads_render_save_and_report_one_spelling`.

2. The `mleaderstyle` capability refusal rested on a false engine boundary.
   acax25enu.tlb (AutoCAD 2026) exposes IAcadMLeaderStyle with Name,
   ArrowSize, LandingGap, TextHeight, TextStyle, ... — exactly what
   `Dictionaries.Item("ACAD_MLEADERSTYLE").Item(i)` returns — and
   IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle") creates one.
   COM `mleaderstyle_list` now reads the four values off those objects
   (`values_available: True`); COM `mleaderstyle_create` validates every value
   (`validate_mleaderstyle`, shared with ezdxf), checks the clash and the text
   style before AddObject, then writes the four properties. The contract
   method is `@abstractmethod` again, the key is gone from both capability
   maps, the fake ActiveX surface models the real call shape, and the spec
   (§4.2, §8.1, §9) states the ActiveX route.

Live AutoCAD was not running on this machine (GetActiveObject: operation
unavailable; no acad.exe), so the COM route is type-library-verified and
fake-tested, not executed live — scripts/smoke_settings_com.py (Task 24)
owns that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 16:48:20 +03:00
Umut CANandClaude Opus 5 c7dd72410a fix(task-22): live named views read and restore the display (VIEWCTR/VIEWSIZE, ZOOM Window), UCS sysvars come from the document, and a frozen current layer is thawed before restore makes it current
Four review findings, all measured by the reviewer on AutoCAD 2026:

1. ucs_list and the ucs_restore('world') CMDACTIVE guard read
   app.GetVariable, but GetVariable is an AcadDocument member — the tool
   was dead live and the guard's swallowed read forced cmd_active to 0,
   sending `_.UCS _W` into any open prompt. Both read the document now,
   and a failed CMDACTIVE read refuses instead of proceeding. The fake
   app in tests/test_views_ucs.py has no GetVariable any more, so the
   old code cannot pass.

2. view_named_save defaulted from doc.ActiveViewport.Center/Height/Width,
   which does not track the display (it answered the initial view after
   `_.ZOOM _C 5,6 20`). The current view is VIEWCTR / VIEWSIZE at the
   SCREENSIZE aspect — what `-VIEW _S` records (width 40.267 for height
   20). The fake viewport is deliberately stale so a read of it fails.

3. view_named_restore wrote Center/Height/Width into the AcadViewport;
   AutoCAD anchors the lower-left corner and widens to the display
   aspect, landing a 10x20 view at (5, 6) on VIEWCTR (20.134, 6). It now
   zooms the saved rectangle (app.ZoomWindow), which centres and fits
   like `-VIEW _R` (VIEWCTR = saved centre, VIEWSIZE = max(h, w/aspect)),
   reports `applied: "zoom_window"` and reads VIEWCTR / VIEWSIZE back.
   Headless the same reconciliation moved the centre whenever the
   *Active VPORT's aspect was rewritten from w/h (even the 1.34 default
   opened at x = 11.734): the aspect the file carries is left alone —
   it is the display's, written by AutoCAD at save — and the window is
   fitted into it; the payload adds `vport_height` / `aspect_ratio`.

4. layer_state_restore set ActiveLayer before the loop and outside the
   try; AutoCAD refuses to make a frozen layer current, so the spec's
   own save → freeze → restore path raised an opaque COM error with
   nothing applied. When `frozen` is being restored the state's current
   layer is thawed first; the write is wrapped and a refusal lands in
   `warnings`. The fake document and layer now model both refusals.

Live AutoCAD was not running in this round (GetActiveObject failed, no
acad process), so the fix is measured against the reviewer's numbers,
not re-measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 16:48:19 +03:00
Umut CANandClaude Opus 5 ac1fba80b7 fix(pid): pid_from_spec refuses non-finite numbers by path and rolls back on any interruption
_num let NaN/inf (and a 400-digit int, as OverflowError) through, so an
INSERT was placed at [nan, 0] and the refusal, when it came, named the
line instead of equipment[0].x. run_spec caught Exception only, so a
CancelledError mid-run left the placed symbols inside an open transaction
(an open undo mark on COM). Finite check in _num; except BaseException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-18 10:51:45 +03:00
Umut CANandClaude Opus 5 a51c1b6e14 fix(task-2): a moved multi-line attribute keeps one placement and its whole value on the headless engine
ezdxf keeps a multi-line ATTRIB's two placement surfaces (the ATTRIB's own
insert/align_point and the embedded MTEXT's insert) in step through
transform() but not translate(): Insert.translate moves only the ATTRIB's
points (1.4.4 attrib.py has no translate). entity_move / entity_copy /
entity_array_rectangular therefore left the embedded MTEXT behind, so
block_explode burst the note 50 units from its symbol (audit clean), and
block_set_attributes / text_find_replace -- which rebuild the MTEXT and let
set_mtext re-place the ATTRIB from it -- teleported the attribute back to
the old spot while the INSERT stayed put (a regression of e7dafb8/b753a12,
which before wrote only dxf.text).

- _anchored_mtext(attrib): the virtual MTEXT re-anchored to the ATTRIB's
  own placement (get_placement() through its OCS, the inverse of ezdxf's
  _update_location_from_mtext). _burst_attrib and _write_attrib_value read
  through it, so a drawing whose surfaces already disagree bursts and
  writes at the ATTRIB's placement instead of the stale MTEXT's.
- _translate(ent, ...): translate + settle every multi-line ATTRIB's MTEXT
  onto the ATTRIB (the set_mtext round trip ezdxf's own transform uses),
  keeping the whole value in group 1. Used by move, copy, rectangular array.
- _transform(ent, m): transform + restore group 1 of every multi-line
  ATTRIB -- BaseAttrib.transform goes through set_mtext, which mirrors only
  the first line, so rotate/scale/mirror/polar array/change_space turned
  'A\PB' into 'A' on the one surface an R2010 save keeps.
- block_insert of a multi-line ATTDEF: add_auto_attribs embeds through
  embed_mtext with the same first-line mirror; the whole value goes into
  group 1 the way _write_attrib_value keeps it.

Tests: move/copy/rectangular array carry the MTEXT (explode lands on the
symbol), set_attributes and find_replace after a move leave the attribute
where the move put it, a rotated reference moves both surfaces by the same
delta, a stale drawing bursts/writes at the ATTRIB placement, rotate/scale/
mirror/polar and a fresh insert keep the whole value across an R2010
round trip, and a single-line reference is unchanged. 13 fail before the
fix, all pass after; full suite 2511 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-18 10:12:56 +03:00
Umut CANandClaude Opus 5 bbc9aeeb63 fix(task-21): registry identity is the file (st_dev/st_ino), not its spelling — a hard link, junction or case variant reloads instead of registering twice
`_file_identity` keyed on normcase(abspath), which folds case and separators
only, and only on Windows. Two names for one file — a hard link (which
Path.resolve() cannot fold either), an NTFS junction reached in-process, or
a case variant on macOS's case-insensitive APFS where normcase is the
identity — registered two entries: both rows said `saved: True`, a save from
either overwrote the other's bytes, and closing the 'clean' one dropped its
work as `discarded_changes: False`.

Identity now asks the disk: `os.stat` (st_dev, st_ino) when the path exists
(the samefile identity), `normcase(abspath)` only for a path not on disk yet
(a save target, or an owner whose file was removed) or a filesystem that
reports no inode. `_register_document`, `_path_holder`,
`_resolve_document_key`, the `_doc_path` setter and the quarantine's
same-path warning all go through the one helper. An untitled entry holds no
file and never matches a path.

Tests: the `_case_folding_fs` guard probes the temp filesystem instead of
`os.path.normcase`, so the case-variant tests run on macOS where they used
to skip while the bug was live. New: hard link (backend and over the wire),
junction (mklink /J; a directory symlink elsewhere), a case variant with
normcase given its POSIX definition, and the spelling fallback for a save
target that is not on disk. Four of the five fail on the previous backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-18 10:08:12 +03:00
Umut CANandClaude Opus 5 b464bcdaf5 fix(task-4): block_define dedupes requested layers case-insensitively so one layer in two spellings is created and reported once
DXF and ActiveX layer tables are case-insensitive, but block_define collected
the primitives' layers with a case-sensitive set. A request naming 'Alpha' and
'ALPHA' with create_layers=True made ezdxf add the first, then raise
DXFTableEntryError on the second after the write — layer left behind, no block,
surfaced as an internal error instead of a named refusal. COM ran Layers.Add
twice, got the existing layer back the second time, and reported two layers
created for one that appeared; the two engines diverged and the refusal message
without create_layers named the same layer twice.

referenced_layers() in backends/block_specs.py keys the set on the folded name
and keeps the first spelling; both engines consume it. _FakeLayers in the
hardening tests now matches Item/Add case-insensitively and returns the existing
record on a duplicate Add, as the ActiveX table does, so the COM path is pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-18 00:23:16 +03:00
Umut CANandClaude Opus 5 375c9f4c70 fix(blocks): block_define refuses a non-finite base point and a missing layer unless create_layers
base_x/base_y were only float()-cast, so a NaN base point was written into
the BLOCK record; a primitive on a layer the drawing lacks was written as a
dangling reference doc.audit() never flags (and on COM would fail mid-loop
after Blocks.Add). validate_base_point names the key; the layer table is
probed before the first write and the missing names are refused, or created
and reported as layers_created when create_layers=true (new optional kwarg,
contract + both engines + tool).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-18 00:11:38 +03:00
Umut CANandClaude Opus 5 7f79497928 fix(blocks): attribute values are validated before any write; null/bool/objects refused by tag
block_insert and block_set_attributes applied str() to whatever arrived, so
a JSON null became the text None and a non-mapping failed after the INSERT
was written. validate_attribute_values (backends/block_specs.py) runs first
on both engines: strings pass the one-line rule, ints and finite floats are
written as plain digits, everything else is a TypeError naming the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 23:59:17 +03:00
Umut CANandClaude Opus 5 31daac022f fix(task-21): layout_create, layout_set_current and viewport_create mark the headless document dirty — the close refusal and the saved row now see them, and each gets an undo step
document_close's promise (never drop unsaved work unless told to) and the
`saved` row of document_list are only as good as _DocState.dirty, and three
headless mutators never set it: a new layout tab, the $TILEMODE / active
layout write, and a paper-space VIEWPORT all changed the file yet closed
without refusal, reported discarded_changes: False, and were absent on disk.
COM refused the same sequence (Saved is AutoCAD's DBMOD), so the two engines
disagreed. Each now calls _mark_dirty() after its write, which also pushes
the undo snapshot every other mutator already has.

The regression test runs the save -> mutate -> list/close/undo/redo -> close
with save sequence for all three and checks the change reached the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 23:37:21 +03:00
Umut CANandClaude Opus 5 b753a12bb6 fix(task-2): find_replace rewrites every surface of a multi-line attribute; COM explode propagates a failing read and undoes a failed burst
Headless text_find_replace matched and wrote only dxf.text of an ATTRIB/ATTDEF, so on a multi-line attribute it reported replaced: 1 while block_get_attributes and block_explode still carried the old embedded MTEXT (and a needle on the second line was never found). The ATTRIB/ATTDEF branch now reads through _attrib_value and writes through _write_attrib_value, the same pair block_get_attributes / block_set_attributes use.

COM block_explode swallowed a failing GetAttributes() (attrs = ()), GetConstantAttributes() and Invisible, then exploded, deleted every ATTDEF placeholder and the reference and returned ok: True with no attribute text -- every value destroyed silently on a transient RPC_E_CALL_REJECTED. Those reads now happen before Explode() and propagate with nothing written; a failure after the explode deletes the members it added and the TEXTs written so far and leaves the reference in place (_undo_explode), so the drawing is never left half-burst under an exception. The fakes gain the members a live AcadBlockReference / AcadAttributeDefinition always expose (GetConstantAttributes, Constant, Invisible) instead of relying on the swallow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 23:32:56 +03:00
Umut CANandClaude Opus 5 b1c93cdd2a fix(task-21): the applied ISO layer set is per document — it follows document_activate on ezdxf and is cleared on every document switch on both engines
`drawing_apply_iso_layers` recorded its standard in `_active_layer_set` on
the backend, and `_role_layer` reads it ahead of the PlanSpec to decide where
`dimension_auto` / `construction_xline` file their output. It was neither a
`_DocState` field nor cleared by `_reset_document_state`, so an iso13567
sheet next to a mech one pushed the mech drawing's dimensions onto
`M-DIMEN-T-N` (created on demand), silently — and `drawing_critique` could
not see it because the same stale set drove the critique. The attribute
predates the registry (v1.5 `drawing_new` did not reset it either), but the
per-document claim of Task 21 covered it and the measured attribute list
missed it.

- `_DocState.active_layer_set` + `_ActiveField("active_layer_set")` on
  `EzdxfBackend`: the set follows the active entry, bidirectionally.
- `PremiumContract._reset_document_state` clears it (declared on the mixin
  like `_plan_spec`), which fixes COM's `_ensure_document_state` switch and
  `drawing_new` / `drawing_open` / `disconnect` on both engines.
- tests: `test_active_layer_set_is_per_document` (headless registry, the
  reviewer's repro plus the reverse direction) and the COM activate test now
  also asserts the set is cleared and `_role_layer('dim')` is back on DIM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 23:17:39 +03:00
Umut CANandClaude Opus 5 aeb27c252b fix(task-17): the headless page setup writes the scale AutoCAD plots by, and reads the code 75 AutoCAD writes
Two measured root causes on the headless engine, both silent:

1. page_setup_apply wrote PLOTSETTINGS group 147 (unit_factor) as 1.0 for
   every scale. Under USE_STANDARD_SCALE AutoCAD's plot engine scales by
   group 147 alone (a 1:10 sheet with code 21 and 147 = 1.0 plotted a
   100-unit line at 99.99 mm while the row, `applied` and 142/143 said
   1:10; patching 147 to 0.1 alone gave 9.99 mm). AutoCAD writes 147 as
   numerator / denominator (measured live on AutoCAD 2026 for fit, 1:1,
   1:2, 1:10, 1:50, 2:1, 10:1: 1.0, 1.0, 0.5, 0.1, 0.02, 2.0, 10.0). The
   writer now stores 75, 142/143 and 147 from the one resolved ratio on
   every route, so no reader can disagree with the plot.

2. DXF_STANDARD_SCALE_TYPE and _plot_scale followed ezdxf's STD_SCALES,
   which has no 19 = 1:5 and numbers everything above 18 one lower than
   the ObjectARX StdScaleType enum AutoCAD actually writes (1:10 = 21,
   1:20 = 23, 1:50 = 26, 1:100 = 27, 2:1 = 28, 10:1 = 31 — measured via COM
   SaveAs DXF). An AutoCAD-authored 1:50 sheet listed as "1:100" and a
   1:10 sheet rendered at 1:16. papers.py now authors the full file enum
   (DXF_STD_SCALES, codes 1-33 with AutoCAD's imperial names), the writer
   uses the same codes ActiveX uses, and nothing goes through ezdxf's
   table. 1:5 stays on the custom route on both engines (the live setter
   refuses 19) while the reader knows code 19 for files AutoCAD wrote.

Tests: the plan's `1:50 -> 25` assertion was the stale numbering and now
pins 26; a pure-data test pins the enum against the measured codes and the
ActiveX numbering; a parametrised writer test checks 75/142/143/147 for
every catalogue scale; a reader test stores the scale exactly as AutoCAD
saves it (including codes 19 and 22 the writer never emits) and checks
page_setup_list and _paper_frame report and render it at its own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 15:29:47 +03:00
Umut CANandClaude Opus 5 5f33613460 fix(task-2): COM burst MTEXT anchors after the attachment; headless multi-line attribute values persist below R2018
Two review findings on the multi-line-attribute paths of block_explode /
block_set_attributes.

COM: `_add_mtext_from_capture` wrote `InsertionPoint` before
`AttachmentPoint`. ActiveX's `AttachmentPoint` keeps the text body where it
is and relocates the anchor, so every non-TopLeft attribute was laid out
TopLeft-at-anchor with only the label moved (live: a BottomLeft two-line
note landed two lines low, BottomRight also a box width right). The anchor
is now written last, unconditionally, after the attachment and the frame.
Verified live on AutoCAD 2026: every alignment's bbox matches the control
and the end-to-end burst MTEXT sits at the ATTRIB's TextAlignmentPoint.
The fake MTEXT now relocates `InsertionPoint` on `AttachmentPoint`/`Normal`
writes the way ActiveX does, both tests assert where the anchor ended up,
and the alignment test uses BottomRight rather than the default TopLeft
that could never show the displacement.

ezdxf: `_write_attrib_value` relied on `set_mtext`, which mirrors only the
first line into `dxf.text`; below R2018 ezdxf exports no embedded MTEXT,
so the default R2010 document saved `NEW ONE` where the reader had
promised `NEW ONE\PNEW TWO\PNEW THREE`. `dxf.text` now carries the whole
value (AutoCAD's own `TextString` convention) after the MTEXT rebuild;
the R2010 round-trip test uses a `\P` value, and an R2018 round trip
pins both surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 15:24:44 +03:00
Umut CANandClaude Opus 5 75c13196b9 fix(task-15): angle sysvars are radians on both engines; OSMODE is registry-saved and refused headlessly
Three authored claims in SYSVAR_CATALOG were false against the engines:

- HPANG said "degrees" but ActiveX GetVariable/SetVariable (and AutoLISP
  getvar) hold radians - measured on AutoCAD 2026 in a scratch document:
  SetVariable(pi/4) reads back 0.7853981633974483 on both paths. The row
  now says radians, like POLARANG.
- ANGBASE said "radians" with both engines True, but the DXF header stores
  $ANGBASE in degrees (AutoCAD saved the 1.5708-radian seat value as 90.0)
  and ezdxf read/wrote it raw, so the two engines disagreed by a factor of
  57.3. EzdxfBackend now translates that header variable at its boundary
  (_HEADER_DEGREE_SYSVARS): the tool speaks radians on both engines, the
  file keeps degrees.
- OSMODE claimed engines.ezdxf True, but $OSMODE is an R12-only header
  variable ezdxf never exports for R2000+: a headless write reported
  ok: True and read back None after save + reopen. It is registry-saved
  in AutoCAD, so it joins _UNSAVED_SYSVARS (refused with
  capability: registry_sysvar; the facade's `osmode` key refuses and
  snapshots None headlessly, like polar / polar_angle). Headless reads of
  every registry-saved variable now report None instead of whatever the
  ezdxf template holds in memory.

The gate that should have caught all three, tests/test_sysvar_catalog.py's
default round-trip, wrote 56 no-ops out of 66 and never saved: it now
writes a value that differs from the fresh document's for every writable
ezdxf-True entry, saves, reopens and reads back from the file. Two new
tests pin OSMODE's refusal and ANGBASE's radians-at-the-tool /
degrees-in-the-file split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 15:20:02 +03:00
Umut CANandClaude Opus 5 64cec37cc7 fix(task-22): ucs_list decides "world" from the frame, not the empty name — an unnamed UCS is its own current row
An unnamed UCS (UCS Origin / 3P without saving, the common kind) has an
empty UCSNAME exactly like WCS does, so both engines answered
`current: world` while the drawing was not in WCS — verified live on
AutoCAD 2026 (`_.UCS _O 10,10,0` → UCSNAME="" WORLDUCS=0) and on the DXF
it writes ($UCSNAME='' $UCSORG=(0,20,0)). A silent wrong value.

- COM: a named match still wins; otherwise WORLDUCS (AutoCAD's own answer)
  decides, and WORLDUCS=0 appends a `name: None` row built from
  UCSORG/UCSXDIR/UCSYDIR.
- ezdxf: the same rule against $UCSORG/$UCSXDIR/$UCSYDIR via the new
  `engineering.environment.ucs.is_world_axes`; a stale $UCSNAME falls back
  to the frame instead of leaving no row current.
- Server `ucs_list` result: `current` is null for an unnamed UCS (never
  `world`), plus `current_unnamed: bool` so null is unambiguous.
- Tests: helper, headless (unnamed row, stale name, and the exact AutoCAD
  DXF over the wire), and COM fake reading WORLDUCS + the frame variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 15:10:00 +03:00
Umut CANandClaude Opus 5 e7dafb85ab fix(task-2): headless block_set_attributes rewrites the embedded MTEXT; block_explode refuses members ezdxf would silently drop
Two review findings on the headless engine, both content lost with ok: True.

Multi-line attribute writer: `block_set_attributes` set only `attrib.dxf.text`,
so after an edit `block_get_attributes` reported the new value while
`block_explode` (`_burst_attrib`) burst the embedded MTEXT at its pre-edit
content -- and on the default R2010 document, where ezdxf exports no embedded
MTEXT, a save/reload carried the new value the exploded drawing never showed.
`_write_attrib_value` now rebuilds the embedded MTEXT from its own virtual
entity with the new content (`virtual_mtext_entity()` + `set_mtext(...,
graphic_properties=False)`, the round trip ezdxf's `BaseAttrib.transform`
uses), keeping placement, style and height; `_attrib_value` reports the
embedded MTEXT's full `Line1\PLine2` content for a multi-line attribute, as
AutoCAD's `TextString` does, so get / set / explode / COM agree.

Opaque members: `block_explode` iterated `virtual_entities()` without a
`skipped_entity_callback`, so ezdxf dropped every member it cannot copy
(OLE2FRAME) or transform (VIEWPORT) with a debug log, an ACAD_PROXY_ENTITY
without proxy graphics contributed nothing, and the reference was then deleted.
`_explodable_members` materializes the list before any write and refuses by
member with the typed capability `explode_opaque_members` (declared on both
engines: headless False with the reason, COM native), naming the block and
the live way out. The block_explode docstrings (backend and tool) now state
this refusal instead of claiming the xref/MINSERT gate covered everything.

Tests: eight new headless cases in tests/test_track_a_hardening.py -- the
multi-line reader, the rewrite (value on every surface, placement unchanged,
clean audit), a `\P` value and the empty value, an R2010 save/reload, the
single-line path unchanged, the opaque-member refusal (nothing written,
reference alive, members named), an ordinary block still exploding under a
uniform scale, and the capability declared on both engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 15:02:44 +03:00
Umut CANandClaude Opus 5 f2386a9fdd fix(task-17): a rotated sheet's margins turn with it, and a paper-units move is named in changed and warnings
Finding 1: page_setup_list swapped size_mm for plot_rotation 1/3 but
reported the margins un-turned, so a 297 x 210 sheet carried its portrait
margins ([20, 20, 7.5, 7.5], printable 282 x 170) while ezdxf's Page and
_paper_frame plotted [7.5, 7.5, 20, 20] (257 x 195). The mapping now lives
once in papers.turned_sheet (cross-checked against Page.from_dxf_layout
for all four rotations) and is used by the ezdxf row, _paper_frame and the
COM row. page_setup_apply resets plot_rotation to 0; headlessly it now
re-writes the turned margins un-turned so the sheet keeps the margins it
had (nothing moves, changed says nothing), and on COM, where margins come
from the .pc3, the ones that moved show up in changed.

Finding 2: both engines forced plot paper units to millimetres with no
units field in the row, so an inch-authored ANSI sheet was rescaled 25.4x
with ok: True and changed naming only the media. Rows now carry
paper_units (mm | inches | pixels, DXF group 72 / AcPlotPaperUnits);
resolve_page_setup takes paper_units="mm" (default) | "inches" | None
(keep the layout's units, as AutoCAD's own Page Setup does); a move is
reported in changed and, with its 25.4x consequence and the way back, in
the new warnings list (papers.page_setup_warnings, identical on both
engines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 14:58:17 +03:00
Umut CANandClaude Opus 5 125a8ed3b8 feat(settings): SYSVAR_CATALOG + system_variable_describe; system_set_variable refuses out-of-range values
90 authored rows (type, range/enum, default, meaning, saved-in, per-engine
support, friendly key); every ezdxf claim is checked against the real
backend and the registry-saved set is pinned to what the backend refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 14:56:25 +03:00
Umut CANandClaude Opus 5 4d246b17b8 fix(task-9): DIMDSEP refuses control characters and code points above the 16-bit DXF group
check_dim_value's char branch only checked len == 1, so '\n', '\t', '\x00'
and non-BMP characters passed and the headless writer stored ord(value)
unguarded: '\n' split the dimension text over two lines, NUL read back as a
comma, and U+1F600 reached $DIMDSEP / group 278 as 128512, outside the 16-bit
range and refused by AutoCAD as "not a single character". ezdxf's audit
caught none of it. The same hole 827719f closed for names and fonts, left
open for the one variable stored as a character code.

The gate now refuses code < 32, DEL, and code > DIMDSEP_MAX_CODE (0x7FFF,
the signed short DWG stores) with a ValueError naming DIMDSEP, so nothing
reaches a backend. Pinned by a parametrized refusal test (newline, tab, NUL,
DEL, U+1F600, U+8000, U+FFFF), a positive test that the middle dot, the
Arabic decimal separator and U+7FFF still pass, and the ezdxf refusal test
now proves dimstyle_create / dimstyle_modify write nothing for these.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 14:52:57 +03:00
Umut CANandClaude Opus 5 6cb7a13c66 fix(task-2): block_explode bursts multi-line attributes as MTEXT and refuses xrefs and MINSERTs on both engines
A multi-line ATTRIB keeps its content in an embedded MTEXT; dxf.text is only
the first line (ezdxf) or "Line1\PLine2" (AutoCAD), so the TEXT the explode
emitted silently dropped lines or rendered literal \P with a clean audit.
ezdxf: `_burst_attrib` adds `virtual_mtext_entity()` (graphic properties and
WCS placement already carried; the reference matrix applied for a constant
ATTDEF) and marks it invisible like the ATTRIB. COM: `_capture_attrib` reads
`MTextAttribute` / `MTextAttributeContent` / `MTextBoundaryWidth` and
`_add_mtext_from_capture` goes through `AddMText` (height, style, frame,
then Rotation, then the attachment mapped from the ATTRIB's Alignment).

Two INSERT kinds `virtual_entities()` cannot explode were destroyed with
ok: True: an xref (no virtual entities, reference deleted) and a MINSERT
(only the first cell resolved, the rest of the grid gone). Both are now
refused by name before any write, on both engines (`block_record.is_xref`
/ `mcount > 1` headless; `Blocks.Item(Name).IsXRef` / `AcDbMInsertBlock`
live, the latter previously failing the INSERT check with the misleading
"not a block reference").

Tests: nine new cases in tests/test_track_a_hardening.py (headless multi-line
ATTRIB and constant ATTDEF, xref and MINSERT refusals, a single-cell grid
that still explodes; COM multi-line capture, frame-before-angle, and both
named refusals). The fake ActiveX surface gains AddMText and IsXRef.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 10:16:26 +03:00
Umut CANandClaude Opus 5 f3ff703144 fix(task-14): refuse CANNOSCALE on an R12 file headlessly instead of losing it at save
Measured: an R12 (AC1009) document has no OBJECTS section, so ezdxf exports
neither AcDbVariableDictionary/DICTIONARYVAR nor the seeded SCALE objects, and
drawing_save_as keeps AC1009. `_set_annotation_scale` reported `ok: True,
changed 1:1 -> 1:50` and the file reloaded as 1:1 -- the header-only write
that vanishes on reload, the class this task exists to remove, and a breach
of the backend's own R12 honesty convention (viewport_set_scale / lock).

The write is now refused with a ValueError naming R12 and "save as R2000 or
newer", after parse_scale (a malformed value is still named as such) and
before either dictionary is touched, so a refused call leaves the root
dictionary as it found it and drawing_settings still applies the sibling keys
(limits / grid / ortho survive R12). The refusal is listed with the others in
the contract and server docstrings. Pinned by two tests that save, reopen and
read the R12 file back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 10:14:05 +03:00
Umut CANandClaude Opus 5 4bf686e943 fix(task-22): headless named views are written as plan views and restore carries the orientation
ezdxf's VIEW default is direction=(1, 1, 1) — an isometric view — so every
named view the headless engine wrote to disk was oblique while the tool
claimed to store the plan window of center/height; AutoCAD restored it in
that DCS. view_named_save now writes direction (0,0,1), target (0,0,0) and
view_twist 0 explicitly. view_named_restore copies direction/target/twist
from the VIEW into the *Active VPORT instead of flattening a foreign
oblique view to plan, reading the attributes the way the file carries them
(ezdxf forces those tags on export). Measured live on AutoCAD 2026:
Views.Add already creates a plan view, so the COM path only gains a note.

Tests: the VIEW entry and the reopened DXF assert 11/21/31 = 0/0/1 and the
target; a hand-built oblique, twisted VIEW restores its orientation into
the VPORT and a tool-saved view puts it back on plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 10:09:14 +03:00
Umut CANandClaude Opus 5 4a6591843f fix(task-17): the headless sheet export plots at the layout's scale and plot area, and reports both
drawing_export_pdf sized the axes window as the sheet at 1:1 whatever
page_setup_apply had written: a 1:2 sheet exported pixel-identical to
the same sheet at 1:1, ok: True, with a MediaBox that read as correct,
so nothing downstream (batch_plot's mediabox gate included) could see it.

_paper_frame now spans the sheet in paper-space units — sheet_mm / unit
x denominator / numerator, the custom ratio for the labels code 75 lacks
(1:5, 5:1) — and honours the plot area: layout (5) from the plot origin;
extents (1) as the paper-space extents (the main viewport excluded, as
AutoCAD excludes it) at the plot origin or centred, scaled to the
printable area under fit. fit under a layout plot is 1:1, the choice
AutoCAD greys out. display/view/limits/window fall back to the sheet
window and say so. The result carries scale, effective_scale,
scale_applied, plot_area, plot_area_applied (and plot_area_note), the
way rotation_applied is already reported.

Tests look at the ink, since the MediaBox cannot see this: an 800-unit
frame ends at (left + 400) / 420 of the width at 1:2 and runs off the
sheet at 1:1; extents + fit spans the printable width exactly; the
custom 1:5 window is 2100 units; an empty extents plot and a display
plot are reported, not hidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 10:01:49 +03:00
Umut CANandClaude Opus 5 2ee18727cd fix(task-10): user dimstyles survive DXF round trips, purge keeps style references, Restore writes every DIM* variable
Three headless defects in the styles block, each measured before the fix:

* `_normalise_dimstyle_rounding` cleaned `Standard` only, while ezdxf's
  exporter writes DIMRND 0.0 for every DIMSTYLE. A user style (ISO-25 / ANSI)
  came back from save/open, transaction_rollback and drawing_undo rounding
  every dimension to a whole unit ("33" for 33.333, "R6" for R6.35) with
  dimstyle_list still reporting DIMDEC 2. It now walks the whole table.

* `drawing_purge` followed only entity `.dxf.style`, so it deleted the text
  style a DIMSTYLE (DIMTXSTY), an MLEADERSTYLE (text_style_handle) or the
  header ($TEXTSTYLE / $DIMTXSTY) referenced; the next dimension silently
  rendered in Standard and drawing_save_as raised DXFTableEntryError after
  leaving a truncated file. `_style_table_references` now counts those, plus
  the arrowhead blocks a DIMSTYLE / MLEADERSTYLE / MULTILEADER references,
  compared case-insensitively.

* `_sync_header_to_dimstyle` wrote only the attributes the target style
  stores, leaving the previous style's $DIM* in the header — folded onto the
  next dimension by `_with_header_dimvars` (ISO-25 DIMZIN 0 / DIMSCALE 2.0
  then Restore Standard rendered 5.0 mm "33.30") and written to the file as
  current-style overrides; $DIMRND 0.5 outlived the style's own discard. It
  now writes the schema default for every whitelist variable the style omits
  (`Standard` for DIMTXSTY), which reproduces a fresh header exactly.

tests/test_styles.py: the reopen test now renders a dimension after the
reopen, plus one test per defect; all four fail on the previous backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 10:00:17 +03:00
Umut CANandClaude Opus 5 b12fa854c1 fix(task-2): block_explode writes Normal as a VARIANT and keeps constant attributes as TEXT
COM: ``text.Normal`` was a plain Python tuple, which ActiveX marshals as
VT_ARRAY|VT_VARIANT and refuses with E_INVALIDARG (verified live on
AutoCAD 2026); because the write came after Explode(), the ATTDEF deletes
and AddText, the tool raised and left the drawing torn. It now goes
through ``_apoint`` like every other point write, and the fake ActiveX
surface refuses a bare tuple on Normal/InsertionPoint/TextAlignmentPoint
so a test cannot pin the wrong call shape again.

COM: every AcDbAttributeDefinition Explode() returned was deleted, but
GetAttributes() excludes constant attributes, so a constant attribute's
only visible text was destroyed with ok: True. A constant ATTDEF from the
explode (Constant member, or its tag in GetConstantAttributes()) is now
captured like an ATTRIB and replaced by a TEXT; placeholders are still
just deleted.

ezdxf: an AutoCAD-authored reference carries no ATTRIB for a constant
attribute and virtual_entities() skips ATTDEF, so the same text vanished
headlessly. A constant ATTDEF whose tag has no attached ATTRIB becomes a
TEXT transformed by the reference matrix, the path add_auto_attribs takes,
and is not converted twice when block_insert did attach one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:52:58 +03:00
Umut CANandClaude Opus 5 909bc47e5e fix(task-14): seed AutoCAD's default scale list before adding an annotation scale headlessly
An ezdxf-authored drawing has no ACAD_SCALELIST entries, and
`_ensure_scale_entry` appended only the requested scale. AutoCAD repopulates
its default list only when the dictionary is *empty*, so the saved file
opened with a one-entry scale list and `SetVariable("CANNOSCALE", "1:1")`
failed with `Error setting system variable` -- the annotation scale could
no longer be changed in AutoCAD, while the tool reported `ok: True` and the
test pinned the degraded state (`len(keys) == 1`).

An empty list is now seeded with AutoCAD's own default metric list before
the requested scale is considered: the 17 entries a fresh AutoCAD 2026
drawing carries (`Documents.Add()` saved as DXF, MEASUREMENT 1), in that
order and under AutoCAD's own keys A0 ... A9, B0 ... B6. New entries take
the next key in that sequence (`_scale_list_key`) instead of being keyed by
their name; presence is still decided by group 300, so a non-empty
AutoCAD-authored list is never re-seeded or duplicated. Verified live: the
headless file now opens in AutoCAD 2026 with the 17 keys, CANNOSCALE 1:50
current, and switching to 1:1 and 1:100 works.

Tests: the fresh-drawing test asserts the seeded names and keys, that
setting 1:50 appends nothing (it is a default), that 1:25 lands under B7
once, and that the list survives save + reopen with 1:1 present; a new test
pins that *reading* the annotation scale creates nothing (the read side
stays pure, AutoCAD fills an empty list itself); the AutoCAD-keyed list test
now expects the A3 key. The `drawing_settings` docstring says what each
engine does and that AutoCAD refuses the live write on a paper-space layout
with no active viewport (measured).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:49:38 +03:00
Umut CANandClaude Opus 5 33604c4408 fix(task-14): read and write system variables on the ActiveX document, not the Application
`system_get_variable` / `system_set_variable` called `GetVariable` /
`SetVariable` on the object `_acad_app()` returns. Those are `AcadDocument`
members; `AcadApplication` has neither, so on a live seat every call died
with `AttributeError: AutoCAD.Application.GetVariable` before anything
reached AutoCAD -- `drawing_settings` (all keys, read and write) was dead on
the COM engine although both engines were promised. Measured on AutoCAD
2026: `hasattr(app, "GetVariable")` is False. Both methods now resolve the
document through `_acad_doc()`, whose "No drawing is open" refusal is what a
seat without a document reports. Verified live through `ComBackend`:
LIMMAX reads (277.0, 202.5); `drawing_settings({"annotation_scale": "1:50",
"polar_angle": 45, "polar": True})` applies and reads back 1:50 / 0.02 /
45.0 / True in model space.

The test fake shipped with the task had GetVariable / SetVariable on the
*Application*, so the gate asserted a call shape the real object does not
have. `_FakeApp` now carries only `ActiveDocument` + `Documents.Count` and
raises `AttributeError` for the two names, exactly like the real
Application; the sysvar store is a `_FakeDoc` that the real `_acad_doc()`
resolves. Two regression tests: the document is the target (call list), and
no open drawing raises the clear RuntimeError. The older
`tests/test_com_sprint3.py` coercion test pinned the same wrong target and
now fakes the document.

`tests/test_com_backend.py::_measure_backend` replaced the module-level
`_acad_doc` without ever restoring it, so a MagicMock document leaked into
every later test of the session -- invisible while nothing downstream
resolved the document itself, fatal once the sysvar path did. An autouse
fixture in that module restores it.

Not touched (same root cause, other owners): `app.GetVariable` /
`app.SetVariable` in `_ensure_linetype_loaded`, `drawing_info`,
`drawing_audit`, `system_run_command` and `system_run_lisp` in
backends/com_backend.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:49:25 +03:00
Umut CANandClaude Opus 5 dcc8f07bf4 feat(environment): portable layer states, named views and UCS on both engines
Layer states are ACADMCP_LAYERSTATES XRECORD chunks (<= 255 ASCII chars each),
in the file and not in AutoCAD's Layer States Manager. Headless view restore
writes the *Active VPORT (ezdxf's header has no $VIEWCTR; measured). UCS is
stored and made current; tool coordinates stay WCS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:47:39 +03:00
Umut CANandClaude Opus 5 376dc812a5 fix(task-17): COM page setup reads mm as mm, 1:5 takes the custom route, and a refused apply puts the media back
Three live-measured behaviours of AutoCAD 2026 that the fake did not replay:

* GetPaperSize / GetPaperMargins answer in millimetres whatever PaperUnits
  says; the 25.4 factor listed every imperial layout as [10668.0, 7543.8]
  and lost the paper name. No factor now; the fake pins PaperUnits=0.
* Layout.StandardScale = 19 (ac1_5) is refused with "Invalid input" in every
  condition; '1:5' is dropped from ACTIVEX_PLOT_SCALE so it goes through
  SetCustomScale(1, 5) like 5:1 already did.
* Writing ConfigName replaces CanonicalMediaName with the new device's
  default, and writing the old device back does not bring the media back —
  the unknown-media refusal and a late refusal both left the layout on the
  old device's default paper while the error claimed it was restored. The
  device write now snapshots media / units / rotation and journals one
  combined undo; the read-back after every unwind is compared to the
  read-back before the first write and the message names what did not go
  back instead of asserting it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:42:13 +03:00
Umut CANandClaude Opus 5 cc3bd4876b feat(styles): dimension, text and multileader styles on both engines; $DIMSTYLE/$TEXTSTYLE reach new entities headlessly
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-17 09:39:16 +03:00
Umut CANandClaude Opus 5 415de73137 fix(task-2): COM block_explode carries the ATTRIB frame and style onto the TEXT
The BURST path on the live engine read TextString/InsertionPoint/Height/
Rotation/Layer/Invisible and nothing else, so the +Z TEXT that AddText
builds got the ATTRIB's OCS Rotation with no Normal: a mirrored
reference's tag (Normal (0,0,-1), the same premise the ezdxf regression
pins) came back reading theta instead of 180deg-theta, un-mirrored, on
the wrong side of its anchor at theta=0, with ok: True. Style members
(StyleName, ObliqueAngle, ScaleFactor, Backward/UpsideDown, Alignment +
TextAlignmentPoint, Color) were dropped the same way.

_capture_attrib_text_members reads the ActiveX twins of the headless
_ATTRIB_TO_TEXT carry, guarded per member and never written as a guess;
a +Z Normal is not carried and a left-aligned ATTRIB drops its
TextAlignmentPoint (ActiveX refuses it). Writes go Normal/Thickness/
style -> Rotation -> InsertionPoint re-asserted in WCS when the frame
changed -> Alignment -> TextAlignmentPoint -> Layer -> Color -> Visible.

Two fake-surface tests: the mirrored/styled ATTRIB pins every write and
the Normal-before-Rotation and Normal-before-InsertionPoint order; the
bare ATTRIB pins that absent members are skipped. Both fail on the
previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 22:01:08 +03:00
Umut CANandClaude Opus 5 20ca39d28a fix(task-17): a layout plot never writes CenterPlot, and a refused COM write unwinds every landed one
The ActiveX Reference (CenterPlot Property, acadauto.chm, AutoCAD 2026):
"This property cannot be set to True on a layout object whose PlotType
property is set to acLayout." The shipped sequence wrote PlotType=5 and
then CenterPlot=True for the *default* call, so page_setup_apply(layout,
resolve_page_setup("ISO_A3")) could not succeed live — either COM raised
with nine writes landed and nothing put back, or the result claimed
applied.center=True over a read-back of False.

- resolve_page_setup resolves `center` to None for plot_area="layout"
  (validated as a bool first; kept as the bool for "extents"), and
  `center_applies(plot_type)` carries the rule for both read-backs.
- Both engines: page_setup_list reports `center: None` under acLayout
  instead of a stored flag nothing honours; apply never sets the centre
  bit / CenterPlot for a layout plot (ezdxf clears bit 4, the state
  AutoCAD stores for acLayout; COM clears a stale CenterPlot=True *before*
  PlotType moves to acLayout, and writes CenterPlot for an extents plot
  only *after* PlotType=acExtents).
- COM: every write is journaled and unwound in reverse when AutoCAD
  refuses one; the ValueError names the refused property and says the
  layout was restored (or what could not be). The old device-only
  rollback is the same mechanism now.
- tests: the fake enforces the documented CenterPlot rule (the shipped
  sequence fails under it, which is the reviewer's repro), and can refuse
  a named property; new cases pin the no-write, the ordering both ways,
  the reverse unwind including SetCustomScale, and the None read-backs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:55:14 +03:00
Umut CANandClaude Opus 5 2e2cd2773d fix(task-9): signed DIMTP/DIMTM, read-side arrowhead spelling, and the Task 10 handshake
Two blocking review findings on the standards data layer:

* DIMTP and DIMTM were pinned to ("float", 0.0, 1e6), refusing every signed
  deviation with a false "must be at least 0". AutoCAD documents both as
  signed and the repo already relies on it: build_dim_override writes
  dimtm=-0.022 for ISO 286 p6 and dimtp=-0.020 for f7, and
  dimension_linear(fit=...) emits exactly those. A dimension style could
  therefore never hold a double-positive or double-negative fit that the same
  server draws correctly per dimension, and an agent obeying the message
  would ship a sign-flipped tolerance. Both are now ("float", -1e6, 1e6); a
  provenance test resolves the p6/f7/H7/h6 overrides build_dim_override
  emits through resolve_dimstyle and pins the signs.

* 827719f made check_dim_value return DIMBLK/DIMBLK1/DIMBLK2 in the
  canonical _OBLIQUE spelling and added ezdxf_arrowhead without telling
  Task 10, whose plan helpers store the value verbatim and read the raw
  string back - executed as written the headless drawing raised at render
  and at doc.write, a user block passed the data layer and failed at save,
  and ezdxf's OBLIQUE-in-session / _OBLIQUE-after-reload split made an
  idempotent re-set report a change (spec section 8.3). This commit adds the
  read-side twin reported_arrowhead (never raises; a foreign name passes
  through), pins it with an in-session-equals-reloaded round trip, and edits
  the plan: Task 9 Produces and Task 10 Consumes now name ARROWHEAD_BLOCKS,
  canonical_arrowhead, ezdxf_arrowhead and reported_arrowhead; Task 10's
  _write_dimstyle_values maps through ezdxf_arrowhead, _dimstyle_value and
  _com_dimvar_for_report map through reported_arrowhead, a new
  _require_arrowhead_blocks refuses a user block absent from doc.blocks
  before dimstyle_create/modify write anything (and rewrites the name to the
  block table's own spelling, measured to be what a reload reports), and two
  headless tests witness the rendered INSERTs, the save/reopen spelling and
  the refusal. The plan's own copy of the range table is corrected to match
  the module (arrow kind, signed tolerances). The edited helper block was
  executed against ezdxf 1.4.4 before being written into the plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:52:01 +03:00
Umut CANandClaude Opus 5 2805697d16 fix(task-21): registry identity is normcase(abspath) everywhere — a case variant reloads instead of registering twice, resolves instead of being refused, and drawing_close(save) checks the file it writes
aeab950 claimed one registry entry per file but only the two save paths
compared with normcase. _register_document keyed by the plain
os.path.abspath string, so on Windows drawing_open of an already-open
file spelled with another case registered a second entry (no `reloaded`,
open_documents: 2); from there every document_activate/close of either
entry was refused as ambiguous, and drawing_close(save=True) — which
bypasses the resolver — wrote the second entry's bytes to the shared
file while the surviving row reported saved: True. The resolver's
by_path route had the same plain-string comparison, so over the wire a
lowercase drive letter was refused 'no open document named ...' while
ComBackend._com_find_document lowercases and accepts it.

One helper, _file_identity(path) = normcase(abspath(path)), now decides
every registry comparison: _register_document consults _path_holder so
another spelling of an open file reloads it; the _doc_path setter re-keys
only when the file changes, so a case-variant save keeps the key a caller
holds; _resolve_document_key's by_path compares identities;
_close_document_sync refuses at the write when another entry holds the
file; _clear_quarantine's same-path warning uses the same rule. Keys stay
spelled as the caller spelled them — the identity is for comparing, not
for showing.

Five tests pin it (four skip on a case-sensitive filesystem, where two
spellings of one file cannot exist), including the wire route through
fastmcp.Client that the review measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:51:16 +03:00
Umut CANandClaude Opus 5 df2ab91900 fix(task-14): return the plain point list from COM system_set_variable, not the VARIANT
The tuple/list branch put the win32com VARIANT built for SetVariable into the
returned dict. The system_set_variable tool returns that dict verbatim under
output_schema {"type": "object"}; pydantic cannot serialise a VARIANT, FastMCP
drops the structured content and the client sees "outputSchema defined but no
structured output returned" -- after AutoCAD had already applied the write, so
every raw point-variable write (LIMMIN, LIMMAX, GRIDUNIT, SNAPUNIT, INSBASE)
was a retry/undo trap on the live engine while the audit log said TOOL OK.

The VARIANT is now only the SetVariable argument; "value" carries the
coerced Python list. The fake-ActiveX test asserts the reply, and a new
Client(server.mcp) test drives the tool through the output schema so the
serialisation path is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:48:09 +03:00
Umut CANandClaude Opus 5 13851a3d20 fix(task-2): block_explode keeps the ATTRIB frame and writes into the owner layout
Two silent wrong-place defects in the BURST path. An ATTRIB is an OCS
entity, so the TEXT that replaces it now carries extrusion/thickness with
insert/align_point; a mirrored reference (ATTRIB reflected to (0,0,-1),
INSERT +Z with yscale=-1) put the tag 50 units across the mirror axis with
a clean audit. Members and TEXTs now go into the INSERT's owner layout
(ezdxf get_layout, COM ObjectIdToObject(OwnerID)) instead of the current
space: a sheet title block exploded while Model was current duplicated
geometry into model space and then died on delete_entity headless, and on
COM landed the tag text in model space with ok: True. A reference nested
in a block definition is refused before any write on both engines.

Five regression tests (mirrored INSERT, sheet-owned INSERT with Model
current, nested INSERT, and the COM owner-block / nested-refusal call
shapes) fail on the previous commit and pass here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:43:08 +03:00
Umut CANandClaude Opus 5 aeab950d2d fix(task-21): one registry entry per file — save/save_as refuse a path another document holds; resolver refuses a shared path instead of guessing
drawing_save / drawing_save_as onto a file that another open entry holds
used to succeed: the registry then carried two entries for one path, the
resolver's exact-key shortcut picked the stale entry on document_close, and
the file ended up holding the other document's bytes while both rows still
reported saved: True. Both save paths now refuse before doc.saveas (as
AutoCAD refuses SAVEAS onto an open drawing), the _doc_path setter raises
rather than let two entries share a path, and _resolve_document_key refuses
a shared file on every route — exact key, path and basename — naming the
breach instead of advising "pass the full path" into the unchecked shortcut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:36:44 +03:00
Umut CANandClaude Opus 5 827719fdd0 fix(task-9): finite text-style numbers, one symbol-name rule, canonical DIMBLK arrowheads
Three review findings on the standards data layer, each a value the layer
let through that a backend would then write as a silent wrong number or a
drawing that can no longer be saved:

* validate_textstyle never checked math.isfinite, unlike its siblings; nan
  and inf passed every comparison and reached the STYLE record verbatim.
  It now has the same _number helper dimstyles and mleaderstyles use.
* The three private _ILLEGAL_NAME_CHARS copies omitted \n \r \t that
  security._FORBIDDEN_SYMBOL_CHARS holds, so 'a\nb' was accepted as a text
  style, DIMTXSTY or leader text style and ezdxf refused the save. The rule
  now lives once in engineering/standards/names.py (fastmcp-free), the
  three modules import it, and a test pins its character set equal to
  security's and its output equal to illegal_symbol_name_chars.
* DIMBLK/DIMBLK1/DIMBLK2 were ("str",): any string went through, and the
  test pinned '_OBLIQUE', which ezdxf's writer refuses at doc.write while
  the COM engine wants exactly that spelling. They are now ("arrow",):
  canonical_arrowhead accepts a built-in with or without its underscore in
  any case (and '', '.', CLOSEDFILLED for the closed filled default), emits
  AutoCAD's documented block-name spelling (also what ezdxf reports after a
  load), and holds user block names to the symbol-name rule; ARROWHEAD_BLOCKS
  is pinned equal to ezdxf's ARROWS vocabulary, and ezdxf_arrowhead is the
  one translation the headless writer needs, proved by exporting all twenty
  headlessly and reading each back as its canonical name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:33:05 +03:00
Umut CANandClaude Opus 5 e8e7e3c524 fix(task-14): match ACAD_SCALELIST entries by their group-300 name, not the dictionary key
`_ensure_scale_entry` decided "already present" with `scales.get(name)`, a
lookup by DICTIONARY key. AutoCAD saves ACAD_SCALELIST keyed A0, A1, ... with
the scale name only in the SCALE object's AcDbScale group 300, so on any
AutoCAD-authored drawing (whose default metric list already carries 1:1 ...
1:100, 2:1) the check never matched and every `annotation_scale` write
appended a second SCALE object with the same name.

Presence is now decided by iterating the list and reading each entry's
group-300 name (`_scale_entry_name`, which reads the DXFTagStorage subclass
ezdxf loads SCALE into). A genuinely new entry is still keyed by its name
(the plan's test contract); if that key is somehow taken it falls back to
the next free `A<n>` slot like AutoCAD.

Regression test seeds an A0/A1/A2-keyed list the way AutoCAD writes it and
asserts: an existing 1:50 is reused (no new key, exactly one object named
1:50), a missing 1:25 is appended exactly once and is idempotent, and the
file round-trips with a clean audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:31:13 +03:00
Umut CANandClaude Opus 5 70f18efb4b fix(task-17): rotated sheets (plot_rotation 1/3) render the whole page instead of a 0.707-scaled, clipped window
AutoCAD stores ISO A4 landscape as 210 x 297 media with plot_rotation 1;
paper-space X then runs along the 297 mm side. _paper_frame turned the
figure but not the axes window, so a 297 x 210 page carried a 210-wide data
window; set_aspect("equal") shrank the axes box to 0.707 and everything past
x = 210 minus the margin was cut, with ok: True and a MediaBox that read as
correct. The window now takes the turned size, and the margins turn with it
the way ezdxf's own Page.from_dxf_layout maps them (rotation 2 included).

Test: a rotated layout with a 277 mm frame exported to PNG; the ink must
reach the right of the sheet (it stopped at column 1314/1753, now 1752),
and the MediaBox still reads 297 x 210.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:30:52 +03:00
Umut CANandClaude Opus 5 19610b8f05 fix(blocks): block_explode keeps attribute text as TEXT (BURST rule) on both engines
virtual_entities() yields geometry only, so an exploded tagged symbol lost
its tag value silently. Every attached ATTRIB now becomes a TEXT at the
same WCS placement/height/rotation/style/layer (invisible stays
invisible), reported as attribute_texts. COM reads GetAttributes before
Explode, deletes the ATTDEF placeholders Explode returns, AddText per
value, and deletes the reference ActiveX Explode leaves behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:26:27 +03:00
Umut CANandClaude Opus 5 174fb8010c feat(environment): multi-document on both engines — ezdxf document registry, document_list/activate/close, drawing_close folded in
EnvironmentContract carries every member of the track E interface; the two
drawing_properties_* members are @capability('dwgprops') in this worktree only
and become @abstractmethod when group C's implementations merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:20:21 +03:00
Umut CANandClaude Opus 5 df39b21f2e feat(settings): drawing_settings gains limits, grid/snap, ortho/polar, psltscale, annotation scale, unit formats and current styles
Headlessly, grid/snap are stored on the active VPORT, CANNOSCALE in
AcDbVariableDictionary (+ an ACAD_SCALELIST entry), and LIMMIN/LIMMAX on
the model-space layout as well as the header — measured: the header alone
is overwritten at save. Registry-saved variables (AUTOSNAP, POLARANG, …)
are refused with capability registry_sysvar instead of vanishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:14:31 +03:00
Umut CANandClaude Opus 5 f46fda10cb feat(pagesetup): page_setup_list/apply on both engines — the PDF's own /MediaBox is the evidence
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:13:48 +03:00
Umut CANandClaude Opus 5 b9510bf377 fix(blocks): entity_create_block_ref refuses an undefined or layout block before writing, on both engines
Track A's task-1 gate covered block_insert only; the sibling tool still
returned a handle for a typo'd name that doc.audit() later deleted, and
both tools accepted *Model_Space (a reference cycle). One gate per engine
(_require_block / _require_block_defined) is now shared by both placing
tools: non-string name -> TypeError, unknown -> ValueError naming it,
layout block -> ValueError, all before add_blockref / InsertBlock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:13:44 +03:00
Umut CANandClaude Opus 5 54fac471a9 feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:11:26 +03:00
Umut CANandClaude Opus 5 49fc6ac6a5 docs(plan): demote the embedded Track A backlog headings so task lookup by '### Task N:' is unambiguous
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:07:42 +03:00
Umut CANandClaude Opus 5 4bd651b159 docs: Track E (settings and environment) implementation plan — 26 TDD tasks in five worktree groups
Skeleton (contracts, interfaces, file ownership, merge notes) plus the
drafted task steps for Track A hardening (H), styles (S), settings (C),
page setup and templates (P), environment (V) and the final tasks (F);
25 cross-group consistency findings reconciled before commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 21:07:26 +03:00
Umut CANandClaude Opus 5 ab258d1383 docs: Track E (settings and environment) design spec
38 tools in three sections: ISO-25/ANSI dimension, text and leader styles
with drawing_apply_standard; drawing_settings growth, a system-variable
catalogue and document properties; page setup with PDF mediabox read-back,
batch plot and five bundled templates built by the server's own tools;
multi-document on both engines, portable layer states, named views, UCS,
launch, preferences and operator prompts. Track A hardening is wave 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 15:22:01 +03:00
Umut CANandClaude Opus 5 2be5e4180d docs(pid): track A shipped — CLAUDE.md, README, changelog with the live COM smoke record
scripts/smoke_pid_com.py runs the whole track against the live AutoCAD in
a new document. ComBackend.connect() is lazy by design, so the script reads
reachability from system_status()["connected"] rather than from connect()
as the plan sketched, and closes its document without saving unless --keep
is passed. On this machine (AutoCAD 2026, "25.1s (LMS Tech)") it exits 0:
5 nodes, 4 edges, 0 dangling, confidence_min 1.0, 0 critique issues,
2 XDATA chunks — identical to the headless engine on the same spec. The
first attempt exited 2 on a stale win32com gen_py cache (a package folder
holding only __pycache__); an environment fault, no backend change needed.

A/B against v1.5.1 published as ab-v1.5.1-vs-v1.6.0-dev.json: 26/29 ->
29/29, three miss -> pass, zero regressed. check_doc_numbers.py now names
the report and the two row labels in one place (AB_REPORT, AB_*_LABEL),
and README.md / benchmarks/README.md carry the new rows; the v1.5.0 and
v1.4.0 tables stay as history.

README: P&ID row, the showcase and catalogue images, the token table
re-measured (161 / 44,930 by default, 152 / 41,420 with TOOL_PACKS=core,
lean 50 / 14,007), every stale count reconciled (154 -> 166 registered,
149 -> 161 advertised, corpus 158/672 -> 162/731 by the same count, 19 ->
20 groups, 6 -> 8 resources, six-task matrix with pid_roundtrip), a
"Known limitations of the P&ID track" subsection, and the snapshot line at
this machine's 2423 collected tests (CI has not run on this tree; noted in
the changelog with the pre-existing 1369 vs 1363 drift).

CLAUDE.md: section 17, sections 5 and 7 counts, TOOL_PACKS, the two 1.6
contracts and the reader rules. Spec status -> implemented. The plan and
spec markdown were reformatted by ruff (fenced Python only) because
`ruff format --check .` was already red on them at HEAD and the gate
requires the whole tree clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 13:34:06 +03:00
Umut CANandClaude Opus 5 8d9c444b76 bench: matrix v4 adds pid_roundtrip; correctness suite grows to 29 with three P&ID checks
tasks_v4.py extends v3 with one task the reader can fail on its own: the
example spec is drawn through pid_from_spec's code and read back by the
graph builder, which never sees the spec. The runner, the chart and the
doc-number gate now default to v4; --matrix v3 / v2 reproduce the earlier
sets, and tests/test_benchmark_v3.py labels its full-matrix run "v3"
explicitly now that the default has moved.

The plan's adapter check expected the two unnumbered lines to come back
with no line number; draw_line builds one from the default number_format
(bare sequence when size/service/spec/insulation are empty), so the task
asserts the two authored numbers on the two process lines and sequence-
built XDATA numbers on the other two instead.

The published A/B report still covers 26 checks; Task 21 re-runs it
against v1.5.1, which is what clears the remaining doc-number drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 13:06:52 +03:00
Umut CANandClaude Opus 5 e911a3a8fb feat(server): TOOL_PACKS — advertise only the vertical packs a client uses
TOOL_PACKS=all (default) | comma list of core,pid. core is everything not
claimed by another pack and can never be dropped; unknown entries are
ignored with a warning. Pack hiding composes with TOOL_PROFILE=lean (the
lean set now carries pid_symbol_insert / pid_line_draw / pid_graph, 50
tools) and ENABLE_3D. system_about reports {enabled, available, ignored};
cad_batch's unknown_tool rejection names the TOOL_PACKS gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:55:38 +03:00
Umut CANandClaude Opus 5 42154e025b fix(task-19): the contact-sheet test fails when the symbols are missing
render() reported symbols = len(all_specs()) without rendering anything, and
the plan's bytes > 10_000 threshold was calibrated for view_screenshot's
16 x 9 in figure, not the 20 x 22 in sheet, where a labels-only render is
85 KB. A builder that draws nothing passed the test.

render() now reports what was drawn: symbols counts the specs whose insert
left an entity with a real extent in the modelspace (blank names the rest;
a spec break is one vertical line, so either axis counts), and ink is the
share of PNG pixels that are not the background colour. The test renders a
labels-only baseline with insert_symbol monkeypatched to draw nothing and
checks that baseline reports zero symbols, is past the byte threshold, and
carries less ink than the real sheet (measured 0.0234 vs 0.0273).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:42:54 +03:00
Umut CANandClaude Opus 5 c441609254 feat(pid): pid_tag_parse, catalogue and ISA-5.1 resources, prompt rewritten onto the tools
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:29:04 +03:00
Umut CANandClaude Opus 5 d80ae17411 feat(pid): pid_from_spec — a whole P&ID in one transaction, graph and critique returned
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:10:14 +03:00
Umut CANandClaude Opus 5 6aded6bbf0 fix(task-16): hints only name ports pid_line_draw accepts; signal_unknown is judged as a signal line
Two review findings on the P&ID critique focuses.

The graph's own `nearest` is computed over every port in its grid, including
the `p1..pn` ports it invents on a promoted unknown_block, so a free end near
a sheet frame was hinted to connect to the frame — a call `pid_line_draw`
refuses, since the frame carries no ACADMCP_PID payload. Every dangling row
is now re-hinted over connectable ports only (catalogue / XDATA nodes,
declared ports), so every hint is executable.

`signal_unknown` — the class the reader assigns to a signal-layer line whose
exact class it cannot tell — was skipped by the incompatibility check
because it is not in the drawable class table. Its kind is still signal, so
a signal-layer line ending on a process port is now an error as spec §11.1
requires; only an unclassified line is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 12:01:37 +03:00
Umut CANandClaude Opus 5 3c4fbf6c42 fix(task-16): a P&ID line ending on an unknown_block is dangling — a 0.3 guess suppresses no finding either
715c8ef applied the evidence rule in one direction only: an `unknown_block`
(any INSERT a plain LINE happened to touch, filed at 0.3) could not *create*
a pid_dangling_line, but it could still *suppress* one. build_graph attaches
a line end that lands on such a box and invents port `p1` at the line's own
end, so `graph["dangling"]` is empty and `_dangling`, which only read that
list, called the line connected. A process line from P-101's discharge to
the sheet frame (a CTO title block is a block reference) passed
drawing_critique and the drawing_finalize gate; the same line stopping 2 mm
short of the frame was an error. Spec §11.1 says the end resolves to nothing
in both cases — a frame, a logo or a note symbol is nothing the drawing
vouches for.

`_dangling` now reads two sources with one rule: the graph's own dangling
rows (filtered to evidenced edges, as before) plus every end of an evidenced
edge whose ref points at an `unknown_block`, at that inferred port's x/y.
The `nearest` hint is computed the way the graph computes it for a free end
(nearest port within 10·tolerance, same space), over recognised nodes' ports
only, so the invented `p1` can never be the suggested fix; `detail["touches"]`
names the block the end sits on. The other direction is unchanged: a note
leader whose only anchor is an unknown_block is not evidenced and stays out.

Tests: the reviewer's repro (landing on the frame → error; graph attaches it,
dangling=[]), the stop-short control, the finalize gate raising on it, and
the nearest-port hint naming a catalogue valve's port 1 mm away rather than
the frame's inferred port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 11:51:22 +03:00
Umut CANandClaude Opus 5 715c8efca4 fix(task-16): P&ID focuses score evidence, never a 0.3 touch guess or an inferred port kind
Two ways the six focuses turned a reader guess into a gate-failing error:

- The "no P&ID content" guard was `not graph["nodes"]`, but build_graph
  (include_foreign=True) files ANY INSERT as an `unknown_block` at 0.3 the
  moment a plain LINE's end lands on its box. A bolt with a note leader on a
  purely mechanical sheet became a P&ID node, the leader a foreign edge, and
  its free end a `pid_dangling_line` error — so drawing_finalize (focus=None)
  failed a sheet the seven mechanical focuses pass. Now `has_pid_content`
  needs a recognised node (catalog / xdata / heuristic); an `unknown_block`
  is never tagged or scored, and `_dangling` only reports an edge that is a
  P&ID line by evidence (a class from XDATA or a P&ID layer, or an end on a
  recognised node) — on a real P&ID the same leader is not dangling either.

- `_incompatible` read `port["kind"]` off inferred ports, which the graph
  hard-codes to "process" for every foreign block. A signal line correctly
  drawn to a CTO control valve's actuator was an error whose hint said to
  redraw the control signal as a process line. Inferred ports are skipped;
  only a declared (catalogue / XDATA) port kind is judged.

Tests: both repros as regressions (unit + the server drawing_finalize gate),
a foreign GATE_VALVE whose dangling line is still reported (the heuristic
path spec §9.3 pins is not silenced), a note leader on a real P&ID, and the
spec's own negative — all six return [] on the README gear sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 11:35:35 +03:00
Umut CANandClaude Opus 5 b4c1423c8e feat(pid): six P&ID critique focuses join drawing_finalize; plan/preflight flag a P&ID intent on the wrong layer set
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 11:17:55 +03:00
Umut CANandClaude Opus 5 346eac4ac6 fix(task-15): instrument connected_to follows junction ends and never names its own wire
instrument_index only resolved {"node"} ends of a signal line, so a
transmitter tapping the middle of a pipe (a {"junction"} end) reported
connected_to="" and an instrument on untagged equipment fell back to the
signal line's own number — which Task 13 always stamps, so the handle
fallback was unreachable and every untagged mounting was labelled with
its wire. Spec 9.5's "line numbers" clause is exactly the junction case.

_far_ends now names the far node's tag (its handle when untagged) and,
at a junction, the line number of every line the junction joins (that
line's handle when unnumbered). Two tests exercise both mountings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 11:02:11 +03:00
Umut CANandClaude Opus 5 4e523ac6ed feat(pid): instrument index, line list and equipment list from the graph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 10:49:30 +03:00
Umut CANandClaude Opus 5 6ad3734c47 fix(task-14): per-space resolution, mirrored inserts, tags above the box, per-axis scale
Closes the four verified BLOCKING findings on pid_graph.

1. scope="all" attached across layouts: the port grid, bbox candidates,
   vertex grid, junctions and text lookup were pooled from every space.
   The grid is now keyed by (space, cell) and every lookup filters by the
   edge's space; junctions carry `space`.
2. A mirrored INSERT (extrusion -Z, as MIRROR3D / foreign DXFs store it)
   got its catalogue ports on the unmirrored image at 1.0. Both engines
   report `mirrored` on INSERT properties (ezdxf: extrusion z < 0; COM:
   Normal z < 0; default False); transform_port takes `mirrored` and
   applies x -> -x about the WCS insertion point after rotation/scale,
   direction (180 - d) mod 360 — cross-checked against Insert.matrix44.
   resolve_endpoint passes it through too, so draw_line starts on the
   mirror image as well.
3. The text-tag fallback searched a full circle around the box top, so a
   line label or note below the symbol beat the tag above it. Candidates
   must now sit at or above top - height/2 (spec §9.2 step 6), nearest
   wins, and line-number texts are never equipment tags.
4. A non-uniform INSERT scale re-ran the transform with the X factor and
   set `inferred` (reserved for foreign-block ports). transform_port
   gains `strict=False` for the reader: point ports scale per axis
   exactly; a radial port keeps min(sx, sy)·r and the node drops to 0.6
   with notes ["non-uniform scale"]. Node dicts gain `y_scale`,
   `mirrored` and `notes` (default []).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 10:35:20 +03:00
Umut CANandClaude Opus 5 ea47cbdd7e fix(task-14): a border INSERT is not a port, and a turned symbol's box is its geometry
Two verified blocking findings on pid_graph.

A foreign block's inferred ports were any line end within tolerance of
its drawn box *or strictly inside it*. A sheet border or title block is
an INSERT too, and its box encloses the whole drawing, so every end that
missed a port attached to the border instead of becoming the junction
or dangling end the tool exists to report: on a border sheet the plan's
own small P&ID read back with no junction, no dangling end and its open
signal line "connected" to A3_BORDER. The test is the boundary again, as
spec 9.2 step 3 says (within tolerance of the block's bounding-box
boundary); overshoot into a body is the tolerance's business. The
nearest-insertion tie-break stays for two boundaries that share an end.

The COM engine's `geometry_bbox` carried each definition member's own
axis-aligned box through the INSERT corner by corner, which is exact at
a right-angle rotation and too large everywhere else: a 45-degree-turned
circle of radius 5 read as 7.07, a diagonal line stood upright read 7.07
wide, and the two engines disagreed on the same drawing with no marker.
Off a right angle the member's real geometry is now measured - a LINE,
POINT and straight polyline by their vertices, a CIRCLE by the
closed-form extents of the scaled-and-turned ellipse, an ARC and a
bulged polyline by their ends and the quadrant points the sweep passes
(uniform scale, reflection reversing the sense) - and cross-checked
against ezdxf's `virtual_entities` extents at 37 and 45 degrees, scaled
and mirrored. A member that cannot be measured (text, hatch, a nested
reference, an elliptical arc) is still carried by its corners and the
box then says `approximate: true`; the graph takes the tighter wall of
that box and the attribute-inclusive `bounding_box`, both of which hold
the drawn geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 10:09:04 +03:00
Umut CANandClaude Opus 5 d17a26103f fix(task-14): arcs past a semicircle and foreign ports behind tag text
Two verified blocking findings on pid_graph.

`_point_segment_distance` put the centre of an arc larger than a
semicircle (|bulge| > 1) on the wrong side of the chord: `copysign`
discarded the sign of `radius - sagitta`, which is exactly what carries
the centre across the chord past 180 degrees. A bulge-2 arc was tested
as its own reflection, so a line ending on its true apex dangled and one
ending on empty paper made a junction. The offset now keeps that sign
and only takes the bulge's direction from the bulge.

Foreign-block port inference tested line ends against the INSERT's
`bounding_box`, which on both engines takes the ATTRIB text with it. A
TAG lettered above a valve pushed the box past the body, so a line
ending on the body's real edge on that side was *inside* the box and
reported dangling. Both engines now report `geometry_bbox` for an
INSERT - the drawn geometry only, ATTRIB/ATTDEF excluded, in WCS, omitted
when the block draws nothing (ezdxf via `virtual_entities`, COM by
carrying each definition member's box through the INSERT's origin,
scale and rotation). The graph prefers it over `bounding_box`, counts
an end as touching when it is within tolerance of the box or inside it,
and when several foreign boxes hold the end picks the nearest insertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 09:53:24 +03:00
Umut CANandClaude Opus 5 8b6f355c3d fix(task-14): polyline bulges leave the backends in WCS, so a mirrored arc reads on its true side
The per-vertex `bulges` list was handed out in the entity's own frame next
to `points` that had been translated to WCS. A mirrored LWPOLYLINE
(extrusion / Normal -Z, what `entity_mirror` produces) is a reflection,
which reverses every arc's sense, so the reader tested endpoints against
the arc's mirror image: a line ending on the true apex dangled, a line
ending on empty paper made a junction, and `length` (frame-invariant)
said nothing. Both engines had it — ezdxf emitted `get_points("xyb")`
raw, COM returned `GetBulge` raw after reflecting `Coordinates`.

`ocs.wcs_bulge(extrusion, bulge)` now negates the sign for a left-handed
frame (the same rule `wcs_arc_angles` applies to an ARC's endpoints) and
both `_entity_info` paths route bulges through it whenever they translate
the points. Fenced by an end-to-end mirrored-pipe graph test, the COM
`_entity_info` fake with Normal -Z, and a boundary-level OCS test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 00:44:34 +03:00
Umut CANandClaude Opus 5 8fcd587b5b fix(task-14): foreign-line membership before junctions, and edges measure the arc the drawing holds
Two reader defects, both about trusting a derived number over the drawing.

Stale junctions: endpoints were resolved before a foreign line's membership
was decided, so a P&ID line ending on a foreign line that was then discarded
(it touched no node) kept `{"junction": "J1"}` while `junctions` was empty
and `stats.dangling` was one short. Node attachment now runs first for every
edge, the foreign lines that attach are admitted, and only then are junctions
and dangling ends resolved over the edges actually in the graph.

Chord length: `length` was a chord walk over the vertices, 12.5% short on a
semicircular crossing jump with no field saying so, and junction detection
tested the chord too, so a line ending on the arc dangled. Both engines now
report per-vertex `bulges` next to `points` (COM asks GetBulge only when
`Length` exceeds the chord walk, since an arc is always longer than its
chord); the reader takes the engine's bulge-aware `length` and tests
endpoints against the arc. The chord sum survives only for an engine that
reports no length, and is then labelled `length_approximate`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 00:30:21 +03:00
Umut CANandClaude Opus 5 e246b5e86a feat(pid): pid_graph reads the drawing back with ports, junctions, dangling ends and confidence
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 00:13:42 +03:00
Umut CANandClaude Opus 5 e924b5db99 fix(task-13): mirrored-symbol ports and COM-visible existing lines
Two review findings on pid_line_draw, both root-caused:

1. existing_pid_lines() filtered entity_list(type_filter="LWPOLYLINE") and
   tested info.type == "LWPOLYLINE". A live LWPOLYLINE's ActiveX ObjectName
   is AcDbPolyline (the repo's own 2026-08-06 measurement), which the COM
   engine reports as POLYLINE, so on live AutoCAD every auto-built number was
   seq 1, crossings always 0 and port_reuse always []. The reader now lists
   without a type filter and accepts {LWPOLYLINE, POLYLINE, 2DPOLYLINE} plus
   LINE — the same set the Task 14 graph reader keeps. The COM _entity_info
   polyline branch matched only the DXF-flavoured "AcDbLWPolyline", so a live
   LWPOLYLINE never got `points` either; it now accepts both spellings (with
   the OCS normalisation), which is what makes the crossing count real there.

2. resolve_endpoint() applied only x_scale, so a mirrored INSERT
   (entity_mirror writes y_scale = -1) resolved its port to a point off the
   symbol and the line was drawn through the pump body. transform_port takes
   an optional y_scale (default: uniform, signature unchanged) and applies the
   full reflect-then-rotate transform to point, direction and radius; a
   stretched symbol (|x_scale| != |y_scale|) has no circle for a radial port
   and is refused with a clear ValueError instead of a silent wrong number.

Tests: mirror about both axes resolves onto the real geometry, a stretched
copy is refused, and a fake-COM AcDbPolyline probe sees the existing line
with its seq, vertices and from-ref.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 23:48:35 +03:00
Umut CANandClaude Opus 5 e8f1a666f8 feat(pid): pid_line_draw — port-to-port orthogonal lines with class, number, markers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 23:26:09 +03:00
Umut CANandClaude Opus 5 4b6abf7b92 feat(pid): pid_symbol_list and pid_symbol_insert — blocks with tags, ports and XDATA
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:44:06 +03:00
Umut CANandClaude Opus 5 8a3b8735b9 fix(task-10): nozzle stubs meet the drawn outline, impossible capsules and nozzle typos refused
Nozzle stubs started at the bounding-box edge, so on a dished head, a
hopper's sloped wall or a cone roof they floated off the vessel (default
horizontal_vessel N3/N4 by 0.8 mm, reactor N3/N4 by 0.06 mm, unbounded for
user fractions). The port now stays STUB outside the bbox and the stub is
drawn from there in to where its axis-aligned line meets the outline, via a
new geometry.scan_hits helper; jacketed reactors' stubs pass through the
jacket to the wall.

validate_vessel_params refuses height <= width for vertical capsules
(vertical_vessel, column, reactor), width <= height for horizontal_vessel
and height <= width/6 for a cone-roof tank: those folded the heads through
each other and produced geometry outside the declared bbox. Unknown keys
inside a nozzle dict are refused naming nozzles[i] and the key, like the
top-level parameters already were.

Tests: a fuzz over every accepted vessel shape x 28 nozzle positions checks
the declared bbox and that each stub's inner end lies on the outline (exact
point-to-arc/segment distance); the default-symbol repros and the refusals
are pinned. 137 of the new cases fail against the previous module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:26:00 +03:00
Umut CANandClaude Opus 5 661258004f feat(pid): six parametric vessel symbols with nozzle ports
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:12:15 +03:00
Umut CANandClaude Opus 5 240d9b3d9d feat(pid): rotating, heat-transfer and inline equipment symbols
Task 9 of the v1.6 P&ID plan: engineering/pid/symbols_equipment.py fills
the stub with three ISO 10628-2 families registered on import — rotating
(centrifugal_pump, pd_pump, compressor, fan, agitator, ejector), heat
(shell_tube_hx, plate_hx, air_cooler, fired_heater) and misc inline
fittings (filter, strainer_y, reducer with shape=concentric|eccentric,
flange_pair, spectacle_blind, orifice_plate with a signal port,
drain_vent). Every port sits on the declared bbox edge and every symbol
carries the TAG/DESC attribute pair; the catalogue harness now covers
103 specs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 16:02:54 +03:00
Umut CANandClaude Opus 5 352ce21030 fix(task-8): actuators stack on the body's real top and every stem meets its glyph
Two authoring slips in the valve x actuator composition:

- The diaphragm actuator's stem stopped at y=4 while the dome's flat base
  sat at y=6, so the canonical control-valve symbol (PID_VALVE_*_DIAPHRAGM,
  9 catalogue entries) shipped with its dome floating 2 mm above the stem.
- Actuators were placed at a fixed y=4 regardless of the body, so on the
  needle body (top 5.6) the piston/motor/solenoid glyphs were drawn over
  the knob and the hand bar across the needle's stem; the stem line(0,0,0,4)
  also duplicated the needle's own axial line (and the butterfly disc).

Placement is now body-relative: `_body` reports `axis_top` (where its own
geometry on x=0 ends) and the stem runs from there to the glyph base at
`body_top + STEM_CLEARANCE`; the diaphragm dome's base is the stem top
(base + DIAPHRAGM_YOKE), and the FAIL attribute follows the glyph base.
The reference 8x4 bodies keep their published numbers (globe+diaphragm
signal at y=9.0, bbox top 9.0).

Tests: per-combination check that the stem starts at the body's derived
axis top, the glyph's lowest edge equals the stem top, the glyph clears
the body, and the signal port is the topmost edge; plus a catalogue-wide
no-duplicate-primitives gate. 33 combinations fail on the old geometry.
The plan's Step 3 snippet is synced with the fixed module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:51:20 +03:00
Umut CANandClaude Opus 5 41e60bd4d0 feat(pid): 11 ISO 10628-2 valve bodies composed with 6 ISA-5.1 actuators
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:37:46 +03:00
Umut CANandClaude Opus 5 cb23f31700 fix(task-7): instrument bubble outline per ISA-5.1 Table 5.4.1, location lines clipped to the outline
build_instrument drew a circle for every type, so `computer` shipped as
circle + hexagon and `plc` as circle + square + diamond (10 of the 20 bubble
blocks wrong, and the circle poked 0.67 mm outside the hexagon's flat top).
Spec 4.4: discrete = circle; dcs = circle inside a square; computer =
hexagon; plc = diamond inside a square. The outline is now chosen per type.

Location lines were always drawn from -r to +r, overshooting the hexagon's
slanted sides by 0.46 mm at y=+/-0.8 and the circle's chord by 0.06 mm.
They now end on the outline (`_bubble_half_width`): chord for the circle,
full width for the square, `r - |y| / tan 60` for the hexagon.

Tests now assert the exact primitive list per type and that every location
line stays on/inside its outline (both fail against the previous code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:27:23 +03:00
Umut CANandClaude Opus 5 6cccadee84 feat(pid): symbol catalogue framework — instruments, markers, connectors
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:14:55 +03:00
Umut CANandClaude Opus 5 a0b16f399b feat(pid): ACADMCP_PID payload encoding with 255-char chunking
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 15:04:01 +03:00
Umut CANandClaude Opus 5 f4c2f27bee fix(task-4): refuse non-finite/non-numeric XDATA and budget 16 KB per entity
encode_values() let strings and nan/inf through float() into 1010 point
coordinates and never finiteness-checked bare 1040 reals, so
entity_set_xdata(handle, app, [["nan", 1]]) or [float("inf")] wrote a
literal "nan"/"inf" into the DXF (and every undo snapshot) and read back
as null. Numbers now go through the same rule block_specs._scalar applies:
int/float only, bool refused, math.isfinite, error naming values[i][j].

The advertised 16 KB limit was per call per app; AutoCAD's is per object
across every application (xdroom/xdsize), so three 15 KB apps on one
entity passed headless and would fail SetXData live. check_entity_budget()
now sums the entity's existing XDATA under every other app (ACAD's DSTYLE
overrides included) plus each 1001 marker with AutoCAD's resbuf sizes,
and both engines run it before registering the APPID or writing; removal
is never refused. The result's "bytes" is that same accounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 14:49:43 +03:00
Umut CANandClaude Opus 5 41d6d3505a fix(task-4): refuse LF/CR in XDATA strings before any write
encode_values() accepted strings containing "\n" or "\r". ezdxf writes
1000-tag values verbatim, so the separator split the tag pair: the saved
DXF failed to reopen (DXFStructureError) and, because every undo and
transaction snapshot is also a DXF save, transaction_rollback broke after
a single such write. Both engines route through encode_values, so the
refusal (ValueError naming values[i]) now happens there, before either
backend touches the document. Only LF and CR split a DXF tag; tabs and
the other Unicode separators round-trip and stay allowed.

Tests: pure encoder refusal for \n, \r, \r\n; other control characters
still typed; ezdxf refuses-then-rollback-and-reopen round trip; COM fake
sees no SetXData / RegisteredApplications call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 14:33:00 +03:00
Umut CANandClaude Opus 5 cda8b69dba feat(entity): entity_get_xdata / entity_set_xdata on both engines
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 14:16:12 +03:00
Umut CANandClaude Opus 5 a84cae2641 feat(blocks): block_define — typed primitives + ATTDEFs on both engines
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:58:09 +03:00
Umut CANandClaude Opus 5 94840042a5 merge: track-a task 12
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:57 +03:00
Umut CANandClaude Opus 5 18f8daef5f merge: track-a task 6
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:53 +03:00
Umut CANandClaude Opus 5 75610d5fd4 merge: track-a task 2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:49 +03:00
Umut CANandClaude Opus 5 2ab3173628 merge: track-a task 1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:44:45 +03:00
Umut CANandClaude Opus 5 0a0be740bd fix(task-2): refuse multi-line / trailing-caret TEXT and ATTDEF strings before write
`text`, ATTDEF `prompt` and `default` were only isinstance(str)-checked, so
'A\nB' and '10^' passed validation and ezdxf's DXFAttr fixer
(`fix_one_line_text`) rewrote them on `add_text` / `add_attdef` at DEBUG
log level — the declared ATTRIB value and the one the P&ID reader gets back
differed silently, and COM (raw string) diverged from ezdxf. Same hole the
tag check closed in 4158575, left open on its three siblings.

A `_one_line(value, where)` helper now refuses '\n', '\r', any other C0
control, and a trailing '^' (ezdxf's own `is_valid_one_line_text` rule),
naming `entities[i]: 'text'` / `attdefs[i]: 'prompt'|'default'`. Tests cover
the refusals, the strings ezdxf keeps verbatim ('10^2', 'Ø20 H7', '%%C20'),
and an ezdxf round-trip proving accepted values are stored unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:34:47 +03:00
Umut CANandClaude Opus 5 2833e1bc93 fix(task-12): refuse non-finite points, non-axis ports and unfillable number formats
Three ways `engineering/pid/lines.py` could emit a silent wrong result or
an opaque crash instead of an honest refusal:

- `format_line_number` only recognised a token that was exactly `{field}`;
  `P-{seq:04d}`, `{size}"-{service}` or `{bogus}x` were copied verbatim,
  braces and all, into the line number. Tokens are now parsed with
  `string.Formatter`: one named known field per token, a format spec is
  honoured (`{seq:04d}` -> `0007`), literal decoration around the field
  survives with it and drops out with it, and conversions, positional
  fields, multi-field tokens, unbalanced braces and specs the value cannot
  take are all refused by token name.
- `route` never checked finiteness: a NaN `end` collapsed the U candidate
  to a valid two-point "straight" path ending at the stub point. Every
  coordinate of start/end/waypoints and the stub are now checked and the
  refusal names the offending `end.x` / `waypoints[1].y` / stub.
- A non-axis port direction (45.0, 90.5, nan) raised `KeyError` from the
  AXIS lookup. `axis_direction()` now normalises with a 1e-6 deg tolerance
  for engine drift and otherwise raises ValueError naming `start_dir` /
  `end_dir` and how to resolve it (rotate to a multiple of 90 or pass
  waypoints).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:30:02 +03:00
Umut CANandClaude Opus 5 2344b3b9d4 fix(task-6): bind the invalid-tag fixture to the reported letter and position
Every grammar error is prefixed with the tag's own letters, so the old
single-letter fragments (FCI/"I", PSVX/"X", ...) matched whatever the
grammar said: a mutation that dropped the letter and position from every
error left 9 of 11 invalid rows green. The fixture now quotes the error
text the spec demands ("unexpected letter I at position 3", "... after
valve V", "trailing O (open/closed) ...", "needs at least one function
letter after F"), and the test pins it to the single first error.

ABCDEF-106 was mispinned as a grammar rejection of "D": six letters
exceed the regex cap, so it never reached the grammar. It is now pinned
as a format rejection, and ABCDE-106 carries the "letter D at position 4"
case.

A fixture lint rejects any fragment that is a substring of its own tag
and requires grammar rows to name a letter, position or trailing rule,
so a future row cannot reintroduce the hole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:27:59 +03:00
Umut CANandClaude Opus 5 41585757c9 fix(task-2): close the trailing-newline tag hole and gate DXF-illegal layer names
Two ways a malformed block_define request slipped past the pre-write
validator and reached the engine:

* `ATTDEF_TAG_RE.match` with a `$` anchor accepts 'TAG\n' because `$`
  matches before a trailing newline. ezdxf strips the newline on write, so
  'TAG\n' + 'TAG' landed as two ATTDEFs with one tag, defeating the
  duplicate-tag refusal, and the tag-keyed attribute readers then dropped
  one silently. Now `fullmatch`.

* `_layer` only required a non-empty string, so a DXF-illegal name such as
  'BAD<LAYER' passed and ezdxf raised DXFValueError mid-loop, after the
  block existed and earlier primitives were written (with overwrite=True,
  after the old definition was already wiped). The gate now refuses on the
  same forbidden-character rule `sanitize_symbol_name` uses, exposed as
  `security.illegal_symbol_name_chars`, with a TypeError naming
  `entities[i]` and 'layer'.

Tests cover both repros and fail against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:22:56 +03:00
Umut CANandClaude Opus 5 6f4bda5028 fix(task-1): refuse block_insert of an undefined block before writing
`add_blockref` never checks the name and `add_auto_attribs` skips autofill
when `Insert.block()` resolves to None, so a typo'd block name returned a
handle whose attribute values were silently dropped and whose dangling
INSERT `doc.audit()` later deletes. Check `name in self._doc.blocks` and
raise `ValueError` on both the attribute and no-attribute paths, matching
the COM engine's `InsertBlock` refusal for an unknown name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:21:16 +03:00
Umut CANandClaude Opus 5 b8de3968f9 feat(pid): ISA-5.1 identification-letter grammar with a pinned fixture
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:14:12 +03:00
Umut CANandClaude Opus 5 6dd25ff0c2 feat(pid): line classes, orthogonal routing, labels, markers, crossings
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:11:59 +03:00
Umut CANandClaude Opus 5 2cc4008fe8 feat(blocks): shared validation for block_define primitive and ATTDEF specs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:10:43 +03:00
Umut CANandClaude Opus 5 be7dfa86d3 fix(ezdxf): block_insert with attributes referenced an anonymous *U block and lost its ATTRIBs
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 13:10:41 +03:00
Umut CANandClaude Opus 5 40eb37cf6f docs: Track A (P&ID) implementation plan — 21 TDD tasks; spec reconciled
Plan covers the prerequisite block_insert attribute fix (ezdxf wrapped the
INSERT in an anonymous *U block and lost its ATTRIBs), the two generic
contracts, the 43-symbol catalogue, routing, the ISA-5.1 grammar, the
graph reader, deliverables, six critique focuses, pid_from_spec, TOOL_PACKS,
resources, benchmark v4, and the live COM smoke.

Spec reconciled with the plan: 43 base symbols, FUNC/LOOP at +-2.0,
solid = DXF SOLID entity, shape/direction options, example spec uses an
actuated FCV-101 (a hand valve has no signal port), refine hints in
detail["hint"], preflight LAYER_SET_INTENT_MISMATCH, radial port matching
on the circle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 11:00:21 +03:00
Umut CANandClaude Opus 5 3430573de1 docs: v1.6 roadmap decomposition and the Track A (P&ID) design spec
Research snapshot of the AutoCAD MCP field (20 READMEs, 2026-09-14), the
seven 1.6 tracks with measurable acceptance criteria, their order
(A -> E -> B -> G -> F -> H -> D), and the tool-pack decision.

Track A: symbol catalogue authored in code (ISO 10628-2 + ISA-5.1), two
generic contracts (block_define, entity_get/set_xdata), nine pid_* tools,
routing, the ISA-5.1 tag grammar, the pid_graph reader, six critique
focuses, TOOL_PACKS, and the test/evidence plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-15 10:07:23 +03:00
Umut CANandClaude Opus 5 abc2a82e71 docs(readme): compact the page and drop the competitor comparisons
420 -> 288 lines. The source-review leaderboard, the live-run score table and
the task-matrix heatmap all ranked this project against other people's
servers; they are gone. What is left is what this repo can prove about
itself: the correctness A/B against its own previous tags, the five benchmark
tasks that can fail, the performance lane with its regression stated, and the
limits section.

Client wiring, the environment table and the showcase tool-call sequence move
into <details> so the page scans in one screen without losing them.

Star history now charts U-Pool alongside this repo.

Every gated number still matches the tree (16/16) and the release-consistency
suite is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:30:34 +03:00
Umut CANandClaude Opus 5 ca1c79fd01 fix: seven defects an adversarial audit found, and the gates that missed them
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 23:07:50 +03:00
Umut CANandClaude Opus 5 6b9860e68e docs: apply 27 corrections from the adversarial triage, and rework the perf lane
The triage over the 43 unaddressed verdicts returned 4 code and 35 doc items.
The code ones shipped in e24b46d; these are the documents.

Twenty-one applied mechanically after a validation pass confirmed each locator
still matched exactly once and each replacement was supported by the tree. Ten
were held back because a re-verify pass found them mutually exclusive -- three
patches rewriting the same benchmarks/README block, three rewriting the same
OCS paragraph -- and applying them in sequence would have corrupted the files.
Those were resolved by hand into one coherent edit each. One was rejected.

The corrections worth naming:

- **The A/B table contradicted itself in three files.** benchmarks/README.md
  and CLAUDE.md said 24 checks / 21-24 / 87.5% / "fixed 3" while the suite has
  26 and the published JSON says 21/26, 80.8%, five fixed. All three now agree,
  and the five are split into the three new-capability `miss -> pass` and the
  two repaired-defect `fail -> pass`, which is the distinction the lane exists
  to make.
- **"Every number below is produced by a script"** was false twice over:
  section 5's nine scores are literals a human assigned, and section 4 now
  quotes hand-collected timings. The claim is narrowed to the charts, which
  really are all rendered from committed data, and both exceptions are named.
- **The OCS paragraph over-quantified.** TEXT rotation was given as the single
  exception; z components and HATCH boundaries are also entity-frame, and the
  live backend records `2d_polyline_coordinates_not_verified_against_live_autocad`
  in the same reason string. It now points at `ocs_normalized` as the live list.
- **Transactions were shown as bare parity.** The server itself grades them
  differently -- `FeatureCapability(True, "undo_mark")` on COM against
  `(True, "snapshot")` headless -- and COM rollback sends `_UNDO B` without
  confirming it landed. The row says so.
- **"Raw commands: Opt-in"** named a switch that does not exist. Both tools are
  registered unconditionally; `DANGEROUS_COMMANDS_ENABLED` only disables the
  sanitizer.
- **"Wave A shipped 13 of a planned 39"** hardened an approximation: the only
  source writes `~39`.
- **CAD_PROGID** said nothing beyond the connection was tested against
  BricsCAD/ZWCAD/GstarCAD, implying the connection was. It was not.

Perf lane reworked against a fresh paired measurement. The old text claimed
"28% slower than v1.4.0, median of three runs" and that
`EZDXF_CALL_TIMEOUT=0` restored v1.4.0's numbers "exactly". Three problems:
`perf_suite` runs each workload once per invocation and cannot produce a
median, "exactly" was refuted by the figures in its own sentence, and the
number moved when the timeout wrapper was rebuilt. It now reports the guard's
cost -- +20.8% and +19.9%, three paired runs, median taken by hand -- says the
figures have no published artifact, and says the wrapper rework recovered about
a third of the v1.5.0 overhead rather than closing it.

perf-ezdxf.json and the perf chart regenerated against the shipped code, and
the README table rebuilt from that artifact so the two cannot drift.

1267 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:55:48 +03:00
Umut CANandClaude Opus 5 e24b46d6f0 fix: three measurement defects the adversarial triage surfaced
**COM discarded areas it had already read.** `entity_measure` fell back to
rebuilding geometry from `.Coordinates` when `.Area` *or* `.Length` was
missing, coupling two independent property reads. HATCH, REGION and 3DSOLID
expose `.Area` and neither of the others, so the fallback fired, found no
vertices, and raised "AutoCAD reported neither an area nor usable vertices" --
while holding the area. Those are the exact types the README sells the COM
backend on. Now only a missing `.Area` triggers the fallback; an absent
perimeter is reported as `None` with `perimeter_exact: False` instead of being
invented, which also removes a `round(None, 6)` TypeError waiting for the first
type that answers `.Area` and nothing else.

**A plain square hatch reported itself self-intersecting.** `ezdxf.path
.flattening()` repeats the start point, and `is_self_intersecting` excluded
adjacent edges *by index*, so with the duplicate present the first and last
real edges were cross-tested and registered as crossing. Measured: a 40x40
SOLID hatch came back `area: 1600.0, self_intersecting: True`. A closed loop
now answers the same whether or not it carries its closing vertex, and a real
bowtie is still caught (`area: 0.0, self_intersecting: True`).

**The bowtie warning never fired on the live backend.** `self_intersecting` was
only computed inside the COM fallback, so an LWPOLYLINE bowtie -- which has
both `.Area` and `.Length` and never reaches it -- returned `area: 0.0,
self_intersecting: null`. The confident zero this field exists to flag, going
out unflagged. It is now computed on the normal path too.

Also corrects a docstring written earlier today in `contracts/analysis.py`
claiming the live backend "asks AutoCAD for `.Area`, which has already applied
the [hatch island] style". Whether ActiveX's hatch area applies the style is
not verified against a live seat, and the method could not measure a hatch at
all. It now says so.

The ActiveX member profiles behind the COM tests are assumptions, not facts,
and the tests say so; a live seat settles them. `.Circumference` for CIRCLE and
`.Perimeter` for REGION are deliberately NOT added here -- writing more members
from the reference is what produced four wrong beliefs in v1.4/v1.5.

1267 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:08:50 +03:00
Umut CANandClaude Opus 5 25f8737132 release: v1.5.1 — bind guard failed open, [full] would not install, DIMSCALE did nothing
Three defects found after 1.5.0 reached PyPI, two of them by the adversarial
pass over the rewritten README.

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 fastmcp otherwise 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, no token -- while the guard inspected the literal "127.0.0.1" and
waved it through. v1.4.0's changelog claimed this guard "fires on every launch
path"; it fired, and looked at the wrong host. It now resolves the effective
host, and warns rather than staying silent if it cannot.

`[com]` and `[full]` could not be installed off Windows. Both 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 the 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 carry
`; sys_platform == "win32"` now, with a release-consistency test to keep them.

`drawing_settings({"dimscale": ...})` reported success and changed nothing
headlessly. It writes `$DIMSCALE`, which is what AutoCAD reads when it draws a
dimension, so the call worked on COM; ezdxf renders from the dimstyle table
entry and never consults the header. One call, one reported success, two
different drawings. The header value now folds 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 the
obvious fix would have missed. DIMSCALE 1.0 writes nothing.

README: every engine-conditional family in the feature table now says which
engine (boundary tracing, WIPEOUT/REVCLOUD/MTEXT masks, CHSPACE, typed hatch
edge paths, headless undo), and the checkout install line stopped recommending
an extra that could not resolve on the reader's platform.

1257 passed, 1 xfailed. 1.5.0 is to be yanked: its diameter and radius callouts
put wrong numbers on drawings at default settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:28:35 +03:00
Umut CANandClaude Opus 5 776fe5bcf1 chore: drop three scratch files a verification agent left behind
`_c131_cpu.py`, `_c131_micro.py` and `_c131_probe.py` were throwaway probes
written by a subagent while checking a README claim, and a7c8a7a swept them
in with `git add -A`. They are not part of the project and nothing imports
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:03:48 +03:00
Umut CANandClaude Opus 5 a7c8a7a0b8 docs(readme): stop upscaling the hero sheet 17%
`view_screenshot` renders at 100 DPI with a tight bounding box, so the
showcase PNG is intrinsically 698 x 500. The README displayed it at
width="820" -- a ~17% browser upscale of a raster, on a page whose subject
is drafting precision, and next to four charts that are resolution-
independent SVG. Native width instead; sharp beats big.

The rest of the hero caption was re-audited against the DXF it describes
and every item holds: 7 engineering layers, involute gear outline, DIN 6885
keyed bore, hatched section A-A, and two ISO 129 dimensions now measuring
144.000 and 40.000 with the H7 callout -- the two that were wrong before the
diameter fix in 8f405bd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:03:26 +03:00
Umut CANandClaude Opus 5 8f405bddf3 fix(dimensions): diameter and radius callouts measured the leader as geometry
`add_diameter_dim` / `add_radius_dim` measure to the point they are handed;
`leader_length` is a text placement. The ezdxf backend passed
`centre + (radius + leader)` as that point, so the leader became part of the
measurement: a true 40 mm bore was dimensioned 60 at 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, at
default settings, on the one part of a drawing that exists to carry numbers --
and it shipped in v1.4.0 and earlier. The size now 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. The two engines
had been disagreeing by 2 x leader_length on the same call.

Found while building the drawing for the new README, whose own bore callout was
wrong. Two checks added to correctness_suite.py; both report fail -> pass
against v1.4.0, not miss -> pass, which is what separates a repaired defect
from new capability in that lane. A/B is now 21/26 -> 26/26, five fixed, zero
regressed.

README rewritten from scratch
- Hero is a real ISO A3 sheet the server drew: m6 z24 involute spur gear, DIN
  6885 keyed bore, section A-A, ISO 129 dimensions, ISO 286 H7 fit, ISO 7200
  title block, 56 entities, drawing_critique clean. New
  scripts/render_readme_showcase.py rebuilds it, and the tool-call sequence
  that produces it is printed in the page.
- Star history chart, badge row, capability matrix, five evidence lanes, and a
  "what this release is bad at" section carrying the 28% throughput regression.
- Every image and repo link is now an absolute URL: README.md is the PyPI long
  description, where relative paths 404.

Corrections this work forced, all measured
- The df=0 example was wrong. FILLET is already in entity_fillet's own text;
  the commands that genuinely scored zero are WBLOCK, PEDIT, OVERKILL,
  LAYTRANS and MATCHPROP.
- 40,305 tokens is the tools/list wire payload; ~25,143 is the part forwarded
  to the model. The old wording said "of schema", which is neither.
- `--tokenizer anthropic` needs a package and credentials this repo does not
  lock, and refuses rather than falling back. Said so.
- Discovery ranks over the 149-tool advertised catalog, not the 154 registered.
- Five middlewares, not four; the fifth is what makes refusals machine-readable.
- Five of the 27 capability keys are environment-dependent, so the matrix
  carries footnotes instead of bare ticks.
- "Every coordinate is WCS" has one deliberate exception: TEXT rotation.
- render_live_chart's axis still said "fixed 10-task matrix" while our bar came
  from v3's fifteen. Each bar now carries its own matrix.
- .env.example said MAX_UNDO_STACK=50 (code: 5) and omitted four real
  variables, two of them the remote-HTTP controls.
- server.json described an empty ALLOWED_PATHS as blocking access. It disables
  the allowlist.
- server.py called the lean profile ~46 tools; it is 47.
- backends/capability.py cited a test file that does not exist.

1249 passed, 1 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:24:44 +03:00
Umut CANandClaude Opus 5 e27071ab48 fix(com): except pywintypes.com_error raised NameError without pywin32
Release / test (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Python evaluates an `except` clause on the way out of every failed call,
not only the ones it is meant to catch. `pywintypes` is bound only when
`sys.platform == "win32"` and the import succeeded, so on any host
without pywin32 -- all of Linux, and Windows installed without the
`[com]` extra -- an ordinary exception leaving `ComBackend._run` hit that
clause and became `NameError: name 'pywintypes' is not defined`, burying
the real error.

Every Linux CI leg failed on exactly this, in
test_ordinary_errors_are_unchanged. The timeout tests survived only
because they matched an earlier `except`; nothing else in the suite sends
a non-timeout exception through `_run`.

Fixed with a module-level `_COM_ERROR` tuple: `(pywintypes.com_error,)`
where the import worked, `()` where it did not. An empty tuple catches
nothing, which is correct when no com_error type exists to be raised -- a
placeholder Exception subclass would be worse than the bug, silently
swallowing unrelated errors.

Reproduced on Windows before fixing by deleting the module attribute,
which resolves the name the same way the absent import does; that is now
a test, alongside one pinning `_COM_ERROR` as a tuple. Verified through
the locked environment CI uses: 1244 passed, 1 xfailed.

README test-count snapshot 1243 -> 1245.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:22:49 +03:00
Umut CANandClaude Opus 5 4e0f8a90fd fix(ci): uv.lock recorded 1.4.0, so every locked lane died before testing
The v1.5.0 lock was generated before the version bump, so its own
[[package]] entry said autocad-mcp-pro 1.4.0 while pyproject said 1.5.0.
No dependency differed -- the transitive graph is byte-identical -- but
`uv sync --locked` will not re-resolve, so lint, test-linux 3.11,
test-linux 3.12 and test-windows all failed at install before running a
single test.

`uv lock` fixes it in one line. The gate that should have caught it did
not exist: test_release_consistency chains pyproject -> version.py ->
CHANGELOG -> README -> server.json, and the lockfile was the one release
artifact carrying a version string nothing checked. Added
test_uv_lock_records_the_pyproject_version, verified RED against the
broken lock and green against the fixed one.

Reproduced the CI lanes locally into a throwaway environment before
pushing this time: `uv sync --locked --only-group dev`, then
`--locked --extra pdf`, then ruff and pytest under `uv run --no-sync`.
1242 passed, 1 xfailed.

README test-count snapshot 1242 -> 1243 for the new gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:03:29 +03:00
Umut CANandClaude Opus 5 b03a7615b1 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>
2026-08-06 09:45:20 +03:00
Umutcan EdizsalanandCursor d5a0ff5109 style: ruff-format the leaderboard renderer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:17:08 +03:00
Umutcan EdizsalanandCursor bae0750d4c docs(bench): dark leaderboard styling for all benchmark charts
Leaderboard restyled to the LLM-benchmark look: dark background, rank-numbered labels, per-rank color palette, 'NN / 100 - grade' annotations. The live-run, task-matrix and performance charts share the same dark theme for a consistent README identity. Charts regenerated deterministically from their data files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:14:04 +03:00
Umutcan EdizsalanandCursor af4813e7b6 docs(bench): nine-server public AutoCAD MCP leaderboard as the README hero visual
source_review.json extended with three projects reviewed 2026-07-23 (LokmenoWer/best-cad-mcp B/74, daobataotie/CAD-MCP B/35, zh19980811/Easy-MCP-AutoCad C/30) and our entry updated to the released v1.4.0 (95: CI matrix, PyPI packaging, A/B gate, paper space, ISO 286, opt-in solids). Chart regenerated via render_chart; README benchmark section now opens with the leaderboard + 9-row table, source-review lane merged into it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:08:01 +03:00
Umutcan EdizsalanandCursor 47a90dbc54 docs(bench): add the v1.3.0 -> v1.4.0 correctness A/B gate to the README benchmark lanes
Ran benchmarks/compare_versions.py against the v1.3.0 tag: 21/21 on both versions, 0 fixed / 0 regressed - the v1.4.0 feature+infra work introduced no correctness regressions. Main README gains lane 4 (version A/B, with the historical v1.0.0 -> v1.1.0 contrast); benchmarks/README records the release-gate result and drops the stale 'performance coming' stub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:00:58 +03:00
Umutcan EdizsalanandCursor d3e5fe4542 chore: drop stale uv.lock from the repo and gitignore it (regenerate deliberately if adopting uv)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:53:40 +03:00
Umutcan EdizsalanandCursor 21cbc62df5 feat(bench): performance lane + task-matrix chart; docs: README rebuilt around four evidence lanes
- benchmarks/perf_suite.py: fixed headless workloads (2k creates, 10k build/export/reopen, 10k region query, premium quality pass) timed through the same backend methods the tools call; machine fingerprint recorded; self-measurement only
- benchmarks/render_perf_chart.py -> docs/assets/autocad-mcp-perf.svg (log-scale wall time)
- benchmarks/render_matrix_chart.py -> docs/assets/autocad-mcp-taskmatrix.svg (tasks x servers status heatmap from published reports)
- render_live_chart now tolerates other lanes sharing results/published/
- README: PyPI-first install, four-lane benchmark section with Python-generated visuals, v1.4 feature table, updated client/config/roadmap
- published perf report: benchmarks/results/published/perf-ezdxf.json

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:53:04 +03:00
Umutcan EdizsalanandCursor 67f2c3b575 ci(release): publish to PyPI with the PYPI_API_TOKEN secret instead of OIDC trusted publishing
Release / test (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:37:17 +03:00
Umutcan EdizsalanandCursor d15068efc5 fix(tests): inject win32gui mock with raising=False so the hwnd tests run on the Linux CI leg
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:22:49 +03:00
Umutcan EdizsalanandCursor e04c814cb8 docs(release): v1.4.0 - changelog, README live-benchmark section, CLAUDE.md sync, consistency gates
- CHANGELOG 1.4.0 section (infra + features), Unreleased now lists the 1.5 openers
- README: v1.4 snapshot (131 tools / 6 resources / 5 prompts / 474 tests), CI + PyPI badges, live-run benchmark table + chart, mcp-name registry marker
- CLAUDE.md: sections 15 (layouts) + 16 (solids), tool profiles, ISO 286 fits; counts synced to the live surface
- tests/test_release_consistency.py: locks pyproject/version.py/CHANGELOG/README/server.json versions, README counts vs registrations, section-header counts, Dockerfile COPY vs wheel includes, mcp-name marker

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:16:32 +03:00
Umutcan EdizsalanandCursor 3cbe30df54 feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids
- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false
- layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it
- ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal
- tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved
- solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary
- capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d
- 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:45 +03:00
Umutcan EdizsalanandCursor 6d7c39e012 feat(bench): live competitor lane - black-box MCP stdio adapters, pinned checkouts, published results
- adapters/mcp_stdio.py: drives competitors over MCP stdio like a real host; payload-level error detection, fastmcp output-wrapper normalization, cold-start warmup, artifact-based verification (harness re-opens every exported DXF with ezdxf)
- competitors_env.py: reproducible pinned-SHA checkout + isolated venv per competitor
- adapters for puran-water/autocad-mcp (95476a3) and beiming183-cloud/AutoCAD-MCP (11f7c47) mapped to their own documented tool contracts; unmapped tasks report 'unsupported' with reasons
- run_competitors.py: registry-driven --server from competitors.yaml
- published reports (pro 100.0 / beiming 50.0 / puran 45.0) + render_live_chart.py -> docs/assets/autocad-mcp-livebench.svg
- benchmark.yml: weekly + manual dispatch matrix

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:27 +03:00
Umutcan EdizsalanandCursor 2f56a73093 feat(release): CI matrix, PyPI trusted-publishing pipeline, registry manifest, Docker fix
- ci.yml: ruff lint/format, Linux 3.11/3.12 tests + coverage, Windows mocked-COM leg, package build + clean-venv wheel smoke, Docker smoke, server.json schema validation
- release.yml: tag -> test gate -> tag/version check -> build -> PyPI trusted publishing (OIDC) -> GitHub Release
- pyproject: SPDX license, authors, keywords, classifiers, project URLs, autocad-mcp-pro console alias; version 1.4.0
- Dockerfile: ship engineering/ + version.py (image previously missed both -> engineering tools failed in containers); healthcheck imports engineering
- server.json: MCP registry manifest (io.github.u-c4n/autocad-mcp, pypi package)
- docs/RELEASE-DISTRIBUTION.md: one-time owner actions (PyPI trusted publisher, mcp-publisher login) + directory submissions runbook

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:15:07 +03:00
Umutcan EdizsalanandCursor 9ec6c4a989 style: apply ruff format across the repository
ruff format was never run repo-wide; CI now enforces 'ruff format --check', so normalize every module once. No behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 15:14:51 +03:00
422 changed files with 249416 additions and 4395 deletions
+81 -2
View File
@@ -1,15 +1,45 @@
# AutoCAD MCP Pro — Environment Variables
# Rendering (view_screenshot / drawing_export_pdf) is an install-time choice,
# not an env var — the core install cannot render at all. ezdxf's drawing
# add-on imports Pillow unconditionally, so every render path needs it:
# pip install "autocad-mcp-pro[pdf]" ezdxf backend, matplotlib renderer
# (also pulls Pillow in transitively)
# pip install "autocad-mcp-pro[com]" live AutoCAD Win32 window capture
# pip install "autocad-mcp-pro[full]" both
# With only [com] the ezdxf backend still cannot render (no matplotlib).
# Backend selection: auto | com | ezdxf
AUTOCAD_MCP_BACKEND=auto
# COM ProgID the live backend attaches to. Escape hatch for AutoCAD-compatible
# applications that expose the same ActiveX object model.
#
# CAD_PROGID only changes which COM application the backend attaches to — every
# tool beyond the connection itself is developed and tested against AutoCAD
# alone, and AutoCAD-clone compatibility is unverified, so treat a non-default
# ProgID as experimental. An unrecognised ProgID fails loudly rather than
# falling back to AutoCAD, because the fallback would launch the very
# application you said you were not using.
#
# Candidates below are UNVERIFIED — none was tested against a running seat.
# List what is actually registered on your machine with:
# Get-ChildItem Registry::HKEY_CLASSES_ROOT |
# Where-Object { $_.PSChildName -like '*.Application*' } |
# Select-Object -Expand PSChildName
# CAD_PROGID=BricscadApp.AcadApplication # UNVERIFIED
# CAD_PROGID=ZWCAD.Application # UNVERIFIED
# CAD_PROGID=GstarCAD.Application # UNVERIFIED
CAD_PROGID=AutoCAD.Application
# Comma-separated list of allowed directories for file operations.
# Leave empty to allow all paths (not recommended for production).
# Example: ALLOWED_PATHS=C:\Drawings,D:\Projects\CAD
ALLOWED_PATHS=
# Maximum undo stack size (number of snapshots to keep)
MAX_UNDO_STACK=50
# Maximum undo stack size (number of snapshots to keep). The code's default is
# 5; this file used to say 50, which was a documentation-only disagreement.
MAX_UNDO_STACK=5
# Logging level: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO
@@ -17,3 +47,52 @@ LOG_LEVEL=INFO
# Enable dangerous AutoCAD commands (ERASE ALL, QUIT, etc.)
# Set to true only if you trust all MCP clients
DANGEROUS_COMMANDS_ENABLED=false
# Exposed tool profile: full (default) | lean
# lean is a curated ~47-tool drafting/inspection set for clients with tight
# tool caps. The "core" profile was removed in v1.5.0 — the discovery layer
# below solves the crowded-surface problem it existed for. TOOL_PROFILE=core
# still boots, falling back to full with a warning.
TOOL_PROFILE=full
# Tool packs: all (default) | comma list of core,pid,settings — advertise only the
# vertical packs this client uses. core is always on.
TOOL_PACKS=all
# Tool discovery: off (default) | search
# "search" replaces the advertised catalog with search_tools + call_tool, so a
# client sees two tools instead of ~126 and searches by AutoCAD command name
# ("QSELECT") or plain English ("round off this corner").
DISCOVERY_MODE=off
# Per-call backend timeouts in seconds; 0 disables the check.
# A hung call would otherwise hold the backend lock forever.
COM_CALL_TIMEOUT=60
EZDXF_CALL_TIMEOUT=120
# Undo/redo history for the headless backend, in steps. 0 disables it.
# Not free: ezdxf has no journal, so each step is a full DXF snapshot.
# Measured 37x slower entity creation with it on (0.18 -> 6.65 ms/call),
# and a snapshot of a 5000-entity drawing costs 130 ms / 658 KB. For a
# single checkpoint use transaction_begin/rollback instead.
# The live COM backend uses AutoCAD's own undo and ignores this.
EZDXF_UNDO_DEPTH=0
# Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported)
ENABLE_3D=false
# --- Limits and remote access -------------------------------------------------
# These four are read by config.py but were missing from this file, which made
# it look like the complete list. Two of them are the remote-HTTP controls.
# Reject DXF input larger than this many bytes (512 MB). 0 disables the check.
MAX_DXF_BYTES=536870912
# 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=
+4
View File
@@ -1,2 +1,6 @@
# Auto detect text files and perform LF normalization
* text=auto
templates/*.dxf text eol=crlf
templates/*.dwt binary
*.dwt binary
*.dwg binary
+53
View File
@@ -0,0 +1,53 @@
name: Benchmark
# Headless competitor benchmark (v2 fixed-task matrix). Manual or weekly:
# clones the pinned competitor SHAs, runs the black-box MCP stdio lane and
# uploads the machine-readable reports as artifacts. Results committed to
# benchmarks/results/ are produced from these runs.
on:
workflow_dispatch:
schedule:
- cron: "17 5 * * 1"
env:
# Keep in step with ci.yml; all three workflows read the same uv.lock.
LOCKED_UV_VERSION: "0.12.1"
jobs:
headless-matrix:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
server:
- autocad-mcp-pro
- puran-water-autocad-mcp
- beiming183-autocad-mcp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.12"
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked harness environment
# This lane publishes evidence, so the harness must not float: an ezdxf
# release would otherwise move our own numbers between runs and the
# diff would be unattributable. Competitors keep their own pinned
# checkouts and venvs (benchmarks/competitors_env.py) either way.
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
- name: Run benchmark (ezdxf lane)
run: |
uv run --no-sync python -m benchmarks.run_competitors \
--server "${{ matrix.server }}" \
--backend ezdxf \
--artifact-dir "benchmarks/results/ci/${{ matrix.server }}" \
> "report-${{ matrix.server }}.json"
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: benchmark-${{ matrix.server }}
path: |
report-${{ matrix.server }}.json
benchmarks/results/ci/${{ matrix.server }}
+140
View File
@@ -0,0 +1,140 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Two deliberately different installation paths run in this file:
#
# lint / test-* install from uv.lock with `--locked`. Every version, down to
# the transitive graph, is the one the suite was last run
# against; `--locked` *fails* rather than re-resolving if
# uv.lock has drifted from pyproject.toml. A release upstream
# can no longer turn main red on its own schedule.
# package installs the built wheel with plain pip, from the dependency
# ranges in pyproject.toml — on purpose. That is the path a
# user gets from PyPI, and it is the only job that exercises
# it, so it must not be locked.
#
# Locked CI is blind to upstream drift by construction; deps-floating.yml is the
# deliberate counterweight that resolves the ranges fresh on a schedule.
env:
# Pinned for the same reason ruff is: the tool that reads the lockfile is part
# of the reproducible environment. Bump this and re-run `uv lock` together.
LOCKED_UV_VERSION: "0.12.1"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
# Dev group only: ruff comes from the lock, the project is not built.
# The ruff version lives in [dependency-groups] dev in pyproject.toml and
# nowhere else — the formatter's output changes between minors, so a
# floating ruff fails `ruff format --check .` on unchanged code.
- run: uv sync --locked --only-group dev
- run: uv run --no-sync ruff check .
- run: uv run --no-sync ruff format --check .
test-linux:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: ${{ matrix.python-version }}
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked environment
# uv.lock is a universal resolution over requires-python >=3.11, so the
# same file serves both matrix legs; --python keeps uv on the
# interpreter setup-python just provisioned instead of picking its own.
run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}"
- name: Run tests
run: uv run --no-sync pytest --cov=. --cov-report=xml --cov-report=term-missing -v
- name: Upload coverage
if: matrix.python-version == '3.11'
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
test-windows:
# Windows leg exercises the mocked-COM suite on the platform the COM
# backend actually targets (pywin32 installs; no live AutoCAD needed).
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.11"
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked environment
run: uv sync --locked --extra full --python "${{ steps.python.outputs.python-path }}"
- name: Run tests
run: uv run --no-sync pytest -v
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build sdist and wheel
run: |
pip install build twine
python -m build
twine check dist/*
# Unlocked on purpose: this resolves the published dependency ranges the
# way a user's `pip install autocad-mcp-pro` does. Locking it would mean
# nothing in CI ever proves those ranges are installable.
- name: Install wheel in a clean venv and smoke test
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/autocad-mcp --help
/tmp/smoke/bin/python -c "import server, config, security, version, backends, engineering; print('wheel ok', version.__version__)"
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t autocad-mcp:ci .
- name: Smoke test image
run: docker run --rm autocad-mcp:ci python -c "import server, engineering.gear, version; print('docker ok', version.__version__)"
registry-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate server.json against MCP registry schema
if: ${{ hashFiles('server.json') != '' }}
run: |
pip install check-jsonschema
check-jsonschema --schemafile https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json server.json
+47
View File
@@ -0,0 +1,47 @@
name: Floating deps
# The counterweight to a locked CI.
#
# ci.yml installs from uv.lock, so a PR can never be broken by an upstream
# release. That is the point — and it is also a blind spot: a new fastmcp,
# ezdxf or pydantic inside the ranges declared in pyproject.toml would go
# unnoticed here until a user hit it, because the only unlocked job in CI
# (`package`) merely imports the wheel and never runs the suite.
#
# This job is the deliberate opposite: resolve the declared ranges fresh and
# run the full suite against whatever the index serves today. It runs on a
# schedule and on demand only, so it never gates a pull request — a red run
# here means "the ceiling needs a look", not "your change is broken".
#
# Lint is intentionally absent: ruff's formatter output moves between minors,
# so a floating ruff would fail on unchanged source and say nothing about the
# runtime dependencies this job exists to watch.
on:
schedule:
# Mondays, 06:17 UTC. Off the hour so it does not queue behind the
# every-cron-on-the-hour crowd.
- cron: "17 6 * * 1"
workflow_dispatch:
jobs:
floating:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install from the declared ranges (uv.lock deliberately ignored)
run: |
pip install -e ".[pdf]"
pip install pytest pytest-asyncio
- name: Record what actually resolved
# Printed unconditionally: when this job fails, the version list is the
# first thing needed to tell which upstream moved.
run: pip list
- run: pytest -q
+115
View File
@@ -0,0 +1,115 @@
name: Release
# Tag-driven release: run the test gate, build, publish to PyPI, then cut the
# GitHub Release with the built artifacts attached.
#
# Auth: the PYPI_API_TOKEN repository secret (an API token from
# https://pypi.org/manage/account/token/). Once the project exists on PyPI,
# prefer replacing it with a project-scoped token — or switch back to trusted
# publishing (OIDC) by restoring `environment: pypi` + `id-token: write` and
# registering the publisher on PyPI.
on:
push:
tags: ["v*"]
env:
# Keep in step with ci.yml; both read the same uv.lock.
LOCKED_UV_VERSION: "0.12.1"
jobs:
test:
# The same three lanes as ci.yml, not one. This job was a single
# ubuntu/3.11 run, so a Windows-only defect could reach PyPI without any
# Windows job having executed -- which is not hypothetical: v1.5.0 shipped
# with `except pywintypes.com_error` raising NameError wherever pywin32 is
# absent, and only the Linux legs of ci.yml caught it.
strategy:
fail-fast: true
matrix:
include:
- os: ubuntu-latest
python-version: "3.11"
extra: pdf
- os: ubuntu-latest
python-version: "3.12"
extra: pdf
- os: windows-latest
python-version: "3.11"
extra: full
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: ${{ matrix.python-version }}
- run: pip install "uv==${{ env.LOCKED_UV_VERSION }}"
- name: Install the locked environment
# `--locked` refuses to re-resolve, so the artifact that goes to PyPI is
# gated by the exact dependency set the suite was run against — not by
# whatever the index happens to serve at tag time. ruff's version comes
# from [dependency-groups] dev via the lock; it is not restated here.
run: uv sync --locked --extra ${{ matrix.extra }} --python "${{ steps.python.outputs.python-path }}"
- run: uv run --no-sync ruff check .
- run: uv run --no-sync ruff format --check .
- run: uv run --no-sync pytest -q
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Verify tag matches package version
run: |
pip install tomli-w 2>/dev/null || true
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "Tag $GITHUB_REF_NAME does not match pyproject version $PKG_VERSION" >&2
exit 1
fi
- name: Build distributions
run: |
pip install build twine
python -m build
twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish-pypi:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
github-release:
needs: publish-pypi
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--generate-notes
+18
View File
@@ -43,6 +43,24 @@ desktop.ini
out/
benchmarks/results/*
!benchmarks/results/.gitkeep
!benchmarks/results/published
!benchmarks/results/published/**
benchmarks/.competitors/
*.dxf
# The bundled templates are the one committed DXF set: built by
# scripts/build_templates.py and pinned reproducible by its --check.
!templates/*.dxf
*.dwg
*.bak
# Local editor/agent state
.cursor/
# NOTE: uv.lock is deliberately *not* ignored. It used to be — back when the
# committed one was stale and unvetted — but CI now installs from it with
# `uv sync --locked`, so ignoring it would silently disarm every locked lane.
# Regenerate with `uv lock` whenever pyproject.toml dependencies change.
# AutoCAD writes its plot-and-publish log into the cwd of the plotting process
# (scripts/smoke_settings_com.py plots from the repo root).
plot.log
+6 -1
View File
@@ -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]
+2110 -1
View File
File diff suppressed because it is too large Load Diff
+73 -17
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~110 tools, 5 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~247 tools, 8 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
## Running the Server
@@ -33,6 +33,9 @@ pip install -e ".[com]"
# With PDF/screenshot support via matplotlib
pip install -e ".[pdf]"
# XLSX workbooks (takeoffs, data_extract) via openpyxl
pip install -e ".[office]"
# Everything
pip install -e ".[full]"
```
@@ -53,7 +56,9 @@ Uses `pytest-asyncio` for async test support.
The server uses a strategy pattern with an abstract base:
- `backends/base.py` — `AutoCADBackend` ABC defining the full interface + shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`)
- `backends/contracts/` — the interface, one module per domain (`drawing.py`, `layouts.py`, `entity_creation.py`, …). Split in v1.5.0 out of a single 1854-line ABC; some slices (premium, gdt, settings) also carry shared *concrete* implementations, so they are mixins rather than pure interfaces.
- `backends/capability.py` — the refusal vocabulary (`UnsupportedCapabilityError`) plus `@capability(key, reason=...)`, which marks a contract method optional and gives it a default that **raises** that refusal. Use it only for genuine engine boundaries, never for an unfinished method; `@abstractmethod` stays the default. Every key used must be declared in *both* capability maps — `tests/test_capability_contract.py` fails otherwise, which is what stops the decorator becoming a way to ship undeclared holes.
- `backends/base.py` — shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`, `CapabilityMap`) and the `AutoCADBackend` composition of every contract. Import surface is unchanged: `from backends.base import AutoCADBackend, UnsupportedCapabilityError` still works.
- `backends/ezdxf_backend.py` — `EzdxfBackend`: file-based DXF operations using the `ezdxf` library. All sync ezdxf calls are wrapped with `asyncio.to_thread` via `_async()`. Transactions are implemented as full DXF snapshots on an `_undo_stack`.
- `backends/com_backend.py` — `ComBackend`: live AutoCAD control via `pywin32` COM. All COM calls are routed through a `ThreadPoolExecutor` with a single thread to satisfy AutoCAD's STA (Single-Threaded Apartment) COM requirement.
@@ -69,29 +74,54 @@ The `mcp` FastMCP instance is configured with:
- **Middleware stack**: `ErrorHandlingMiddleware` → `AuditMiddleware` (custom timing/audit log) → `TimingMiddleware` → `LoggingMiddleware`
- **`_backend(ctx)`** helper: retrieves the backend from lifespan context, raises `ToolError` if not ready
Tools are organized into 12 sections:
1. Drawing Management (11 tools): `drawing_*`
2. Entity Creation (13 tools): `entity_create_*`
Tools are organized into sections (counts are indicative — `system_about` is authoritative):
1. Drawing Management (12 tools): `drawing_*` (includes `drawing_redo`)
2. Entity Creation (14 tools): `entity_create_*` (includes `entity_create_table`, `leader_create_mleader`)
3. Dimensions (5 tools): `dimension_*`
4. Entity Modification (12 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
5. Entity Query (3 tools): `entity_get`, `entity_list`, `entity_delete_many`
6. Layer Management (12 tools + 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
7. Block Operations (7 tools): `block_*`
8. Analysis & Query (8 tools): `analysis_*`
9. View & Screenshot (5 tools — includes `view_zoom_and_screenshot`): `view_*`
4. Entity Modification (16 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, corner ops (`entity_trim/extend/fillet/chamfer`), plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
5. Entity Query (9 tools): `entity_get`, `entity_list`, `entity_delete_many`, `selection_get`, `selection_window/polygon/filter`, and `entity_get_xdata` / `entity_set_xdata` (typed by group code)
6. Layer Management (14 tools incl. 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
7. Block Operations (8 tools): `block_*` — includes `block_define` (a block from typed primitive specs + ATTDEFs, no source entities needed)
8. Analysis & Query (8 tools): `analysis_*` — plus Batch (2), Templates (2), Validation (1)
9. View & Screenshot (4 tools — includes `view_zoom_and_screenshot`): `view_*`
10. Transactions (3 tools): `transaction_begin/commit/rollback`
11. System (7 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables)
12. Engineering / Deterministic CAD (7 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
13. Premium meta-tools: `drawing_plan`, `drawing_critique`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
11. System (8 tools): `system_status/get_variable/set_variable/run_command/run_lisp/about/capabilities`, plus `drawing_settings` (friendly units/scale/precision/osnap facade over system variables; the facade now also covers limits, grid/snap/ortho/polar, PSLTSCALE, annotation scale (`"1:50"`), linear/angular unit formats and the current dimstyle/textstyle, with unknown keys refused and every key reported on read)
12. Engineering / Deterministic CAD (8 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize`
13. Premium meta-tools (12): `drawing_preflight`, `drawing_plan`, `drawing_critique`, `drawing_refine`, `drawing_deliver`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart`
14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus
15. Layouts & Paper Space (12 tools): tab lifecycle `layout_list/create/set_current/delete/rename/copy`, viewports `viewport_create/list/set_scale/lock/delete`, and `entity_change_space` (CHSPACE — tagged `layout` *and* `modify`, and `layout` wins the group priority, so it files here). `drawing_export_pdf` takes an optional `layout` param and **does** project model content through viewports on both engines (`viewport_render` — v1.4 declared this COM-only and was wrong; the headless renderer only omits the viewport borders). `entity_change_space` is the mirror case — ezdxf-only (`chspace`), because ActiveX exposes no change-space member and the COM route is unverified.
16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS).
17. P&ID (9 tools, pack `pid`): `pid_symbol_list/insert` (ISO 10628-2 + ISA-5.1 catalogue authored in code, real blocks with TAG attributes, named ports, ACADMCP_PID XDATA), `pid_line_draw` (port-to-port orthogonal routing, ISA-5.1 line classes → layer/linetype/marker blocks, line numbers), `pid_tag_parse` (ISA-5.1 grammar), the reader `pid_graph` and its deliverables `pid_instrument_index/line_list/equipment_list`, and `pid_from_spec` (whole sheet, one transaction). Six `pid_*` critique focuses (`pid_dangling_line`, `pid_duplicate_tag`, `pid_incompatible_connection`, `pid_untagged_instrument`, `pid_illegal_tag`, `pid_unconnected_equipment`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing without P&ID nodes. Two resources (`autocad://pid/symbols`, `autocad://standards/isa51`) carry the catalogue and the ISA-5.1 letter tables. Both engines implement every P&ID contract; the COM paths were executed live once by `scripts/smoke_pid_com.py` (AutoCAD 2026).
18. Styles (10 tools, pack `core`): `dimstyle_list/create/modify/set_current` (authored ISO-25 / ANSI presets from `engineering/standards/dimstyles.py`; `dimstyle_modify` accepts only `DIM_VARIABLE_WHITELIST` names (33) and refuses out-of-range values by name before writing; `changed` lists only variables that moved, and `rerender_required: true` says a headless DIMENSION keeps its rendered block until redrawn), `textstyle_list/create/set_current` (`font_resolved: false` is reported, never refused — DXF stores only the name), `mleaderstyle_list/create` (both engines: ezdxf on `doc.mleader_styles`, live through the `ACAD_MLEADERSTYLE` dictionary — ActiveX has no MLeaderStyle *collection*, which is not an engine boundary, so no `mleaderstyle` capability key exists), and `drawing_apply_standard("iso"|"ansi")` — dimstyle + text style + units + the `mech` layer set in one call, each item reporting `created` or already present.
19. Page setup & templates (6 tools, pack `core`): `page_setup_list/apply` (ISO 216 / ANSI Y14.1 sizes from `PAPER_SIZES`; an unknown ctb is written and reported `plot_style_known: false`), `plot_style_list` (catalog + the files actually installed on a live seat), `batch_plot` (every paper layout through `drawing_export_pdf(layout=)`; `mediabox_mm` is **read back from the PDF's own `/MediaBox`**, so the sheet size is verified, not assumed), `drawing_template_list` (the five bundled templates: `iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch` — DXF headless, `.dwt` twin on COM, `scripts/build_templates.py --check` proves the DXFs reproducible), `drawing_template_save` (a `.dwt` request headlessly is refused with capability `dwt_write` naming the DXF route). `drawing_new(template=)` resolves a catalog name to the bundled file and reports `template: {name, source}`.
20. Environment (22 tools, pack `settings`): documents `document_list/activate/close` (both engines — the headless backend keeps a document registry; `document_close` refuses to discard unsaved work unless `discard=True`), layer states `layer_state_save/restore/list/delete` (portable: JSON chunks in an `ACADMCP_LAYERSTATES` XRECORD that travels with the file and **does not appear in AutoCAD's Layer States Manager** — `system_capabilities.layer_states` says so), named views `view_named_save/restore/list` (headless restore writes `$VIEWCTR/$VIEWSIZE` and reports `applied: "header_only"`), UCS `ucs_list/set/restore` (non-orthogonal axes refused with the measured angle; **tool coordinates stay WCS** — a UCS is stored for the operator, no tool interprets input in it), `system_variable_describe` (the 90-entry `SYSVAR_CATALOG`; an unknown name answers `known: false` plus nearest names, never an error; `system_set_variable` now refuses an out-of-range value for a catalogued name before writing), `drawing_properties_get/set` (custom properties on both engines through `$CUSTOMPROPERTYTAG`; the five summary fields are COM-only, capability `dwgprops`), and the live-only group `system_launch` (`live_application`), `system_preferences_get/set` (`preferences`; a whitelist of `Preferences.*` keys with ranges, read-only keys refused by name, writes report `old`/`new`), `user_pick_point/select`, `system_prompt_message` (`interactive_prompt`; a cancelled pick returns `{cancelled: true}`, a `COM_CALL_TIMEOUT` overrun reports `timed_out`). The COM paths were executed live once by `scripts/smoke_settings_com.py` (AutoCAD 2026), which also built the `.dwt` twins.
21. Mechanical parts (6 tools, pack `mech`): `mech_part_draw`, `mech_view_add`, `mech_dimension_part`, `mech_hole_pattern`, `mech_part_from_spec`, `mech_part_inspect`. One part model — a profile (`RevolvedPart` segments or a `PrismaticPart` outline) plus typed features — and one view engine that turns it into front / side / top / section / detail views with hidden lines, cut faces and ISO 129 dimensions. The model is written onto the drawing as `ACADMCP_MECH` XDATA, so `mech_view_add` can add a view months later without the caller re-describing the part. A feature that cannot be projected into a view is listed in `omitted: [{feature, reason}]` — never dropped.
22. Standard parts (3 tools, pack `mech`): `std_part_list`, `std_part_insert`, `std_feature_draw`. ISO 4014 / 4017 / 4032 / 7089 / 4762 fasteners, ISO 15 bearings with ISO 8826-1 representation, DIN 471/472 retaining-ring grooves, DIN 509 undercuts, DIN 332 centre holes and ISO 3601-2 O-ring grooves — authored tables, inserted as real blocks with attributes and `ACADMCP_MECH` XDATA, which is what makes the parts list a *read* of the drawing. A size outside a table's transcribed coverage is refused by name, never interpolated. The four feature tables (DIN 471/472, DIN 509, DIN 332, ISO 3601-2) ship with **no rows** in 1.6: their drawing code and provenance tests exist, every size is refused by name, and filling one is a data edit described in its module docstring.
23. Mechanical annotation (5 tools, pack `mech`): `surface_texture` (ISO 21920-1, ISO 1302's older Ra form accepted), `weld_symbol` (ISO 2553), `centre_marks` (ISO 128-23), `section_line` (ISO 128-40 — it returns the plane `mech_view_add(kind="section")` consumes, so the label and the view cannot disagree), `hatch_material` (the ISO 128-50 map; an unknown material is refused with the list, never hatched as steel).
24. Sheet and delivery (11 tools, pack `core`): `sheet_frame` (ISO 5457 A4-A0 — 20 mm filing margin, 10 mm elsewhere on A3, zone grid, centring and trimming marks), `titleblock_apply` (ISO 7200 for every size; `titleblock_apply_iso_a3` is now a thin alias), `revision_add`, `bom_extract` (never modifies the drawing), `bom_table` (ISO 7573), `balloon_add` (ISO 6433, linked by XDATA so a rerun renumbers instead of duplicating), `data_extract` (CSV always; XLSX refused with `xlsx_write` when `openpyxl` is absent), `xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg` (COM `SaveAs` natively; headless only with the ODA File Converter, otherwise `dwg_write` refuses and names the install route). The COM paths of SECTIONS 21-24 were executed live once by `scripts/smoke_mech_com.py` (AutoCAD 2026): part, section, dimensions, an ISO 4014 block with its XDATA, the parts list read off the drawing, a linked balloon, an xref attached and detached, a real `.dwg`, and `drawing_critique(focus=None)` clean.
25. Architecture (12 tools, pack `arch`): `arch_wall`, `arch_opening`, `arch_stair`, `arch_room`, `arch_rooms_detect`, `arch_schedule`, `arch_grid`, `arch_symbol`, `arch_catalogue_list`, `arch_catalogue_insert`, `arch_dimension_chains`, `arch_plan_from_spec`. One plan model — a wall network (axis polyline, thickness, justification, material) with hosted doors and windows — and one engine: L / T / X junctions resolved, openings cut, cut faces hatched by material (`engineering/arch/materials.py`: ISO 128-50 general hatching for brick, the JIS A 0150 RC / stone / wood symbols, AutoCAD's AR-CONC / AR-SAND, AAC as a declared convention), and anything it cannot place listed in `omitted: [{element, reason}]`, never dropped. Rooms are *computed*: a label's area is the planar face its point lies in, and `arch_rooms_detect` reads rooms from any plan (confidence 1.0 for our walls, 0.6 for plain lines) without modifying it. Schedules are a read of the `ACADMCP_ARCH` records, drawn as real TABLEs. Labels and schedules speak English, or Turkish with `lang="tr"` (decimal comma). The COM paths were executed live once by `scripts/smoke_arch_com.py` (AutoCAD 2026).
26. Understanding & QA (4 tools, pack `core`): `drawing_understand` (one read-only report of a drawing somebody else made — declared vs inferred units with the evidence, declared vs robust extents and the outlier handles behind them, clusters (sheets, plan copies), every layer's likely discipline and service from an EN / TR / RU / NL / DE vocabulary with a confidence and the deciding keyword, equipment tags, rooms, text languages, blocks, layouts, title-block attributes), `drawing_scale_check` (a drawing against a reference: tags unique within a cluster are matched — a tag seen twice is `ambiguous`, never picked — pairs at least 2 m apart, the median ratio, IQR and the share within ±10 %: `to_scale` at 80 % or more, with its factor; `schematic` at 50 % or less; `partly` between), `drawing_diff` (two revisions matched by signature, then handle, then type + layer + nearest position; `markup=True` draws revision clouds on the newer one — its only write) and `drawing_topology_check` (dangling ends, near misses, interior crossings; the three `topo_*` critique focuses run the same engine under `focus=None` on layers no semantic focus covers). Every one reads one snapshot per drawing (`engineering/understand/snapshot.py`: `ezdxf` directly, or on a live seat `Document.Export(<tmp>/snap, "DXF", ...)`, which never renames the document and carries model space, paper space and block definitions — measured).
27. Plant takeoffs (2 tools, pack `plant`): `pipe_takeoff` (topology from the P&ID — segments merged at their ends, split at T-junctions, continued through inline fittings, a double-drawn segment counted once; service from the layer overridden by supply / return words; sizes kept as drawn — `Ø51`, `SMS51`, `DN20` are never equated — direct, by continuity or `unassigned`; lengths from the layout: the rectilinear MST of the tags a run connects, labelled an estimate, the P&ID length kept beside it as a comparison; statuses A / B / C) and `cable_takeoff` (one row per load: power from the P&ID's electrical texts, never invented; the panel from the layout's `Wiring to CPn` callouts; the Manhattan length plus the allowance rounded up to the metre; a section only from a caller's `section_rules`). Both write a workbook (Metraj / Özet / Kontrol / Metodoloji, `lang` tr / en / ru, XLSX with the `office` extra, CSV always) and never the drawing. `scripts/field_test_takeoff.py` runs both on a user's own pair and writes next to it, never into this repository. The COM paths were executed live once by `scripts/smoke_understand_com.py` (AutoCAD 2026).
**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` = 65 curated drafting tools (55 with `TOOL_PACKS=core`, which drops the three `pid_*`, the three `mech_*` and the four `arch_*` lean members, and keeps `drawing_understand`; the five settings essentials — `drawing_apply_standard`, `dimstyle_set_current`, `textstyle_set_current`, `page_setup_apply`, `batch_plot` — are core-pack tools and stay; including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
**Tool packs:** `TOOL_PACKS=all|core,pid,settings,mech,arch,plant` (default `all`) advertises only the vertical packs a client uses; `core` is always on and is everything no other pack claims; `settings` is the 22 SECTION 20 environment tools, `pid` the nine P&ID tools, `mech` the 14 SECTION 21-23 mechanical tools, `arch` the 12 SECTION 25 architectural tools, `plant` the two SECTION 27 takeoffs (track D's Plant 3D readers join it); SECTION 18/19/24/26 are core; unknown names are ignored with a warning; a `lean` profile intersects with the enabled packs. `system_about` reports `tool_packs`.
**Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index.
**ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text.
**ISO 286 fits:** the same dimension tools take `fit="H7"` (mutually exclusive with `tol_*`) — deviations are resolved from authored ISO 286 tables (`engineering/fits.py`; shafts d/e/f/g/h/js/k/m/n/p and interference r/s/t/u, holes D/E/F/G/H/JS and K/M/N/P by the ISO 286 delta rule, sizes 1–500 mm) against the measured nominal and the fit code is appended to the dimension text.
**Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric.
**Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so.
**Benchmark matrix:** `benchmarks/tasks_v7.py` is the current set (21 tasks; `--matrix v6` / `v5` / `v4` / `v3` / `v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks, the v4 `pid_roundtrip` / `page_setup_truth`, the v5 `mech_assembly` (a flange-coupling sheet gated on a zero-issue `drawing_critique(focus=None)` and a finalize score of at least 90) or the v6 `arch_roundtrip` (a two-room plan drawn by `arch_plan_from_spec`, its rooms read back by `arch_rooms_detect` within 0.1 % of the label and of the hand-computed net floor, the schedules listing the drawn tags, gated like `mech_assembly`) or the v7 `takeoff_roundtrip` / `understand_foreign` (the synthetic plant pair of `tests/fixtures/plant_pair.py` read without its truth: the P&ID called schematic, every routable run's layout length equal to the rectilinear MST of its tags, the cable metres rounded up after the allowance; units, the outlier, both plan copies and the service layers found by one `describe`) — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (48 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.5.1` (the published report is `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`).
### Adding a New Tool
1. Add the abstract method to `AutoCADBackend` in `backends/base.py`
1. Add the method to the matching module in `backends/contracts/` — `@abstractmethod` if both engines must have it, `@capability("key", reason=...)` only if it is a real engine boundary (and then declare `key` in both capability maps, or the gate fails)
2. Implement it in `EzdxfBackend` (`backends/ezdxf_backend.py`) using the `_async(func)` wrapper
3. Implement it in `ComBackend` (`backends/com_backend.py`) using `self._com(func)`
4. Register the tool in `server.py` with `@mcp.tool(...)` calling `_backend(ctx).your_method(...)`
@@ -101,17 +131,43 @@ Tools are organized into 12 sections:
- **Entity handles**: hex strings (e.g. `"1A2B"`). Always returned from create/copy operations; required by all modify/query operations.
- **Coordinates**: drawing units (mm by default); angles in degrees, counter-clockwise from X axis.
**Every coordinate in or out of a tool is WCS on both engines.** DXF stores
CIRCLE/ARC/LWPOLYLINE/TEXT/INSERT geometry in a per-entity frame (`extrusion`);
`backends/ocs.py` translates at the backend boundary, so OCS never reaches a
tool. An entity in a plane tilted out of WCS XY reports `plane_normal` and omits
the fields xy cannot express; writing an xy into such an entity is refused with
`capability: "ocs_tilted_plane"` — use `entity_move` instead.
- **ACI colors**: 1–255 for specific colors, 256=ByLayer, 0=ByBlock.
- **COM backend only**: `system_run_command`, `system_run_lisp`, and `view_zoom_extents/window` (view ops are no-ops in ezdxf).
- **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib.
- **`_dc(obj)`**: converts dataclasses to dicts recursively for JSON serialization.
- **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out.
- **Generic contracts added in 1.6:** `block_define(name, entities, attdefs)` (typed primitives + ATTDEFs, both engines; `overwrite` replaces contents so INSERTs keep the name) and `entity_get/set_xdata` (typed by group code; 255-char strings, 16 KB per entity, enforced before writing).
- **P&ID reader rules:** `pid_graph` never modifies the drawing, reads geometry from the drawing (an INSERT's real rotation/scale) rather than the catalogue, and reports `source`/`confidence` per node (catalog 1.0, xdata 0.95, heuristic 0.6, inferred 0.3) — never raised by agreement between heuristics. Read `stats.confidence_min` before trusting a foreign drawing.
- **`TOOL_PACKS=all|core,pid,settings,mech,arch,plant`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`.
- **Layer states are ours, not AutoCAD's.** `layer_state_*` stores a portable snapshot in an `ACADMCP_LAYERSTATES` XRECORD; it survives save/reopen on both engines and travels with the file, and it is not listed in AutoCAD's Layer States Manager. Said in the tool docstring, in `system_capabilities.layer_states` (`mode: "xrecord"`, reason `portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager`) and in the README.
- **Page-setup evidence is the PDF.** `batch_plot` reports `mediabox_mm` parsed from the file's `/MediaBox` (`engineering/standards/pdfinfo.py`); a page setup is verified by what a printer would get, not by the setter's return value. A style or setting write reports `changed` — re-setting a value to itself is not a change.
- **Nothing interprets coordinates in UCS.** `ucs_set` / `ucs_restore` store and activate a UCS for the operator's benefit; every tool input and output stays WCS (the repository rule above).
- **The part model, not primitives**: a machine part is `mech_part_draw` with a segment list or an outline plus typed features — never hand-drawn lines. The same rule as gears and keyways, for the same reason.
- **Standards tables are transcribed, never derived**: every row under `engineering/mech/standards/` carries a `SOURCE` naming standard, edition and table, and a size outside the coverage is refused by name. `engineering/mech/standards/materials.py` states which of its rows are ISO 128-50, which are ASME Y14.2M via `acadiso.pat`, and which (concrete, wood) have no mechanical standard at all.
- **Six `mech_*` critique focuses** (`mech_missing_centreline`, `mech_unhatched_section`, `mech_view_misaligned`, `mech_duplicate_dimension`, `mech_thread_unrepresented`, `mech_bom_balloon_mismatch`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_MECH` payload. `mech_view_misaligned` checks the projection axis and not the quadrant, and `mech_duplicate_dimension` catches two dimensions in the same place rather than a semantic restatement — both narrowings are in the module docstring, because the payload carries no projection angle and no engine exposes what a dimension measured.
- **Live ActiveX will not create a layer on assignment** (`entity.Layer = "X"` on a missing layer raises `Key not found`; ezdxf creates it silently). Every mechanical/annotation draw path ensures its target layers before the first entity — a new path must do the same, or it passes every headless test and dies live.
- **A diameter's text stands beyond the FIRST chord point** on both engines (the ActiveX `AddDimDiametric` rule, measured); `dimension_diameter(x1, y1, x2, y2)` annotates on the `(x1, y1)` side.
- **The plan model, not lines**: a wall is `arch_wall` (an axis, a thickness, a justification, a material) and a door or window is hosted in it by `arch_opening` — never hand-drawn lines. The engine resolves the junctions and cuts the openings; what it cannot place is in `omitted`.
- **Areas are measured, never typed**: a room's area is the planar face its label point lies in, written into the label and the `room` record by `arch_room`; no tool accepts an area from its caller, and `arch_plan_from_spec` refuses a typed `area` by path.
- **Plan text follows the plot scale**: `scale` is a denominator (50 = 1:50) and a paper height `h` is drawn at `h × scale` drawing units — no annotative objects. `lang="tr"` switches words and the decimal separator; any other language is refused with the list.
- **Three `arch_*` critique focuses** (`arch_room_unlabelled`, `arch_wall_gap` — two wall ends within 50 mm that do not join, `arch_opening_clash`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing with no `ACADMCP_ARCH` record.
- **Nominal, not standard**: the furniture and sanitary blocks carry nominal catalogue dimensions and say so; the stair tool reports the Blondel relation (2R + G within 600–650 mm) as a design rule, never as a building code; the `arch` layer colours are a repository convention (ISO 13567 fixes names, not colours).
- **Read-only by default (track H)**: `drawing_understand`, `drawing_scale_check`, `drawing_topology_check` and both takeoffs never modify the drawing; `drawing_diff` writes only with `markup=True`, and a takeoff writes a workbook file, never the drawing.
- **Lengths come from the inferred unit**: a reader measures in the unit the geometry implies (text heights, wall and door sizes, overall size) and reports metres; a disagreement with `INSUNITS` is a warning with the numbers — a layout declaring inches over millimetre geometry is measured in millimetres, never ×25.4.
- **Scale check before a P&ID length**: a P&ID is a length source only when `drawing_scale_check` calls it `to_scale`; otherwise a run's length is the orthogonal estimate on the layout and the drawn length stays a comparison column (`length_source="pid"` on a schematic P&ID is refused unless `force=True`, quoting the statistics).
- **Nothing is invented (track H)**: a power the P&ID does not state stays empty and becomes an open item, an unreadable size stays `unassigned`, and no cable-section table is authored — a section comes only from the caller's `section_rules`.
- **Client drawings never enter the repository**: every track-H fixture is the synthetic plant pair (`tests/fixtures/plant_pair.py`), whose truth is computed from the positions it placed; `scripts/field_test_takeoff.py` refuses an output folder inside the repository and prints no quantity unless `--show` is given.
- **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator.
### Premium Drawing Rules
These rules are non-negotiable for production engineering output. The full
discipline + workflow templates live in `.claude/skills/autocad-mcp-premium/`.
These rules are non-negotiable for production engineering output.
1. **Plan before draw**: Call `drawing_plan(intent, scale, sheet)` *before* any
`entity_create_*`. The returned PlanSpec is held by the backend and replayed
+6 -3
View File
@@ -4,9 +4,12 @@ WORKDIR /app
RUN useradd --create-home --shell /bin/bash mcp
COPY pyproject.toml ./
COPY server.py config.py security.py ./
COPY pyproject.toml README.md LICENSE ./
COPY server.py config.py security.py version.py ./
COPY backends/ ./backends/
COPY engineering/ ./engineering/
COPY discovery/ ./discovery/
COPY templates/ ./templates/
RUN pip install --no-cache-dir -e .
@@ -15,6 +18,6 @@ USER mcp
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import server; print('ok')" || exit 1
CMD python -c "import server, engineering.gear; print('ok')" || exit 1
CMD ["python", "server.py"]
+121
View File
@@ -0,0 +1,121 @@
# AutoCAD MCP Pro — как у нас развёрнут и как обновлять
Этот репозиторий — **зеркало** открытого проекта `U-C4N/Autocad-MCP` (PyPI-пакет
`autocad-mcp-pro`), которым мы пользуемся для черчения из агентов Hermes.
## 1. Источники версий и обновлений (единственные авторитетные)
| Что | Где смотреть |
|---|---|
| Исходный репозиторий | https://github.com/U-C4N/Autocad-MCP |
| Релизы и теги (что нового) | https://github.com/U-C4N/Autocad-MCP/releases — теги `v1.3.0 … v1.6.0` |
| Список изменений | `CHANGELOG.md` в корне репозитория (самый подробный источник) |
| PyPI-пакет и доступные версии | https://pypi.org/pypi/autocad-mcp-pro/json (поле `info.version`, `releases`) |
| Внутренняя версия сервера | MCP-вызов `system_about` / ответ `initialize` (`serverInfo.version`) |
**Текущий пин зеркала:** ветка `main`, коммит `cdb1063` (после релиза **1.6.0 от 24.09.2026**).
Релизы: `v1.5.1` → `v1.6.0` (247 зарегистрированных инструментов, 242 отдаётся по умолчанию).
Обновления проверять так (без установки):
```bash
# последняя версия на PyPI
curl -s https://pypi.org/pypi/autocad-mcp-pro/json | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['info']['version'], list(d['releases'])[-4:])"
# свежие релизы на GitHub
curl -s https://api.github.com/repos/U-C4N/Autocad-MCP/releases | python3 -c "import json,sys;[print(r['tag_name'], r['published_at']) for r in json.load(sys.stdin)[:5]]"
```
## 2. Как это работает у нас (архитектура)
```
Hermes (Docker, VM108) --HTTP MCP (Bearer)--> контейнер autocad-mcp :30015 --ezdxf--> DXF/PDF
контейнер autocad-mcp-160 :30016 (тестовый 1.6.0)
папка обмена: /var/lib/docker/volumes/hermes_hermes-data/_data/dxf → /opt/data/dxf
```
| Параметр | Значение |
|---|---|
| Рабочий контейнер | `autocad-mcp`, образ `autocad-mcp:latest` = **1.5.1 + наш патч**, порт **30015 → 8000** |
| Тестовый контейнер | `autocad-mcp-160`, образ `autocad-mcp:1.6.0` (апстрим без патча), порт **30016** |
| Бэкенд | `AUTOCAD_MCP_BACKEND=ezdxf` (headless, без AutoCAD; COM — только на Windows рядом с AutoCAD) |
| Токен доступа | файл `/opt/data/.autocad_mcp_token` (chmod 600, **в git не попадает**), передаётся как `Authorization: Bearer …` |
| Папка файлов | `ALLOWED_PATHS=/opt/data/dxf` — сервер принимает файлы **только** оттуда (иначе «Path rejected») |
| Профиль инструментов | `TOOL_PROFILE=full`; для слабых моделей есть `TOOL_PACKS=core,mech...` и `DISCOVERY_MODE=search` (2 инструмента / 356 токенов вместо 83 921) |
| Деплой-скрипты | `/opt/data/deploy_autocad_mcp.py` (рабочий, с тегом отката `autocad-mcp:prev`), `/opt/data/work/cad_mcp/deploy_160_test.py` (тестовый 1.6.0) |
| Логи сборки | `/opt/data/autocad_mcp_build.log`, `/opt/data/autocad_mcp_160_build.log` |
| Проверка живости | `python3 /opt/data/work/cad_mcp/tools_inventory.py 30015` (делает `initialize` → `tools/list`) |
## 3. Что в нашей рабочей сборке сверх апстрима (патч 01.10.2026)
Оверлей лежит в `/opt/data/autocad-mcp-build/patch/` и копируется поверх `site-packages`
(см. `Dockerfile` в `/opt/data/autocad-mcp-build/`). Этих инструментов в апстриме нет:
| Инструмент | Зачем |
|---|---|
| `titleblock_apply_gost_form3` | основная надпись по **ГОСТ Р 21.1101-2013 форма 3** (апстрим знает только ISO 7200) |
| `file_put` / `file_get` | передача файлов внутрь `ALLOWED_PATHS` и обратно (без Docker-томов и HTTP-моста) |
| `drawing_open_base64` / `drawing_save_base64` | обмен чертежами в base64 |
| `entity_create_attdef`, `block_get_attdefs` | символы-блоки с атрибутами (тип/высота/группа) |
| параметр `layout=` у инструментов **геометрии** (29 инструментов) | рисовать линии/полилинии прямо на ЛИСТЕ, а не только в модели |
| параметр `inline_font=True` (5 инструментов) | инлайн-кодировка ISOCPEUR в каждом тексте |
| `drawing_export_pdf(page_size="layout")` | страница PDF ровно по формату листа (в 1.5.1 апстрим подгонял по содержимому) |
| `layout_set_paper` | явный размер бумаги листа (A4 книжный и др.) |
| `template_apply_layers(spds)` | ГОСТ-набор слоёв (RAMKA, STAMPS, 00 БЛОКИ АННОТАЦИЙ, Стены) |
| `dimstyle_ensure`, `text_apply_style` | размерный стиль СПДС, текстовые стили |
⚠️ **Следствие:** апстрим 1.6.0 нельзя просто «поставить вместо» — часть наших возможностей
пропадёт (ГОСТ-штамп, file_put/file_get, `layout` у геометрии, `inline_font`). Поэтому 1.6.0
поднимается **рядом** (порт 30016), а перенос патча делается отдельным шагом.
## 4. Установка с нуля
```bash
# 1) образ (Docker-контекст: Dockerfile + patch/)
mkdir -p /opt/data/autocad-mcp-build && cd /opt/data/autocad-mcp-build
# Dockerfile:
# FROM python:3.11-slim
# RUN pip install --no-cache-dir autocad-mcp-pro==1.5.1 matplotlib
# COPY patch/ /usr/local/lib/python3.11/site-packages/
# EXPOSE 8000
# CMD ["autocad-mcp","--transport","http","--host","0.0.0.0","--port","8000"]
# 2) сборка и переустановка контейнера (Portainer API, безопасно: сначала образ, потом замена)
python3 /opt/data/deploy_autocad_mcp.py # рабочий (с тегом отката autocad-mcp:prev)
python3 /opt/data/work/cad_mcp/deploy_160_test.py # тестовый 1.6.0 на :30016
# 3) подключение в Hermes (конфиг защищён, менять через config set)
export PATH="/opt/hermes/.venv/bin:$PATH" HERMES_HOME=/opt/data HOME=/opt/data
hermes config set mcp_servers.autocad.type http
hermes config set mcp_servers.autocad.url http://10.26.5.108:30015/mcp
hermes config set mcp_servers.autocad.headers.Authorization "Bearer <токен из /opt/data/.autocad_mcp_token>"
hermes mcp test autocad # ожидаем "Connected" + число инструментов
# 4) проверка
python3 /opt/data/work/cad_mcp/tools_inventory.py 30015
python3 /opt/skills/devops/cad-automation/scripts/mcp_acceptance_test.py # приёмочный прогон
```
## 5. Обновление версии
1. Проверить новую версию (п. 1) и прочитать `CHANGELOG.md`.
2. Собрать **тестовый** образ на новую версию: скопировать
`/opt/data/autocad-mcp-build/Dockerfile` в новый каталог, заменить пин версии,
запустить `deploy_160_test.py` (порт 30016).
3. Прогнать инвентаризацию и приёмку по обоим портам, сравнить:
`tools_inventory.py 30015` против `tools_inventory.py 30016` — что появилось, что пропало.
4. Перенести наш патч (см. п. 3) либо решить, что из апстрима заменяет нашу доработку.
5. Переключить рабочий контейнер (`deploy_autocad_mcp.py`) — он сам ставит тег отката
`autocad-mcp:prev`; при проблеме вернуться на него.
**Откат:** остановить контейнер, создать заново из образа `autocad-mcp:prev` с теми же
параметрами (порт 30015 → 8000, `ALLOWED_PATHS=/opt/data/dxf`, тот же токен, User 10000:10000).
## 6. Грабли, которые уже собирали (не повторять)
- `tools/list` «сырым» POST даёт **HTTP 400** — нужен `initialize` → заголовок `Mcp-Session-Id` → `tools/list`.
- Файлы внутрь контейнера: только путь из `ALLOWED_PATHS` (`/opt/data/dxf`), иначе «Path rejected».
- У контейнера **свой том** — это не тот `/opt/data/dxf`, который видит агент; для передачи
файлов используем `file_put`/`file_get` или мост `dxf-bridge` (`http://10.26.5.108:30055/`).
- PDF: проверять медиабокс (`pymupdf` → `page.rect.width/72*25.4` ≈ формат листа) и шрифт (`ISOCPEUR`).
- Чертёж отдавать пользователю только после проверки движком AutoCAD (`accoreconsole /i … /s …`).
- COM-режим (живой AutoCAD) **невозможен из Docker** — сервер обязан работать на Windows рядом с AutoCAD.
+395 -274
View File
@@ -1,348 +1,469 @@
<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 anywhere — one typed contract, two engines.
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-1f6feb?logo=python&logoColor=white)](pyproject.toml)
[![FastMCP 3](https://img.shields.io/badge/MCP-FastMCP%203-8957e5)](https://github.com/jlowin/fastmcp)
[![Stars](https://img.shields.io/github/stars/U-C4N/Autocad-MCP?style=flat&color=e3b341)](https://github.com/U-C4N/Autocad-MCP/stargazers)
[![CI](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml/badge.svg)](https://github.com/U-C4N/Autocad-MCP/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/autocad-mcp-pro?color=1f6feb&label=PyPI&logo=pypi&logoColor=white)](https://pypi.org/project/autocad-mcp-pro/)
[![Downloads](https://img.shields.io/pypi/dm/autocad-mcp-pro?color=8957e5&label=downloads)](https://pypi.org/project/autocad-mcp-pro/)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-1f6feb?logo=python&logoColor=white)](https://github.com/U-C4N/Autocad-MCP/blob/main/pyproject.toml)
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950)](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
One typed MCP contract controls two execution engines. Build and edit drawings,
query exact geometry, apply engineering standards, refine quality issues inside
transactions, and deliver hashed artifacts with validation evidence.
[Install](#install) · [What's new](#whats-new-in-16) · [Tools](#what-you-get) · [Engines](#the-two-engines) · [Evidence](#evidence) · [Limits](#what-this-release-is-bad-at) · [Config](#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
> **v1.3 release snapshot:** 122 tools · 6 resources · 5 prompt templates ·
> 415 collected tests. Runtime discovery through `system_about` is authoritative.
<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="800">
## Start in 60 seconds
<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>
```bash
git clone https://github.com/U-C4N/Autocad-MCP.git
cd Autocad-MCP
python -m venv .venv
```
</div>
Activate the environment and start the stdio server:
> **v1.6 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4992 collected tests.
> 247 is the **registered** count; a default install advertises 242 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
```powershell
# Windows
.venv\Scripts\Activate.ps1
pip install -e .
python server.py
```
## What's new in 1.6
```bash
# macOS / Linux
source .venv/bin/activate
pip install -e .
python server.py
```
Five tracks of one [roadmap](https://github.com/U-C4N/Autocad-MCP/blob/main/docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md), 154 → 247 registered tools. Each track has its own design spec, correctness checks that can fail, and a live AutoCAD 2026 run of its COM paths recorded in the [changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md).
That starts the headless-capable server with automatic backend selection. For
live AutoCAD control on Windows:
```powershell
pip install -e ".[com]"
$env:AUTOCAD_MCP_BACKEND = "com"
python server.py
```
No AutoCAD installation is needed for the ezdxf backend:
```powershell
$env:AUTOCAD_MCP_BACKEND = "ezdxf"
python server.py
```
## Evidence, not adjectives
![Source-reviewed AutoCAD MCP capability benchmark](docs/assets/autocad-mcp-benchmark.svg)
The graphic is generated with Matplotlib from
[`benchmarks/source_review.json`](benchmarks/source_review.json). A fixed,
public 100-point rubric covers functional CAD breadth, correctness and delivery,
backend reach, engineering production, tests, and security.
| Project | Score | Evidence |
|---|---:|:---:|
| **[U-C4N/Autocad-MCP](https://github.com/U-C4N/Autocad-MCP)** | **93** | A |
| [varavista/autocad-mcp](https://github.com/varavista/autocad-mcp) | 84 | A |
| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | 83 | A |
| [AnCode666/multiCAD-mcp](https://github.com/AnCode666/multiCAD-mcp) | 73 | A |
| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | 70 | A |
| [NCO-1986/AutoCAD_mcp](https://github.com/NCO-1986/AutoCAD_mcp) | 68 | B |
This is a dated source review—not a claim that every project completed a shared
live AutoCAD run. Evidence grade **A** means source plus executable local
evidence was reviewed; **B** means source and tool surface were reviewed but the
live AutoCAD path was not fully executed.
Reproduce the chart and run the ten-task reference adapter:
```bash
pip install -e ".[pdf]"
python -m benchmarks.render_chart
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json
```
The v1.3 ezdxf reference run records **10/10 tasks (100.0)**. It is a
self-check, not a competitor ranking.
Cross-project runtime scores will be published only after equivalent adapters
execute the same task contract. The complete rubric, caveats, version A/B suite,
and reproduction commands live in [`benchmarks/`](benchmarks/README.md).
## Why this architecture holds up
### One contract, two engines
The COM backend controls a live AutoCAD session on Windows through a
single-threaded STA executor. The ezdxf backend creates, edits, renders, and
validates DXF drawings without AutoCAD. Both return the same typed entities,
layers, blocks, drawing metadata, and capability format.
### Geometry agents do not have to guess
Tools such as `point_from_snap`, `point_intersection`, and `point_tangent`
resolve exact construction coordinates from drawing entities. Handle-preserving
edit tools and smart selection keep agents attached to real geometry instead of
reconstructing state from prose.
### Quality is a loop
`drawing_preflight` checks whether an engineering request is ready to draw.
`drawing_critique` reports standards and geometry issues. `drawing_refine`
repairs the supported subset inside isolated transactions and rolls back rounds
that reduce quality. `drawing_finalize` combines structural validation,
standards critique, a scalar score, and invalidity ratio.
### Delivery carries evidence
`drawing_deliver` creates the requested artifacts, computes SHA-256 hashes,
reopens the canonical DXF, compares entity/layer/type counts and bounds, and
writes `manifest.json` plus `validation.json`. A successful tool call is not
treated as proof that the drawing survived export.
## Backend capabilities
| Capability | COM backend | ezdxf backend |
|---|:---:|:---:|
| Live AutoCAD document control | ✓ | — |
| Headless DXF creation and editing | — | ✓ |
| Cross-platform execution | — | ✓ |
| Deterministic entity/query contract | ✓ | ✓ |
| Transactions and rollback | ✓ | ✓ |
| TABLE and MLEADER semantics | Native | Portable composite |
| Screenshots | AutoCAD window | Matplotlib render |
| Raw AutoCAD commands / AutoLISP | Opt-in | — |
Use `system_capabilities` at runtime instead of assuming backend support. The
machine-readable response distinguishes `native`, `composite`, `emulated`, and
`unsupported` behavior where the engines differ.
## A production drawing workflow
The server exposes low-level CAD primitives, but the intended engineering path
uses deterministic orchestration and validation:
```text
drawing_preflight → drawing_plan → drawing_apply_iso_layers
→ deterministic create/edit tools → dimension_auto
→ drawing_critique → drawing_refine
→ drawing_finalize → drawing_deliver
```
Representative capability groups include:
| Area | Examples |
|---|---|
| Drawing lifecycle | create, open, save, export, audit, purge, undo/redo |
| Geometry | lines, arcs, polylines, hatches, splines, trim/extend/fillet/chamfer |
| Annotation | dimensions, ISO 129 tolerances, TABLE, MLEADER, text editing |
| Engineering | gears, keyed bores, title blocks, ISO 1101 GD&T and datums |
| Query | entity/layer/block inspection, selection, bounds, distance and area |
| Quality | preflight, critique, score, transactional refinement and finalization |
| Delivery | PDF/PNG/DXF output, hashes, manifests and reopen-parity checks |
The tool surface evolves. Ask `system_about` for the live grouped inventory
instead of building integrations around a copied list from this README.
## Installation profiles
| Profile | Command | Use case |
| Track | What it adds | Pack |
|---|---|---|
| Core | `pip install -e .` | Headless ezdxf workflows |
| COM | `pip install -e ".[com]"` | Live AutoCAD on Windows |
| PDF | `pip install -e ".[pdf]"` | PDF export and headless screenshots |
| Full | `pip install -e ".[full]"` | Both backends and all render features |
| **P&ID — drafter and reader** | 43 ISO 10628-2 / ISA-5.1 symbols as real blocks with ports and `TAG` attributes, port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reading any P&ID back with a confidence per node, instrument index, line list, equipment list, `pid_from_spec` for a whole sheet in one transaction | `pid` |
| **The AutoCAD environment** | ISO-25 / ANSI dimension styles, text and leader styles, `drawing_apply_standard`, ISO 216 / ANSI page setups checked against the plotted PDF's own `/MediaBox`, five bundled templates, several open documents headlessly, portable layer states, named views, UCS, a 90-entry system-variable catalogue; on a live seat, preferences, launch and operator prompts | `core`, `settings` |
| **Mechanical parts and the sheet** | one part model and one view engine (front, side, top, section and detail views with hidden lines, cut faces and ISO 129 dimensions), ISO fasteners and bearings as blocks with attributes, ISO 21920-1 surface texture, ISO 2553 welds, ISO 5457 frames A4–A0, ISO 7200 title blocks, an ISO 7573 parts list read off the drawing with linked ISO 6433 balloons, xrefs, images, real `.dwg` | `mech`, `core` |
| **Architecture** | walls with hosted doors and windows: one engine resolves L / T / X junctions and cuts the openings. Room areas are measured from the faces the walls enclose, and rooms are read back from any plan. Door / window / room schedules are real tables; labels in English or Turkish | `arch` |
| **Understanding and takeoffs** | `drawing_understand` reads a drawing somebody else made in one call. Also `drawing_scale_check`, `drawing_diff` and `drawing_topology_check`. `pipe_takeoff` / `cable_takeoff` take the topology from the P&ID and the lengths from the layout into a Turkish, English or Russian workbook | `core`, `plant` |
Requirements:
`TOOL_PACKS` keeps a client from paying for the tracks it does not use ([idle cost below](#why-this-exists)).
- Python 3.11+
- [FastMCP](https://github.com/jlowin/fastmcp) 3.0+
- [ezdxf](https://github.com/mozman/ezdxf) 1.3+
- AutoCAD, `pywin32`, and Pillow only for live COM control
- Matplotlib only for PDF/headless rendering and benchmark visualization
<div align="center">
## Connect an MCP client
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/mech-showcase.png" alt="ISO A3 flange-coupling sheet: a hub with a full section and chain dimensions, a flange with its bolt-circle end view, two ISO 4014 bolts with ISO 4032 nuts, a parts list with linked balloons, an ISO 5457 frame with zones and an ISO 7200 title block" width="800">
### Claude Desktop
<sub><b>A mechanical sheet, gated.</b> The <code>mech_assembly</code> benchmark sheet: a hub drawn from its part model with a full section and chain dimensions, a flange with its bolt-circle end view, two ISO 4014 M12×60 bolts and ISO 4032 nuts inserted as blocks, the ISO 7573 parts list <i>read off the drawing</i>, balloons linked to its rows, an ISO 5457 frame and an ISO 7200 title block. <code>drawing_critique(focus=None)</code> returns <b>0 issues</b> and the finalize score is <b>100</b>. Rebuild it with <code>python scripts/render_readme_mech.py</code>.</sub>
Add a server entry to `claude_desktop_config.json` and use the absolute path to
your checkout:
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/arch-showcase.png" alt="Two-room floor plan: hatched brick walls with an aerated-concrete partition, two doors, two windows, a straight stair, room labels with measured areas, exterior dimension chains, and door, window and room schedules" width="800">
<sub><b>A floor plan whose areas nobody typed.</b> <code>arch_plan_from_spec</code>'s example, drawn in one transaction: a 250 mm brick ring split by a 100 mm AAC wall, two doors, two windows, a straight stair and exterior dimension chains at 1:50. The rooms measure 27.74 and 21.99 m², read from the faces the walls enclose, and the door, window and room schedules are read off the drawing. <code>drawing_critique(focus=None)</code> returns <b>0 issues</b>. Rebuild it with <code>python scripts/render_readme_arch.py</code>.</sub>
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/pid-showcase.png" alt="P&ID sheet: a centrifugal pump, a diaphragm-actuated globe control valve, a vertical vessel with two nozzles, a flow controller bubble and an off-page connector, joined by numbered process lines and an electric signal line" width="800">
<sub><b>The same server as a P&ID drafter.</b> <code>pid_from_spec</code> placed five catalogue symbols as real blocks with <code>TAG</code> attributes and named ports and routed four port-to-port lines with ISA-5.1 classes and line numbers, all in one transaction. Then <code>pid_graph</code> read the sheet back: 5 nodes, 4 edges, 0 dangling ends, <code>confidence_min</code> 1.0. Rebuild it with <code>python scripts/render_readme_pid.py</code>.</sub>
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/pid-catalog.png" alt="Contact sheet of the 43 P&ID catalogue symbols: valve bodies with actuators, pumps and compressors, heat exchangers, vessels, instrument bubbles, line markers and connectors" width="800">
<sub><b>The catalogue, authored in code.</b> 43 ISO 10628-2 / ISA-5.1 symbols: 11 valve bodies × 6 actuators, 6 rotating machines, 4 heat exchangers, 6 parametric vessels, 7 inline items, the instrument bubble in 4 types × 5 locations, 5 line markers and 3 connectors. Every one was placed through <code>pid_symbol_insert</code>'s own path. Rebuild it with <code>python scripts/render_pid_catalog.py</code>.</sub>
</div>
## Why this exists
**A big MCP server is expensive to be connected to.** The full catalog costs a client **83,921 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
**A drafter searches for `FILLET`, not `entity_fillet`.** Before the corpus, those command names appeared in no tool name or description. That was `df = 0` against a stock index: the tools were not ranked badly, they were *absent*. The fix was data: an authored corpus of **206 AutoCAD command names and 1439 synonym phrases** covering all 247 tools. A test refuses to let a tool exist without one.
| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
| `TOOL_PROFILE=full` (default) | 242 | 83,921 tokens |
| `TOOL_PACKS=core,settings` (full profile) | 205 | 63,890 tokens |
| `TOOL_PACKS=core,mech` (full profile) | 197 | 64,836 tokens |
| `TOOL_PACKS=core,arch` (full profile) | 195 | 63,549 tokens |
| `TOOL_PACKS=core,pid` (full profile) | 192 | 60,844 tokens |
| `TOOL_PACKS=core,plant` (full profile) | 185 | 59,290 tokens |
| `TOOL_PACKS=core` (full profile) | 183 | 57,122 tokens |
| `TOOL_PROFILE=lean` | 65 | 22,661 tokens |
| `DISCOVERY_MODE=search` | 2 | **356 tokens** |
> [!NOTE]
> These are offline ratio estimates, not tokenizer counts, and they are the *uncached* cost: prompt caching amortises the idle term. `benchmarks/token_suite.py --tokenizer anthropic` counts for real.
## Install
```bash
pip install autocad-mcp-pro # or: uvx autocad-mcp-pro
autocad-mcp # stdio MCP server, backend auto-selected
```
```bash
AUTOCAD_MCP_BACKEND=ezdxf autocad-mcp # portable DXF engine, no AutoCAD needed
AUTOCAD_MCP_BACKEND=com autocad-mcp # live AutoCAD (needs the [com] extra)
```
| Extra | Pulls in | For |
|---|---|---|
| *(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, any platform |
| `[office]` | `openpyxl` | XLSX workbooks (takeoffs, `data_extract`); CSV is always written |
| `[full]` | everything above | Development and CI |
> [!NOTE]
> The bare install **cannot draw pixels** — `ezdxf.addons.drawing` imports Pillow unconditionally, so every render path needs it. Add `[pdf]` for images on Linux or macOS.
<details>
<summary><b>Wire it to an MCP client</b></summary>
```json
{
"mcpServers": {
"autocad": {
"command": "python",
"args": ["C:\\path\\to\\Autocad-MCP\\server.py"],
"command": "autocad-mcp",
"env": {
"AUTOCAD_MCP_BACKEND": "auto",
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD"
"ALLOWED_PATHS": "C:\\Users\\you\\Documents\\AutoCAD",
"TOOL_PROFILE": "full",
"TOOL_PACKS": "all",
"DISCOVERY_MODE": "off"
}
}
}
}
```
Cursor, Cline, Continue, Goose, and other stdio-capable MCP hosts use the same
`command` plus `args` shape. The model vendor is not part of the server
contract.
It works with Claude Desktop, Cursor, or any stdio MCP host. For HTTP, run `autocad-mcp --transport http --port 8000`. It binds loopback only, unless remote HTTP is explicitly enabled **and** a bearer token is set.
### Local HTTP
</details>
```bash
fastmcp run server.py:mcp --transport http --port 8000
```
## What you get
Loopback is the default. A non-loopback bind is refused unless remote HTTP is
explicitly enabled and bearer-token authentication is configured.
| Area | What it does |
|---|---|
| Drawing lifecycle | create, open, save, export DXF/PDF, audit *(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"`, interference shafts r/s/t/u included), TABLE, MLEADER, GD&T frames and datums (ISO 1101) |
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block |
| Mechanical parts | one part model (segments or outline + typed features) and one view engine: front / side / top / section / detail with hidden lines, ISO 128-50 cut faces and ISO 129 dimensions. ISO 4014/4017/4032/7089/4762 fasteners and ISO 15 bearings insert as real blocks with attributes. DIN 471/472, DIN 509, DIN 332 and ISO 3601-2 features have their drawing code, but *their tables ship empty in 1.6* (see [limits](#what-this-release-is-bad-at)). Also ISO 21920-1 surface texture, ISO 2553 welds and ISO 128-40 section lines |
| Sheet & delivery | ISO 5457 frames A4-A0 with zones and trim marks, ISO 7200 title blocks for every size, revision blocks with clouds, ISO 7573 parts lists with ISO 6433 balloons linked by XDATA, CSV/XLSX extraction, xrefs and images on both engines, real `.dwg` on a live seat |
| Architecture | one plan model (walls with hosted doors and windows) and one engine that resolves L / T / X junctions, cuts openings and draws poché by material. Room areas are measured from the faces the walls enclose, never typed, and rooms are read back from any plan with a confidence. Door / window / room schedules are real TABLEs. Also a furniture and sanitary catalogue at nominal sizes, a structural grid, north arrow, section / level / elevation marks, exterior dimension chains, and English or Turkish labels (`lang="tr"`, decimal comma) |
| Understanding & takeoffs | `drawing_understand` reads a drawing somebody else made in one call: declared vs inferred units, robust extents and the outlier behind them, plan copies, each layer's discipline and service in five languages, tags, rooms. `drawing_scale_check` says whether a P&ID is to scale against its layout. `pipe_takeoff` / `cable_takeoff` take the topology from the P&ID and the lengths from the layout (rectilinear MST, statuses A/B/C, power never invented) into a Turkish / English / Russian workbook. `drawing_diff` compares revisions; `drawing_topology_check` finds dangling ends, near misses and crossings |
| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
| Styles & standards | ISO-25 / ANSI dimension styles, ISOCP / ROMANS text styles, ISO / ANSI leader styles from authored presets; `drawing_apply_standard("iso")` sets all of it plus units and the `mech` layers in one call; `changed` reports only what moved |
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 242-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
| Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `page_setup_apply` on both engines, and `batch_plot` with every sheet size read back from its PDF's `/MediaBox`. Five bundled templates, built by the server's own tools and pinned reproducible: `drawing_new(template="iso_a3_mech")`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`. Save any drawing as a template (`.dwt` on live AutoCAD; `dwt_write` refused headlessly) |
| Environment | several open documents headlessly, portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager), named views, UCS *(tool coordinates stay WCS)*, a system-variable catalog with ranges, document properties. On a live seat: launch/attach, preferences, and an operator prompt / pick / select, where ESC is `cancelled`, not an error |
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
| Hatch depth | gradients, in-place edits, typed edge boundaries *(headless)*, island styles |
| Annotation objects | WIPEOUT, REVCLOUD, MTEXT background masks *(headless)*, text find/replace |
| 3D solids | `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); 18 domain critique focuses (P&ID 6, mechanical 6, architecture 3, topology 3), each silent on a drawing outside its domain |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>247 tools in 28 groups. <code>TOOL_PACKS=core</code> hides these from a client that needs none of them: the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools, the 12 architectural tools and the two plant takeoffs. <code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones, and <code>TOOL_PACKS=core,plant</code> the takeoffs. There are also 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.**
- Every coordinate in and out of a tool is WCS on both engines. The one exception is TEXT `rotation`, which stays in the entity frame: a mirrored TEXT is mirror-imaged, and no scalar angle expresses that.
- Never read vertices back and shoelace them. That silently loses **28.2%** of the area on a semicircular edge. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
## The two engines
One contract across 23 modules in `backends/contracts/`. `@capability(key, reason=…)` supplies a default that *refuses*, and a test holds both backends' capability key sets equal. There are **41 capability keys**. Read `system_capabilities` at runtime rather than trusting the table: several depend on what the machine has installed (matplotlib, openpyxl, the ODA File Converter).
| Capability | COM (live AutoCAD) | ezdxf (headless) |
|---|:---:|:---:|
| Live document control | ✅ | — |
| Cross-platform, no AutoCAD | — | ✅ |
| Transactions and rollback | ✅ | ✅ |
| Paper-space layouts + viewports | ✅ | ✅ |
| Viewport model-content rendering | ✅ | ✅ ᵐ *(no borders)* |
| 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* |
| 3D solids | ✅ *with `ENABLE_3D=true`* | — |
| DWG write | ✅ `SaveAs` | via the ODA File Converter, if installed |
| `.dwt` templates | ✅ | DXF only — `dwt_write` refuses |
| Reading a foreign drawing (`drawing_understand`, takeoffs) | ✅ one `Document.Export` snapshot; the document is never renamed | ✅ |
| XLSX workbooks | ✅ ᵒ | ✅ ᵒ |
| Preferences · launch · operator prompts | ✅ | — *refused by name* |
| WIPEOUT · MTEXT background colour | — *verified absent, AutoCAD 2026* | ✅ |
| REVCLOUD · BPOLY · typed hatch edges | — *no ActiveX member* | ✅ |
| CHSPACE | — *unverified on a live seat* | ✅ ᶜ |
| Undo history | ✅ | opt-in — `EZDXF_UNDO_DEPTH` |
| TABLE and MLEADER | Native | Portable composite |
| Screenshots and PDF | Window capture | Matplotlib ᵐ |
<sub>ᵐ Needs matplotlib (<code>[pdf]</code>/<code>[full]</code>). Without it, <code>png</code>, <code>pdf</code>, <code>viewport_render</code> and <code>handle_overlay</code> report unsupported headlessly.<br>
ᵒ Needs openpyxl (<code>[office]</code>/<code>[full]</code>). Without it, XLSX is refused with <code>xlsx_write</code> and CSV is still written.<br>
ᶜ Headless CHSPACE has four restrictions, named in its capability reason: top-view untwisted viewports only; dimensions refused unless frozen; ACIS, proxy and table entities refused; viewport clipping reported rather than applied.</sub>
> [!IMPORTANT]
> Every COM path added in v1.6 was executed against a **live AutoCAD 2026**, one smoke script per track (`scripts/smoke_*_com.py`), each run recorded in the changelog. Those runs found defects no headless test could. ActiveX refuses `entity.Layer` on a missing layer, where ezdxf quietly creates it, so `mech_part_draw` died after drawing every view. ActiveX calls a block reference `BLOCKREFERENCE`, not `INSERT`, so the parts list read nothing. And once pywin32's type-library cache existed, every listed entity arrived as a bare `IAcadEntity` with no line endpoints, so the room reader saw no walls and `arch_plan_from_spec` refused its first room label.
## Evidence
Everything below is produced by scripts in [`benchmarks/`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/) and published as JSON under [`benchmarks/results/published/`](https://github.com/U-C4N/Autocad-MCP/tree/main/benchmarks/results/published).
### Against the other AutoCAD MCP servers
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-benchmark.svg" alt="Source-reviewed capability leaderboard: U-C4N/Autocad-MCP 93, varavista 84, beiming183-cloud 83, best-cad-mcp 74, multiCAD-mcp 73, puran-water 70, NCO-1986 68, daobataotie 35, Easy-MCP-AutoCad 30" width="800">
A fixed 100-point rubric applied to nine public AutoCAD MCP servers ([`source_review.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/source_review.json)). Stars and raw tool counts score nothing. 1.6.0 is scored **per category, with the evidence for every point withheld**:
| Category | Weight | 1.6.0 | Points withheld for |
|---|---:|---:|---|
| Functional CAD coverage | 25 | 24 | no electrical schematics, image-to-CAD tracing, Plant 3D, obstacle-avoiding routing or headless REGION / 3DSOLID |
| Correctness and delivery | 20 | 19 | a headless DIMENSION does not re-render; a takeoff on a real P&ID still needs the engineer's scope decisions |
| Backends and platforms | 15 | 14 | BricsCAD, ZWCAD and GstarCAD unverified |
| Engineering production | 15 | 14 | four DIN / ISO feature tables ship empty |
| Tests and maintenance | 15 | 14 | headless entity creation slower than v1.4.0 |
| Security and operations | 10 | 8 | `ALLOWED_PATHS` unscoped by default; command filtering is a denylist |
| **Total** | **100** | **93** | |
> [!NOTE]
> Read this as a documented self-assessment, not an independent review: this repository applies the rubric to itself and to the others. The competitor rows are their July 2026 snapshots and were not re-reviewed for this release, and the best of them, varavista/autocad-mcp, scored 84. 1.4.0 was given one overall 95. This review is stricter and scores each category apart.
A second lane runs tasks instead of reading source. Every task in the fixed-task matrix can fail, and its artifacts are verified by re-opening the DXF, never by trusting a response. The reference adapter passes **21 / 21** of matrix v7. The two competitors with adapters, pinned at their July 2026 commits, passed 5 / 10 and 4 / 10 of the ten v2 tasks they were run on. The eleven tasks added since then were never put to them, so they show as *not run*, not as zero:
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-taskmatrix.svg" alt="Task matrix: autocad-mcp-pro passes all 21 tasks; beiming183 and puran-water were run on the first ten, with the later eleven not run" width="800">
### Correctness — every release re-proves itself
48 deterministic headless checks against the previous tag and the current tree. Each runs in its own subprocess, so a hard crash counts as a miss rather than killing the run.
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---|---:|---:|---:|---:|
| v1.5.1 *(baseline)* | 26 / 48 | 54.2 % | — | — |
| **v1.6.0** *(this release)* | **48 / 48** | **100 %** | 22 | **0** |
The 26 checks v1.5.1 was released on all still pass. The twenty-two fixed rows are new capability (`miss → pass`), and each one pins a number computed by hand, not read back from the code that drew it:
<details>
<summary><b>The twenty-two checks 1.6 added</b></summary>
| Track | Check | Verified against |
|---|---|---|
| Understanding | `scale_check_detects_schematic` | the synthetic P&ID reads **schematic** against its layout; a uniform 2× copy of six tags reads **to scale** with factor 2 |
| | `pipe_takeoff_rmst_exact` | all eight routable runs measure exactly the rectilinear MST of their tags |
| | `cable_takeoff_roundup` | 9, 17, 19 and 10 m after the 20 % allowance; the heater's unstated power stays empty |
| | `diff_detects_known_edits` | a move, a text change, an attribute change, an add and a delete, and nothing else |
| | `topology_known_defects` | a 5 mm near miss, one interior crossing, and a clean T that is neither |
| Architecture | `arch_junction_l_t_x` | an L corner mitres at (4100, −100) / (3900, 100), a T stem stops on the near face, an X cuts all four faces; wall areas 1 400 000, 1 490 000 and 2 360 000 mm² |
| | `arch_room_area_net` | a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not the 21.84 m² its axes enclose |
| | `arch_opening_cuts_wall` | both faces are interrupted across a 900 door and two jambs close it |
| | `arch_rooms_detect_foreign` | plain lines on a `WALLS` layer yield 20 and 15 m² at confidence 0.6, and nothing is written |
| Mechanical and sheet | `mech_part_roundtrip` | a part read back out of its own `ACADMCP_MECH` XDATA equals the part drawn |
| | `mech_section_hatch_area` | a 60 × ⌀40 sleeve with a ⌀20 bore cuts 1200 mm², measured out of the drawing by `analysis_measure_entity` |
| | `mech_iso286_on_dimension` | 40 H7 is +0.025 / 0 and survives the layout |
| | `mech_thread_unrepresented_is_caught` | the ISO 6410 focus fires on a thread drawn as a plain circle. A gate that never fires is not a gate |
| | `std_part_iso4014_m12` | s = 18, k = 7.5, read by value |
| | `sheet_frame_iso5457_a3` | the frame runs 20,10 to 410,287 inside the 420 × 297 sheet |
| | `bom_balloon_link` | two identical bolts are one row of quantity 2, and its balloon carries the row's item number |
| Environment | `settings_dimstyle_iso25_values` | the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers |
| | `settings_layer_state_roundtrip` | save → change → restore puts the layer table back, and the state survives save/reopen |
| | `settings_pdf_mediabox_a3` | the plotted PDF's own `/MediaBox` reads 420 × 297 |
| P&ID | `pid_block_define_attdef_roundtrip`, `pid_tag_parse_fic`, `pid_graph_edge_count` | a block with ATTDEFs defined and read back, an ISA-5.1 tag parsed, a drawn P&ID's edge count |
</details>
### The task matrix — tasks that can fail
An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added in 1.5 because they *can* fail, and three of them did while being written. 1.6 adds six more:
- one the P&ID reader can fail on its own;
- one where the PDF file, not the setter, is the witness;
- one where a whole mechanical sheet has to come out clean;
- one where a floor plan's rooms are read back off the drawing;
- two where a foreign plant's P&ID and layout are read: the takeoffs against lengths computed in advance, and the one-call report against the defects planted in it.
| Task | Verified against |
|---|---|
| `tool_discovery` | six AutoCAD command names, each ranking #1 |
| `token_budget` | 83,921 → 356 tokens, against a 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 |
| `pid_roundtrip` | the example sheet drawn by `pid_from_spec`, read back by `pid_graph`, which never sees the spec: 5 nodes, 4 edges, 0 dangling, `confidence_min` 1.0, `FIC-101` wired to `FCV-101` |
| `page_setup_truth` | ANSI B, then ISO A3 landscape, applied to Layout1. Each is plotted through `batch_plot` and read back from its PDF's `/MediaBox`: 432 × 279, then 420 × 297 mm, not the setter's return value. A fresh layout is already A3, so the B sheet is what a no-op setter cannot fake |
| `mech_assembly` | a flange-coupling A3 sheet: a hub with a full section, a flange with its bolt-circle end view, two ISO 4014 bolts with ISO 4032 nuts, an ISO 7573 parts list read off the drawing, linked balloons, an ISO 5457 frame and an ISO 7200 title block. Gated on `drawing_critique(focus=None)` returning **zero** issues and a finalize score of at least **90**; a test drops one balloon and watches the gate fail |
| `arch_roundtrip` | a two-room plan (an entrance door, an interior door, two windows, a stair) drawn by `arch_plan_from_spec` and read back by `arch_rooms_detect`, which never sees the spec. Each room must be within 0.1 % of its label and of the net floor computed by hand (4825 × 5750 and 3825 × 5750 mm), and the schedules must list D1, D2, W1, W2. Gated like `mech_assembly`; a test removes one room label and watches the gate fail |
| `takeoff_roundtrip` | the synthetic plant pair: a P&ID stretched 1.3× in one room and rearranged in the other, Russian supply / return words, a return drawn on the supply layer, a reducer, a segment drawn twice. The scale check must call it **schematic** (21.8 % of 55 pairs within ±10 %). `pipe_takeoff` must give every routable run's layout length equal to the rectilinear MST of its tags, sizes and services as drawn, and the run to a tag the layout lacks status C. `cable_takeoff` must give 9 / 17 / 19 / 10 m after the 20 % allowance and leave the heater's power empty. A test removes the wiring callouts and watches the cable half fail by name |
| `understand_foreign` | the pair's layout (inches declared over millimetres, a stray line 5,000 km out, two plan copies) comes back as millimetres with an `INSUNITS` warning, the stray line's handle and two clusters. The P&ID's `P_product piping` / `P_cipsupplyline` / `P_cipreturnline` / `P_ijswater` / `E_power` classify as product / CIP supply / CIP return / ice water / electrical. A test makes the layout declare millimetres and watches the units gate fail |
### Headless performance
<img src="https://raw.githubusercontent.com/U-C4N/Autocad-MCP/main/docs/assets/autocad-mcp-perf.svg" alt="Performance across four headless workloads" width="800">
The workloads call the same backend methods the MCP tools call, so server-side overhead is included. Numbers move with hardware; the report records the machine fingerprint. **Read the next section before quoting them.** This release is slower than v1.4.0 at creating entities.
## What this release is bad at
A page that only lists strengths is a page that has not been measured.
**Headless entity creation is slower than v1.4.0, and 1.6 did not win it back.** These are medians of five alternating runs on one machine, with one interpreter (CPython 3.11.15) and one ezdxf, v1.4.0's code against 1.6.0's ([`perf-v1.4.0-vs-v1.6.0.json`](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/perf-v1.4.0-vs-v1.6.0.json)):
| Workload | v1.4.0 | v1.6.0 | v1.6.0, `EZDXF_CALL_TIMEOUT=0` |
|---|---:|---:|---:|
| 2,000 lines, one call each | 244.5 ms | 306.1 ms (1.25×) | 284.9 ms (1.17×) |
| 10,000 lines: build, DXF export, reopen | 1,847.9 ms | 2,553.5 ms (1.38×) | 2,216.4 ms (1.20×) |
| Region query over 10,000 entities | 665.2 ms | 712.2 ms (1.07×) | 783.9 ms (1.18×) |
| Premium quality pass | 150.6 ms | 65.3 ms (**2.3× faster**) | 69.9 ms |
The per-call timeout stops one hung call from wedging a server whose document lock is a single `asyncio.Lock`. It explains only part of the slowdown: switched off, creation is still 1.17× and 1.20× slower. The rest is extra work per call that 1.6 has not removed. A cProfile of the event-loop thread counts 491,071 function calls for the 2,000 creates, against v1.4.0's 319,038. Earlier READMEs said that switching the timeout off returned creation to v1.4.0's numbers. Measured this way, it does not. The region-query row, which moves the wrong way, shows this machine's noise floor (about ±10 %). Roadmap criterion 7 is not met and moves to 1.7.
**Four mechanical feature tables ship empty.** DIN 471/472 retaining-ring grooves, DIN 509 undercuts, DIN 332 centre holes and ISO 3601-2 O-ring grooves have their drawing code and their provenance tests, but no rows. A groove diameter written from memory is a wrong workshop drawing, while a narrow table is merely narrow. So every size is refused by name until rows verified against the standard's own table are transcribed. That is a data edit; each module's docstring says how.
**Takeoffs are estimates, and they say so.**
- A layout length is the rectilinear minimum spanning tree of the tags a run connects, plus a stated vertical allowance, and it is labelled an estimate.
- A P&ID length is used only when `drawing_scale_check` calls the P&ID to scale.
- A power the P&ID does not state stays empty and becomes an open item, and no cable-section table is authored: a section comes only from your `section_rules`.
- Where no callout names a panel, the nearest panel is inferred and flagged.
- Detail copies of equipment are flagged, and excluded only when you ask.
The readers were tuned on the synthetic plant pair and on one real P&ID / layout pair that is not in this repository. Another drafting office's layer and text conventions may need vocabulary.
**No Plant 3D.** The roadmap's track D (read-only queries of a Plant 3D project) was dropped: the maintainer does not use Plant 3D, and a reader tested against nobody's real projects would be a guess. Roadmap criterion 5 is withdrawn, not met.
**No REGION or 2D booleans headlessly.** `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.
**`system_run_command` / `system_run_lisp` are a guardrail, not a security boundary.** The rejection message says so in those words. A 36-verb denylist refuses the obvious cases, but AutoCAD accepts hundreds of commands, and `DANGEROUS_COMMANDS_ENABLED=true` switches the list off entirely.
**Path validation is per-tool, and unscoped until you scope it.** 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`.**
**Non-AutoCAD ProgIDs are unverified.** `CAD_PROGID` changes which COM application the backend attaches to; nothing beyond the connection has been tested against BricsCAD, ZWCAD or GstarCAD.
### Known limitations of the P&ID track
**The router is orthogonal and blind.** `pid_line_draw` picks `auto` from seven orthogonal candidates, by fewest bends then length:
- straight;
- one bend;
- two bends through the midline;
- a stub out of each port joined by three bends.
You can also take `direct`, or pass waypoints. It *counts* the lines it crosses (`crossings` comes back in every result, and `pid_from_spec` sums it), but it does not route around them. Obstacle-avoiding routing is not in 1.6; move the symbol or pass waypoints.
**A foreign drawing is read at the confidence it earns, and no higher.** A block this server did not place classifies at 0.6, with inferred ports, by its `TAG`-style attribute or a keyword in its name. It classifies at 0.3 when a line merely touches it, and a 0.3 guess can neither raise nor suppress a critique finding.
On the live engine, an INSERT turned off a right angle has members ActiveX cannot measure, so it reports an *approximate* box. `pid_graph` takes the tighter wall of that box and the attribute-inclusive box on every side. Even so, a line ending on such a body's true edge can still be reported `dangling`, with the nearest port as the hint. Read `stats.confidence_min` first.
**Same-file only, DXF headlessly.** Off-page connectors link within one drawing; links across files are not resolved. The headless engine reads DXF; a DWG P&ID is read through the live backend. There is no DEXPI/Proteus export and there are no ISA-5.2 binary-logic symbols in this release. Both cuts are recorded in the spec so nobody re-derives them.
### Known limitations of the settings track
**Layer states are ours.** `layer_state_save` writes a portable snapshot into an `ACADMCP_LAYERSTATES` XRECORD. It survives save/reopen on both engines and travels with the DWG/DXF. It is **not** an AutoCAD `LAYERSTATE` and does not appear in the Layer States Manager, which is why `system_capabilities` reports `layer_states` as `mode: "xrecord"`.
**Six of the 22 environment tools need a live seat.** Preferences, launching, the operator prompts and the five summary fields of document properties are refused headlessly with `preferences` / `dwgprops` / `live_application` / `interactive_prompt`. Each is a refusal, not a stub.
A `.dwt` template is the same case (`dwt_write`): a `.dwt` is a DWG container, so headlessly the template is saved as DXF. `mleaderstyle_create` is *not* such a case. ActiveX has no MLeaderStyle collection, but the `ACAD_MLEADERSTYLE` dictionary holds full `IAcadMLeaderStyle` objects, so both engines create leader styles.
**A headless dimension does not re-render.** On the ezdxf engine, `dimstyle_modify` reports `dimensions_using_style` and `rerender_required: true`, and the DIMENSION keeps its rendered block until it is redrawn. AutoCAD regenerates on the next regen.
### Known limitations of the architecture track
**Nominal, not standard.** The furniture and sanitary blocks carry nominal catalogue sizes and say so. The stair tool reports the Blondel relation (2R + G within 600–650 mm) as a design rule, never as a building code. The `arch` layer colours are a repository convention: ISO 13567 fixes the names, not the colours. Labels speak English or Turkish; any other `lang` is refused with the list.
## Configuration
Set variables in your shell, process manager, or MCP client configuration.
[`.env.example`](.env.example) is a reference; the server reads process
environment variables directly.
Nothing loads a `.env` file — export these, or set them in your MCP client's `env` block.
<details>
<summary><b>All 17 environment variables</b></summary>
| Variable | Default | Purpose |
|---|---|---|
| `AUTOCAD_MCP_BACKEND` | `auto` | Select `auto`, `com`, or `ezdxf` |
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to |
| `TOOL_PROFILE` | `full` | `lean` (65 curated tools, 55 with `TOOL_PACKS=core`) or `full` |
| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings,mech,arch,plant` (`core` always on) |
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM) |
| `LOG_LEVEL` | `INFO` | Python logging level |
| `ALLOWED_PATHS` | empty | Comma-separated absolute paths the server may access |
| `ALLOWED_PATHS` | *(empty)* | Comma-separated absolute paths the server may access |
| `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots |
| `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables the check |
| `MAX_LIST_LIMIT` | `5000` | Bound list and selection response sizes |
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout in seconds |
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP and report unsafe mode |
| `EZDXF_UNDO_DEPTH` | `0` | Headless undo history depth; `0` disables it |
| `MAX_DXF_BYTES` | `536870912` | Reject larger DXF input (512 MB); `0` disables |
| `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes |
| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (s); `0` disables |
| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless timeout (s); `0` disables |
| `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP; reported as unsafe mode |
| `ALLOW_REMOTE_HTTP` | `false` | Permit a non-loopback HTTP bind |
| `MCP_AUTH_TOKEN` | empty | Bearer token required for remote HTTP |
| `MCP_AUTH_TOKEN` | *(empty)* | Bearer token required for remote HTTP |
Backend selection:
| Value | Behavior |
|---|---|
| `auto` | Try COM on Windows, then fall back to ezdxf |
| `com` | Require a reachable live AutoCAD instance |
| `ezdxf` | Force portable, headless DXF operation |
</details>
## Architecture
```mermaid
flowchart LR
A[MCP host / AI agent] --> B[FastMCP server]
B --> C[Error · audit · timing · logging middleware]
C --> D[Typed tool contract]
D --> E[COM backend]
D --> F[ezdxf backend]
E --> G[Live AutoCAD]
F --> H[Headless DXF]
E --> I[Engineering quality + delivery]
F --> I
A[MCP host / AI agent] --> B[FastMCP 3 server]
B --> C[Error · audit · timing · logging · capability-refusal middleware]
C --> D[Typed contract · 23 modules · 41 capability keys]
D --> E[COM backend<br/>single-STA thread]
D --> F[ezdxf backend<br/>asyncio.to_thread]
E --> G[Live AutoCAD]
F --> H[Headless DXF]
E --> I[Engineering · critique · scoring · delivery]
F --> I
```
- `server.py` owns the FastMCP surface, lifespan, middleware, resources, and
prompt templates.
- `backends/base.py` defines the shared contract and capability model.
- `backends/com_backend.py` serializes COM calls through one STA thread with
per-call timeouts.
- `backends/ezdxf_backend.py` wraps synchronous DXF work with
`asyncio.to_thread` and snapshot-based transactions.
- `engineering/` contains standards-aware planning, drawing, critique, scoring,
refinement, validation, and delivery.
- `security.py` centralizes path validation and command/LISP sanitization.
<details>
<summary><b>The drawing at the top of this page, as tool calls</b></summary>
## Security model
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 hero sheet:
The server treats tool input as untrusted:
```text
drawing_new() ISO linetypes + layers bootstrapped
drawing_apply_iso_layers("mech") ISO 128 lineweights per layer
drawing_settings({units: "mm", linear_precision: 2})
- Every file path passes through traversal and allowed-root validation.
- Dangerous AutoCAD commands and AutoLISP channels are blocked by default.
- Remote HTTP requires explicit opt-in and a bearer token.
- DXF size, query-result size, undo history, and COM call duration are bounded.
- Audit middleware records tool timing and failures.
- `system_status` and `system_about` expose `unsafe_mode` when dangerous
operations are enabled.
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)
`DANGEROUS_COMMANDS_ENABLED=true` is a trust decision, not an installation
shortcut. Keep it disabled for agents or clients you do not fully control.
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", ...)
Please avoid filing public issues for vulnerabilities. Contact the maintainer
through [GitHub](https://github.com/U-C4N) first.
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, `drawing_refine` after the critique, and `layout_create` + `viewport_create` + `drawing_deliver` at the end.
</details>
## Development
```bash
pip install -e ".[full]"
pip install pytest pytest-asyncio pytest-cov ruff build
python -m pytest
python -m ruff check .
python -m ruff format --check .
python -m build
uv sync --locked --all-extras
uv run pytest
uv run ruff check . && uv run ruff format --check .
```
Focused benchmark checks:
`uv.lock` pins the whole transitive graph, so this reproduces CI exactly. CI runs Linux (3.11 / 3.12) and Windows (mocked COM), plus package, Docker and MCP-registry jobs. The release gate runs the same three test lanes before anything reaches PyPI. Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z`.
```bash
python -m pytest tests/test_benchmark_chart.py tests/test_benchmark_v2.py -q
python -m benchmarks.render_chart
```
## Roadmap (1.7)
Repository map:
- **Speed.** Win back the headless creation throughput that 1.6 lost against v1.4.0 (criterion 7): arm the per-call timeout only where a call can block, and remove the per-call work that came with it.
- **IEC 60617 electrical schematics.** They reuse the P&ID track's symbol, port and graph machinery.
- **Transcribed rows** for DIN 471/472, DIN 509, DIN 332 and ISO 3601-2, each verified against the standard's own table.
- **HATCH boundary geometry in `entity_get`.** 1.5 and 1.6 measure a hatch's filled area but do not hand back its loops.
- **`ezdxf.recover` as a fallback** on `drawing_open`.
- **An allowlist of permitted AutoLISP heads**, replacing the denylist. Enumerating dangerous symbols does not terminate.
```text
server.py FastMCP surface and orchestration
config.py Environment-driven settings
security.py Path, command, and AutoLISP guards
version.py Canonical package version
backends/ COM and ezdxf implementations
engineering/ Standards, critique, refinement, delivery
benchmarks/ Reproducible data, runners, and reports
tests/ Headless, mocked-COM, contract, and security tests
```
Features ship when their contracts and limitations are testable — not when they make a longer checklist.
## Roadmap
## Star History
The next priorities are capability-aware tool discovery, paper-space/layout
depth, ISO 286 fits, Windows CI coverage, opt-in COM 3D solids, and real
cross-project runtime adapters. Features are published when their contracts and
limitations are testable—not when they make a longer checklist.
## Contributing
Pull requests are welcome, especially for reproducible competitor adapters,
mocked/live COM coverage, engineering standards, and backend parity. For a
large change, open an issue first so the tool contract and evidence plan can be
agreed before implementation.
[![Star History Chart](https://api.star-history.com/svg?repos=U-C4N/U-Pool,U-C4N/Autocad-MCP&type=Date&legend=top-left)](https://www.star-history.com/#U-C4N/U-Pool&U-C4N/Autocad-MCP&Date&legend=top-left)
## 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)
<!-- MCP registry ownership marker; must equal the "name" in server.json. -->
mcp-name: io.github.u-c4n/autocad-mcp
+1
View File
@@ -1,4 +1,5 @@
"""AutoCAD MCP Pro – backend package."""
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
from .com_backend import ComBackend
from .ezdxf_backend import EzdxfBackend
+83 -1470
View File
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
# backends/block_specs.py
"""Typed primitive specs for ``block_define``, validated before any write.
Both engines consume the same normalised specs, so a malformed request is
refused identically on COM and ezdxf and nothing is written on either. Every
error names the offending entry (``entities[i]`` / ``attdefs[i]``) and key.
"""
from __future__ import annotations
import math
import re
from security import illegal_symbol_name_chars
ALLOWED_TYPES = ("line", "circle", "arc", "polyline", "text", "solid")
TEXT_ALIGNMENTS = ("left", "center", "right", "middle_center")
#: Checked with ``fullmatch`` — ``$`` alone would accept a trailing newline, which
#: ezdxf then strips on write, turning two "distinct" tags into one duplicate.
ATTDEF_TAG_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,30}$")
_REQUIRED = {
"line": ("x1", "y1", "x2", "y2"),
"circle": ("cx", "cy", "r"),
"arc": ("cx", "cy", "r", "start_deg", "end_deg"),
"polyline": ("points",),
"text": ("text", "x", "y", "height"),
"solid": ("points",),
}
_POSITIVE = {"r", "height"}
def _scalar(value, where: str, *, positive: bool = False) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{where} must be a number, got {value!r}")
try:
number = float(value)
except OverflowError as exc: # a 400-digit JSON integer is an int, not a float
raise TypeError(f"{where} must be finite") from exc
if not math.isfinite(number):
raise TypeError(f"{where} must be finite")
if positive and number <= 0:
raise TypeError(f"{where} must be > 0, got {number}")
return number
def _number(spec: dict, key: str, where: str) -> float:
return _scalar(spec.get(key), f"{where}: {key!r}", positive=key in _POSITIVE)
def _points(
spec: dict, where: str, *, minimum: int, maximum: int | None = None
) -> list[tuple[float, float]]:
raw = spec.get("points")
if not isinstance(raw, (list, tuple)) or len(raw) < minimum:
raise TypeError(f"{where}: 'points' must be a list of at least {minimum} [x, y] pairs")
if maximum is not None and len(raw) > maximum:
raise TypeError(f"{where}: 'points' must have at most {maximum} pairs")
out: list[tuple[float, float]] = []
for i, pt in enumerate(raw):
if not isinstance(pt, (list, tuple)) or len(pt) != 2:
raise TypeError(f"{where}: points[{i}] must be an [x, y] pair")
out.append(
(
_scalar(pt[0], f"{where}: points[{i}].x"),
_scalar(pt[1], f"{where}: points[{i}].y"),
)
)
return out
def _align(spec: dict, where: str) -> str:
align = spec.get("align", "left")
if align not in TEXT_ALIGNMENTS:
raise TypeError(f"{where}: 'align' must be one of {TEXT_ALIGNMENTS}, got {align!r}")
return align
def _layer(spec: dict, where: str) -> str:
layer = spec.get("layer", "0")
if not isinstance(layer, str) or not layer.strip():
raise TypeError(f"{where}: 'layer' must be a non-empty string")
layer = layer.strip()
# Gate the DXF name rule here rather than letting ezdxf raise DXFValueError
# on `add_*(dxfattribs={"layer": ...})` mid-loop, after the block exists and
# earlier primitives are already written (and, with overwrite, the old
# definition is already gone).
offenders = illegal_symbol_name_chars(layer)
if offenders:
shown = ", ".join(repr(c) for c in offenders)
raise TypeError(f"{where}: 'layer' is not a legal DXF layer name (contains {shown})")
return layer
def _one_line(value, where: str) -> str:
"""A single-line DXF string (TEXT content, ATTDEF prompt/default).
ezdxf's DXFAttr fixer (``fix_one_line_text``) silently strips line breaks
and a trailing ``^`` on write, logged at DEBUG only, so the declared value
and the value read back would differ; COM writes the raw string, so the two
engines would also diverge. Refuse it here, before any write, using ezdxf's
own ``is_valid_one_line_text`` rule plus the other C0 controls DXF text
cannot carry.
"""
if not isinstance(value, str):
raise TypeError(f"{where} must be a string")
controls = sorted({c for c in value if ord(c) < 32})
if controls:
shown = ", ".join(repr(c) for c in controls)
raise TypeError(
f"{where} must be a single line without control characters (contains {shown})"
)
if value.endswith("^"):
raise TypeError(f"{where} must not end with '^' (DXF strips a trailing caret)")
return value
def validate_entity_specs(entities) -> list[dict]:
"""Normalise ``block_define`` primitive specs; refuse the whole list on the first defect."""
if not isinstance(entities, (list, tuple)) or not entities:
raise ValueError(
"block_define: 'entities' is empty — a block that draws nothing is refused"
)
out: list[dict] = []
for i, spec in enumerate(entities):
where = f"entities[{i}]"
if not isinstance(spec, dict):
raise TypeError(f"{where}: must be an object")
kind = spec.get("type")
if kind not in ALLOWED_TYPES:
raise TypeError(f"{where}: 'type' must be one of {ALLOWED_TYPES}, got {kind!r}")
for key in _REQUIRED[kind]:
if key not in spec:
raise TypeError(f"{where}: {kind} requires {key!r}")
norm: dict = {"type": kind, "layer": _layer(spec, where)}
if kind == "line":
for key in ("x1", "y1", "x2", "y2"):
norm[key] = _number(spec, key, where)
elif kind == "circle":
for key in ("cx", "cy", "r"):
norm[key] = _number(spec, key, where)
elif kind == "arc":
for key in ("cx", "cy", "r", "start_deg", "end_deg"):
norm[key] = _number(spec, key, where)
elif kind == "polyline":
pts = _points(spec, where, minimum=2)
closed = spec.get("closed", False)
if not isinstance(closed, bool):
raise TypeError(f"{where}: 'closed' must be a boolean")
bulges = spec.get("bulges")
if bulges is not None:
if not isinstance(bulges, (list, tuple)) or len(bulges) != len(pts):
raise TypeError(f"{where}: 'bulges' must list one value per point")
bulges = [_scalar(b, f"{where}: bulges[{j}]") for j, b in enumerate(bulges)]
norm.update(points=pts, closed=closed, bulges=list(bulges) if bulges else None)
elif kind == "text":
norm.update(
text=_one_line(spec.get("text"), f"{where}: 'text'"),
x=_number(spec, "x", where),
y=_number(spec, "y", where),
height=_number(spec, "height", where),
rotation_deg=_scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"),
align=_align(spec, where),
)
elif kind == "solid":
norm["points"] = _points(spec, where, minimum=3, maximum=4)
out.append(norm)
return out
def validate_attdef_specs(attdefs) -> list[dict]:
"""Normalise ATTDEF specs: tag pattern, uniqueness, numeric fields, flags."""
if attdefs is None:
return []
if not isinstance(attdefs, (list, tuple)):
raise TypeError("block_define: 'attdefs' must be a list")
out: list[dict] = []
seen: set[str] = set()
for i, spec in enumerate(attdefs):
where = f"attdefs[{i}]"
if not isinstance(spec, dict):
raise TypeError(f"{where}: must be an object")
tag = spec.get("tag")
if not isinstance(tag, str) or not ATTDEF_TAG_RE.fullmatch(tag):
raise TypeError(f"{where}: 'tag' must match {ATTDEF_TAG_RE.pattern}, got {tag!r}")
if tag in seen:
raise TypeError(f"{where}: duplicate tag {tag!r}")
seen.add(tag)
prompt = _one_line(spec.get("prompt", tag), f"{where}: 'prompt'")
default = _one_line(spec.get("default", ""), f"{where}: 'default'")
invisible = spec.get("invisible", False)
if not isinstance(invisible, bool):
raise TypeError(f"{where}: 'invisible' must be a boolean")
out.append(
{
"tag": tag,
"prompt": prompt,
"default": default,
"x": _number(spec, "x", where),
"y": _number(spec, "y", where),
"height": _number(spec, "height", where),
"rotation_deg": _scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"),
"align": _align(spec, where),
"invisible": invisible,
}
)
return out
def validate_base_point(base_x, base_y) -> tuple[float, float]:
"""``block_define``'s base point as finite floats; ``TypeError`` names the key.
Only ``float()`` was applied before, so ``float("nan")`` was written into
the BLOCK record headlessly and would reach AutoCAD as a NaN VARIANT.
"""
return (
_scalar(base_x, "block_define: 'base_x'"),
_scalar(base_y, "block_define: 'base_y'"),
)
def referenced_layers(entities: list[dict]) -> list[str]:
"""The distinct layers ``block_define``'s primitives reference, other than ``0``.
DXF and ActiveX layer tables are case-insensitive, so the set is keyed on
the folded name and keeps the first spelling seen: a request naming
``Alpha`` and ``ALPHA`` wants *one* layer. Deduping case-sensitively let
ezdxf write ``ALPHA`` and then raise ``DXFTableEntryError`` on ``Alpha``
mid-call, and let COM report two layers created for one ``Layers.Add``
that took effect.
"""
seen: dict[str, str] = {}
for spec in entities:
layer = spec["layer"]
if layer != "0" and layer.lower() not in seen:
seen[layer.lower()] = layer
return sorted(seen.values())
def validate_attribute_values(attributes, where: str = "attributes") -> dict[str, str]:
"""``{TAG: value}`` for ``block_insert`` / ``block_set_attributes`` as the
strings that will be written, refused before any write.
``str(value)`` used to be applied to whatever arrived, so a JSON ``null``
became the text ``None``, ``true`` became ``True`` and an object became its
Python repr — silently, on both engines. A string is taken as is (one line,
the DXF rule ``_one_line`` enforces); an int or a finite float is written
with ``str`` (``101`` → ``"101"``, ``2.5`` → ``"2.5"``); ``bool``, ``None``,
lists and objects are refused by tag. ``None`` for the whole mapping means
"no attributes".
"""
if attributes is None:
return {}
if not isinstance(attributes, dict):
raise TypeError(f"{where} must be an object of {{TAG: value}} pairs")
out: dict[str, str] = {}
for tag, value in attributes.items():
if not isinstance(tag, str) or not tag.strip():
raise TypeError(f"{where}: every tag must be a non-empty string")
label = f"{where}[{tag!r}]"
if isinstance(value, str):
out[tag] = _one_line(value, label)
elif isinstance(value, bool) or value is None or not isinstance(value, (int, float)):
raise TypeError(f"{label} must be a string or a number, got {type(value).__name__}")
else:
if isinstance(value, float) and not math.isfinite(value):
raise TypeError(f"{label} must be finite")
out[tag] = str(value)
return out
def solid_vertices(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""DXF SOLID draws vtx0→vtx1→vtx3→vtx2, so a quad given in polygon order
(a, b, c, d) is stored as (a, b, d, c). Triangles are stored as given."""
pts = [(float(x), float(y)) for x, y in points]
if len(pts) == 4:
return [pts[0], pts[1], pts[3], pts[2]]
return pts
+134
View File
@@ -0,0 +1,134 @@
"""Optional contract methods: declared once, refused loudly, gated by a test.
The backend contract is large (112 abstract methods before this file existed,
and the plan adds ~39 more). Every one of them is an `@abstractmethod`, which
means adding a single method to the contract makes **both** backends fail at
instantiation until both are written — and two test modules construct
`ComBackend` at import time, so the whole suite goes red on a half-finished
edit. That is a real cost paid on every contract change.
The obvious fix is to give new methods a default body. The obvious default is
wrong: returning ``{"ok": False, "capability": ...}`` from a method nobody
implemented produces exactly the quiet "this didn't work and nothing told you"
that the v1.5.0 honesty pass spent a milestone deleting. A default that lies
quietly is worse than a build break.
So `@capability` gives a default that **raises**
`UnsupportedCapabilityError` — the same typed refusal the deliberate
boundaries raise, carrying the same `capability` key, surfaced by the same
`CapabilityRefusalMiddleware`, and indistinguishable to a client from a
hand-written refusal. Three properties follow:
* An unimplemented method refuses instead of pretending. It cannot silently
succeed and it cannot silently do nothing.
* The refusal is *declared*: `test_capability_contract.py` fails if a key used here is
missing from either backend's `CapabilityMap`, so `system_capabilities` can
never disagree with what a call actually does. This is the part that keeps
the escape hatch honest — without it, `@capability` would just be a way to
ship undeclared holes.
* Overriding is ordinary subclassing. A backend that implements the method
simply defines it; nothing needs to be un-registered.
`@abstractmethod` remains the default for anything both backends must have.
Reach for `@capability` only when a method is genuinely optional — an engine
boundary (no ACIS kernel headlessly) rather than an unfinished chore.
"""
from __future__ import annotations
import functools
from collections.abc import Callable
from typing import Any
__all__ = [
"UnsupportedCapabilityError",
"capability",
"capability_keys",
"is_capability_default",
]
# ---------------------------------------------------------------------------
# The refusal vocabulary
#
# This lives here rather than in ``backends.base`` because the contracts import
# the decorator and ``base`` imports the contracts — putting the exception in
# ``base`` makes that a cycle. ``backends.base`` re-exports both names, so
# ``from backends.base import UnsupportedCapabilityError`` is unchanged for the
# rest of the repo.
#
# There is exactly one authoring rule: a backend that cannot do a thing RAISES
# ``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a
# key of ``capabilities().features`` so a client can pre-check the same boundary
# through ``system_capabilities`` instead of parsing English. Tools never catch
# it and never hand-build refusal payloads — one middleware turns it into the
# wire payload, and ``cad_batch`` turns it into a step row. One class object,
# defined once: two look-alike types would silently break every ``isinstance``
# classifier.
#
# The ``{"ok": False, "error": ...}`` returned-dict shape stays legal only for
# soft failures that are not capability boundaries ("Layout not found", "No
# active transaction").
# ---------------------------------------------------------------------------
def _unsupported(error: str, capability_key: str) -> dict:
"""Build a backend's machine-readable "this backend can't" payload."""
return {"ok": False, "error": error, "capability": capability_key}
class UnsupportedCapabilityError(RuntimeError):
"""Raised when a call needs a capability this backend does not have.
Stays a plain ``RuntimeError``: rebasing it on ``FastMCPError`` was measured
and changes nothing a client can observe, because the refusal is carried by
``ToolResult(structured_content=..., is_error=True)`` rather than by the
exception class. ``to_dict()`` keeps the ``{ok, error, capability}`` payload
attached to the raise so the boundary survives the ``__cause__`` chain.
"""
def __init__(self, capability: str, error: str):
super().__init__(error)
self.capability = capability
def to_dict(self) -> dict:
return _unsupported(str(self), self.capability)
#: ``{capability_key: (method_name, ...)}`` for every method declared optional.
#: Populated at import by the decorator and read by the parity gate.
_REGISTRY: dict[str, set[str]] = {}
def capability(key: str, *, reason: str = "") -> Callable[[Callable], Callable]:
"""Mark a contract method optional, defaulting to a typed refusal.
Args:
key: capability name; must appear in **both** backends' capability maps.
reason: appended to the refusal message. Say what the caller can do
instead, not merely that they cannot do this.
"""
def decorate(func: Callable) -> Callable:
@functools.wraps(func)
async def default(self, *args: Any, **kwargs: Any):
message = f"{func.__name__}: not supported by the {self.name} backend"
raise UnsupportedCapabilityError(key, f"{message}. {reason}".rstrip())
default.__capability__ = key
default.__is_capability_default__ = True
_REGISTRY.setdefault(key, set()).add(func.__name__)
return default
return decorate
def capability_keys() -> dict[str, set[str]]:
"""Every optional-capability key and the methods declared under it."""
return {key: set(names) for key, names in _REGISTRY.items()}
def is_capability_default(owner: type, method_name: str) -> bool:
"""True when ``owner`` inherited the refusing default rather than implementing it."""
attribute = getattr(owner, method_name, None)
return bool(getattr(attribute, "__is_capability_default__", False))
+5748 -274
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
"""Per-domain slices of the backend contract.
One 1854-line ABC with 112 abstract methods made every contract change a
whole-file edit, and made a half-finished change break both backends at
instantiation at once. The seams here are the section markers that were
already in the file, so the split moved code without re-deciding anything.
"""
from __future__ import annotations
from backends.contracts.analysis import AnalysisContract
from backends.contracts.blocks import BlockContract
from backends.contracts.corner_ops import CornerOpsContract
from backends.contracts.dimensions import DimensionContract
from backends.contracts.drawing import DrawingContract
from backends.contracts.entity_creation import EntityCreationContract
from backends.contracts.entity_modification import EntityModificationContract
from backends.contracts.entity_query import EntityQueryContract
from backends.contracts.environment import EnvironmentContract
from backends.contracts.gdt import GdtContract
from backends.contracts.identity import IdentityContract
from backends.contracts.layers import LayerContract
from backends.contracts.layouts import LayoutContract
from backends.contracts.linetypes import LinetypeContract
from backends.contracts.pagesetup import PageSetupContract
from backends.contracts.premium import PremiumContract
from backends.contracts.refs import RefsContract
from backends.contracts.settings import SettingsContract
from backends.contracts.solids import SolidContract
from backends.contracts.styles import StylesContract
from backends.contracts.system import SystemContract
from backends.contracts.transactions import TransactionContract
from backends.contracts.view import ViewContract
__all__ = [
"AnalysisContract",
"BlockContract",
"CornerOpsContract",
"DimensionContract",
"DrawingContract",
"EntityCreationContract",
"EntityModificationContract",
"EntityQueryContract",
"EnvironmentContract",
"GdtContract",
"IdentityContract",
"LayerContract",
"LayoutContract",
"LinetypeContract",
"PageSetupContract",
"PremiumContract",
"RefsContract",
"SettingsContract",
"SolidContract",
"StylesContract",
"SystemContract",
"TransactionContract",
"ViewContract",
]
+175
View File
@@ -0,0 +1,175 @@
"""Measurement and analysis.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from backends.capability import capability
if TYPE_CHECKING:
from backends.base import EntityInfo
class AnalysisContract(ABC):
# ── boundary tracing (M8 / F3) ───────────────────────────────────────────
@capability( # noqa: B027 — @capability supplies the body
"boundary_trace",
reason=(
"ActiveX exposes no BOUNDARY member; the live path would have to drive the "
"-BOUNDARY command blind, which has not been verified against a live AutoCAD. "
"Use the headless backend (AUTOCAD_MCP_BACKEND=ezdxf), or run BOUNDARY in "
"AutoCAD directly."
),
)
async def boundary_trace(
self, x: float, y: float, layer: str | None = None, tolerance: float = 1e-9
) -> dict:
"""AutoCAD's BOUNDARY/BPOLY: trace the closed loop enclosing a seed point.
Returns ``{ok, handle, vertices, area, closed, source_handles}``.
``vertices`` is a count, ``area`` is unsigned.
Implementations must not enumerate every loop in the drawing:
``edgeminer.find_all_loops`` is O(n!) and was measured at 63.9 s for 40
edges and a timeout at 60. The seed point picks a starting edge and one
loop is walked from it.
A seed with no enclosing loop is refused. Returning an empty boundary
would be a boundary that bounds nothing, reported as success.
"""
...
@capability( # noqa: B027 — @capability supplies the body
"boundary_trace",
reason=(
"Chaining arbitrary entities into a loop needs the same edge graph BOUNDARY "
"does, which ActiveX does not expose. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf)."
),
)
async def boundary_from_entities(self, handles: list[str], tolerance: float = 1e-9) -> dict:
"""Chain the given entities into one closed polyline.
Returns ``{ok, handle, vertices, area, closed}``. Refuses a chain that
does not close, naming the coordinates of the endpoint that dangles —
"these do not form a loop" is not actionable without the gap.
"""
...
@abstractmethod
async def analysis_list_properties(self, handle: str) -> dict:
"""AutoCAD's LIST: the full DXF attribute set for one handle.
Returns ``{ok, handle, type, layer, properties, dxf_attributes}``.
``properties`` mirrors what ``entity_get`` reports; ``dxf_attributes``
is the raw attribute set that ``entity_get`` deliberately does not
carry.
Two things the raw dump cannot be handed over as-is. It holds ``Vec3``
values, so it is not JSON-serialisable and would die at the wire
boundary; and its coordinates are stored in the entity's own frame, so
for anything with an extrusion they would contradict every other
coordinate this server reports. Both are converted here, and
``extrusion`` stays in the dump so the original frame is still visible.
"""
...
@abstractmethod
async def analysis_stats(self) -> dict: ...
@abstractmethod
async def analysis_entities_in_region(
self,
x1: float,
y1: float,
x2: float,
y2: float,
) -> list[EntityInfo]: ...
@abstractmethod
async def analysis_measure_distance(
self,
x1: float,
y1: float,
x2: float,
y2: float,
) -> float: ...
@abstractmethod
async def analysis_measure_area(self, points: list[list[float]]) -> float: ...
@abstractmethod
async def entity_measure(self, handle: str, flatten_tolerance: float = 0.001) -> dict:
"""Area and perimeter of ONE existing entity, addressed by handle.
Returns ``{"handle", "type", "area", "perimeter", "closed",
"assumed_closed", "method", "exact", "flatten_tolerance", "backend",
"self_intersecting", "perimeter_exact", "loop_count"}``.
A HATCH is measured as the area it *fills* — outer loops minus the
islands inside them — and the headless engine adds ``hatch_style``
(``normal``/``outer``/``ignore``) so the number can be read against the
drawing's own island setting. The live backend does not carry that key:
it reads ActiveX ``.Area``, which **does** apply the island style —
verified on a live AutoCAD 2026 (2026-08-06): a hatch of r20 with an
r10 island returns 942.478, which is pi*(400-100).
``perimeter`` is ``None`` where ActiveX exposes no length member for the
type, and ``perimeter_exact`` is then False. The member differs per
type and was measured rather than assumed: LWPOLYLINE ``.Length``,
CIRCLE ``.Circumference``, REGION ``.Perimeter``, HATCH none. A
3DSOLID has no ``.Area`` at all — only ``.Volume`` — and is refused by
name.
A missing perimeter is not a reason to discard an area that was read
successfully — doing exactly that is what made this method raise on
every ACIS type it advertised.
``exact`` and
``flatten_tolerance`` are the accuracy disclosure: when one engine can
answer in closed form and the other has to flatten, the payload says so
rather than leaving two numbers to quietly disagree.
Two different refusals, and the difference matters. A type that bounds no
area at all (LINE, TEXT, a 3D polyline) raises a plain ``RuntimeError``:
no engine can measure it, so tagging a capability would falsely suggest
switching backends helps. A type this *engine* cannot evaluate (ACIS
solids headlessly) raises ``UnsupportedCapabilityError``, which names the
engine that can.
"""
...
@abstractmethod
async def analysis_bounding_box(self) -> dict: ...
@abstractmethod
async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ...
@abstractmethod
async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ...
@abstractmethod
async def selection_get(self) -> dict:
"""Read the live viewport's implied ("pickfirst") selection set.
COM-only / meaningful only with live AutoCAD. Returns a dict::
{
"ok": bool, # True on COM even for an empty pick
"count": int,
"handles": list[str], # entity handles to act on
"entities": list[EntityInfo], # _dc()-converted by the server
"pickfirst": bool | None, # state of the PICKFIRST sysvar
"message" / "error": str, # optional guidance / failure reason
}
The ezdxf headless backend has no viewport, so it returns ``ok=False``
with an empty ``handles`` list (same shape, never raises).
"""
...
+76
View File
@@ -0,0 +1,76 @@
"""Blocks and block references.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import BlockInfo, EntityInfo
class BlockContract(ABC):
@abstractmethod
async def block_list(self) -> list[BlockInfo]: ...
@abstractmethod
async def block_insert(
self,
name: str,
x: float,
y: float,
scale_x: float = 1.0,
scale_y: float = 1.0,
rotation: float = 0.0,
attributes: dict | None = None,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def block_explode(self, handle: str) -> dict: ...
@abstractmethod
async def block_get_attributes(self, handle: str) -> dict: ...
@abstractmethod
async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ...
@abstractmethod
async def block_create_from_entities(
self,
name: str,
handles: list[str],
base_x: float = 0.0,
base_y: float = 0.0,
) -> dict: ...
@abstractmethod
async def block_define(
self,
name: str,
entities: list[dict],
attdefs: list[dict] | None = None,
base_x: float = 0.0,
base_y: float = 0.0,
overwrite: bool = False,
create_layers: bool = False,
) -> dict:
"""Create (or with ``overwrite`` replace the contents of) a block
definition from typed primitive specs and ATTDEF specs — see
``backends/block_specs.py`` for the spec vocabulary.
Refusals, all before any write: a malformed spec (``TypeError`` naming
``entities[i]``/``attdefs[i]`` and the key), a non-finite or non-numeric
``base_x``/``base_y`` (``TypeError`` naming it), a name clash without
``overwrite`` (``ValueError``), and a primitive ``layer`` that does not
exist in the drawing (``ValueError`` listing the missing names) unless
``create_layers`` is true, in which case the missing layers are created
with the engine's defaults and listed in ``layers_created``. Returns
``{ok, name, entity_count, attdef_count, replaced, layers_created,
backend}``."""
...
+66
View File
@@ -0,0 +1,66 @@
"""Trim, extend, fillet, chamfer.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class CornerOpsContract(ABC):
@abstractmethod
async def entity_trim(
self,
target_handle: str,
cutter_handle: str,
keep_x: float,
keep_y: float,
) -> EntityInfo:
"""Trim `target` against `cutter`; keep the segment containing
(keep_x, keep_y). Raises ToolError if no intersection."""
...
@abstractmethod
async def entity_extend(
self,
target_handle: str,
boundary_handle: str,
end_x: float | None = None,
end_y: float | None = None,
) -> EntityInfo:
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
target endpoint nearest the boundary is auto-selected."""
...
@abstractmethod
async def entity_fillet(
self,
handle1: str,
handle2: str,
radius: float,
trim: bool = True,
) -> EntityInfo:
"""Fillet two entities with the given radius. Returns the new ARC.
When `trim` is True (default), the source entities are shortened
to the tangent points (AutoCAD default behaviour)."""
...
@abstractmethod
async def entity_chamfer(
self,
handle1: str,
handle2: str,
dist1: float,
dist2: float | None = None,
trim: bool = True,
) -> EntityInfo:
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
(symmetric chamfer). Returns the new chamfer LINE."""
...
+89
View File
@@ -0,0 +1,89 @@
"""ISO 129 dimensions.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class DimensionContract(ABC):
@abstractmethod
async def dimension_linear(
self,
x1: float,
y1: float,
x2: float,
y2: float,
dim_x: float,
dim_y: float,
rotation: float = 0.0,
layer: str | None = None,
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_aligned(
self,
x1: float,
y1: float,
x2: float,
y2: float,
dim_x: float,
dim_y: float,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_angular(
self,
vx: float,
vy: float,
x1: float,
y1: float,
x2: float,
y2: float,
tx: float,
ty: float,
layer: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_radius(
self,
cx: float,
cy: float,
chord_x: float,
chord_y: float,
leader_length: float = 10.0,
layer: str | None = None,
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def dimension_diameter(
self,
x1: float,
y1: float,
x2: float,
y2: float,
leader_length: float = 10.0,
layer: str | None = None,
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> EntityInfo: ...
+40
View File
@@ -0,0 +1,40 @@
"""Document lifecycle.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import DrawingInfo
class DrawingContract(ABC):
@abstractmethod
async def drawing_info(self) -> DrawingInfo: ...
@abstractmethod
async def drawing_new(self, template: str | None = None) -> dict: ...
@abstractmethod
async def drawing_open(self, path: str) -> dict: ...
@abstractmethod
async def drawing_save(self, path: str | None = None) -> dict: ...
@abstractmethod
async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ...
@abstractmethod
async def drawing_export_dxf(self, path: str) -> dict: ...
@abstractmethod
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ...
@abstractmethod
async def drawing_purge(self) -> dict: ...
+255
View File
@@ -0,0 +1,255 @@
"""Creating geometry.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from backends.capability import capability
if TYPE_CHECKING:
from backends.base import EntityInfo
class EntityCreationContract(ABC):
@abstractmethod
async def entity_create_line(
self,
x1: float,
y1: float,
x2: float,
y2: float,
z1: float = 0.0,
z2: float = 0.0,
layer: str | None = None,
color: int | None = None,
linetype: str | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_circle(
self,
cx: float,
cy: float,
radius: float,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_arc(
self,
cx: float,
cy: float,
radius: float,
start_angle: float,
end_angle: float,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_polyline(
self,
points: list[list[float]],
closed: bool = False,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_text(
self,
text: str,
x: float,
y: float,
height: float = 2.5,
rotation: float = 0.0,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_mtext(
self,
text: str,
x: float,
y: float,
width: float = 100.0,
height: float = 2.5,
rotation: float = 0.0,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_table(
self,
x: float,
y: float,
rows: list[list[str]],
headers: list[str] | None = None,
column_widths: list[float] | None = None,
row_height: float = 7.0,
text_height: float = 2.5,
title: str | None = None,
layer: str = "TEXT",
) -> EntityInfo: ...
@abstractmethod
async def leader_create_mleader(
self,
points: list[list[float]],
text: str,
text_height: float = 2.5,
landing_gap: float = 1.0,
arrow_size: float = 2.5,
layer: str = "DIM",
) -> EntityInfo: ...
@abstractmethod
async def entity_create_hatch(
self,
pattern: str,
boundary_points: list[list[float]],
scale: float = 1.0,
angle: float = 0.0,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_spline(
self,
fit_points: list[list[float]],
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_ellipse(
self,
cx: float,
cy: float,
major_x: float,
major_y: float,
ratio: float = 0.5,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_point(
self,
x: float,
y: float,
layer: str | None = None,
color: int | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_create_block_ref(
self,
name: str,
x: float,
y: float,
scale_x: float = 1.0,
scale_y: float = 1.0,
rotation: float = 0.0,
layer: str | None = None,
) -> EntityInfo: ...
# ── hatch depth (M8 / F4) ────────────────────────────────────────────────
@abstractmethod
async def hatch_set_gradient(
self,
handle: str,
color1: list[int],
color2: list[int],
rotation: float = 0.0,
centered: float = 0.0,
one_color: bool = False,
tint: float = 0.0,
name: str = "LINEAR",
) -> dict:
"""Replace a hatch's fill with a two-colour gradient. ``{ok, handle, gradient}``."""
...
@abstractmethod
async def hatch_edit(
self,
handle: str,
pattern: str = "",
scale: float | None = None,
angle: float | None = None,
color: int | None = None,
style: str = "",
) -> dict:
"""Edit an existing hatch in place. Returns ``{ok, handle, changed}``.
``changed`` names exactly the attributes that moved: an omitted
parameter must leave its attribute alone, because a partial edit that
quietly resets the rest is data loss. ``style`` is the island style —
``normal``, ``outer`` or ``ignore``.
"""
...
@abstractmethod
async def hatch_add_boundary(self, handle: str, edges: list[dict]) -> dict:
"""Add one boundary path built from typed edges.
Returns ``{ok, handle, path_count, edge_types}``. Edges are
``{"type": "line"|"arc"|"ellipse", ...}``. Typed edges exist because a
boundary API that only accepts vertex lists silently straightens every
curve it is given.
"""
...
# ── annotation objects (M8 / F15) ────────────────────────────────────────
@capability( # noqa: B027 — @capability supplies the body
"wipeout",
reason=(
"ActiveX has no wipeout constructor — ModelSpace.AddWipeout does not exist "
"(verified against AutoCAD 2026); WIPEOUT is a command only. Use the headless "
"backend (AUTOCAD_MCP_BACKEND=ezdxf), or run WIPEOUT in AutoCAD directly."
),
)
async def entity_create_wipeout(
self, points: list[list[float]], layer: str | None = None
) -> dict:
"""Mask the drawing behind a closed polygon. Returns ``{ok, handle, points}``.
Refuses fewer than three points: a zero-area mask masks nothing while
reporting success.
"""
...
@capability( # noqa: B027 — @capability supplies the body
"revcloud",
reason=(
"ActiveX exposes no revision-cloud member and driving the REVCLOUD command "
"blind against a live drawing is unverified. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf), or run REVCLOUD in AutoCAD directly."
),
)
async def entity_create_revcloud(
self,
points: list[list[float]],
segment_length: float,
layer: str | None = None,
closed: bool = True,
) -> dict:
"""Draw a revision cloud along a path. Returns ``{ok, handle, segments}``.
A revision cloud *is* a polyline whose every segment carries an arc
bulge; a segment length longer than the path yields no arcs at all, so
that case is refused rather than returned as a rectangle.
"""
...
+125
View File
@@ -0,0 +1,125 @@
"""Moving, copying and editing geometry.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class EntityModificationContract(ABC):
@abstractmethod
async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ...
@abstractmethod
async def entity_copy(
self, handle: str, dx: float, dy: float, dz: float = 0.0
) -> EntityInfo: ...
@abstractmethod
async def entity_rotate(
self,
handle: str,
base_x: float,
base_y: float,
angle_deg: float,
) -> dict: ...
@abstractmethod
async def entity_scale(
self,
handle: str,
base_x: float,
base_y: float,
factor: float,
) -> dict: ...
@abstractmethod
async def entity_mirror(
self,
handle: str,
x1: float,
y1: float,
x2: float,
y2: float,
delete_original: bool = False,
) -> EntityInfo: ...
@abstractmethod
async def entity_offset(
self,
handle: str,
distance: float,
side_x: float | None = None,
side_y: float | None = None,
) -> EntityInfo: ...
@abstractmethod
async def entity_delete(self, handle: str) -> dict: ...
@abstractmethod
async def entity_array_rectangular(
self,
handle: str,
rows: int,
cols: int,
row_spacing: float,
col_spacing: float,
) -> list[EntityInfo]: ...
@abstractmethod
async def entity_array_polar(
self,
handle: str,
count: int,
fill_angle: float,
center_x: float,
center_y: float,
) -> list[EntityInfo]: ...
# ── text annotation (M8 / F15) ───────────────────────────────────────────
@abstractmethod
async def text_set_background(
self,
handle: str,
enabled: bool = True,
color: int | None = None,
scale: float = 1.5,
) -> dict:
"""Put an opaque background box behind an MTEXT. ``{ok, handle, enabled}``.
MTEXT only — TEXT has no background-fill attribute, so setting one on
it would report success and change nothing. ``scale`` is a multiple of
the text box and must be >= 1: a box smaller than its text is a stripe
through the text, not a mask.
"""
...
@abstractmethod
async def text_find_replace(
self,
find: str,
replace: str,
layer: str | None = None,
match_case: bool = True,
dry_run: bool = False,
) -> dict:
"""Replace text across the drawing.
Returns ``{ok, replaced, entities, searched_types, note, dry_run}``.
``searched_types`` is reported rather than assumed: "no matches" and
"that type was never searched" are different answers, and an editor
that silently skips block attributes is the classic version of this
bug. DIMENSION text is deliberately out of scope — its ``text`` field
holds the ``<>`` override placeholder rather than the measurement, so
editing it would either do nothing or break the association.
"""
...
+172
View File
@@ -0,0 +1,172 @@
"""Reading entities back.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import EntityInfo
class EntityQueryContract(ABC):
# ── selection filters (M8 / F1) ──────────────────────────────────────────
@abstractmethod
async def selection_window(
self,
x1: float,
y1: float,
x2: float,
y2: float,
mode: str = "window",
entity_type: str = "",
layer: str = "",
) -> dict:
"""AutoCAD's ssget window/crossing. ``{ok, handles, count, mode}``.
``window`` returns only entities wholly inside the box; ``crossing``
also returns the ones that straddle its edge. Getting those backwards
is silent and wrong, so ``mode`` is reported back and defaults to the
narrower one. A zero-area box is refused rather than answered with an
empty list, which would read as "nothing there".
Selection is by *drawn* position: an entity in a mirrored frame is
found where ``entity_get`` reports it, not where the file stores it.
"""
...
@abstractmethod
async def selection_polygon(
self,
points: list[list[float]],
mode: str = "window",
entity_type: str = "",
layer: str = "",
) -> dict:
"""Window/crossing selection against an arbitrary polygon.
A polygon is not its bounding box — the difference is the whole point
of the tool, so the implementation must test against the shape itself.
"""
...
@abstractmethod
async def selection_filter(
self,
entity_type: str = "",
layer: str = "",
color: int | None = None,
linetype: str = "",
min_area: float | None = None,
) -> dict:
"""Property filter. Returns ``{ok, handles, count, filtered_by}``.
Named parameters rather than a query string, deliberately: ezdxf's
query language answers an unknown attribute with an empty result, so a
typo is indistinguishable from "no matches". Here a typo is a
``TypeError`` at the boundary, and ``filtered_by`` tells the caller
which filters actually ran — "I filtered by layer and found none" and
"I never filtered by layer" are different answers.
"""
...
@abstractmethod
async def entity_get(self, handle: str) -> EntityInfo: ...
@abstractmethod
async def entity_set_properties(
self,
handle: str,
layer: str | None = None,
color: int | None = None,
linetype: str | None = None,
lineweight: float | None = None,
visible: bool | None = None,
) -> dict: ...
@abstractmethod
async def entity_edit_text(
self,
handle: str,
text: str | None = None,
height: float | None = None,
rotation: float | None = None,
) -> EntityInfo:
"""Edit an existing TEXT or MTEXT entity in place. Any argument left
None is unchanged. Returns the updated EntityInfo. Raises if the handle
is not a TEXT/MTEXT entity."""
...
@abstractmethod
async def entity_edit_geometry(
self,
handle: str,
cx: float | None = None,
cy: float | None = None,
radius: float | None = None,
x1: float | None = None,
y1: float | None = None,
x2: float | None = None,
y2: float | None = None,
start_angle: float | None = None,
end_angle: float | None = None,
) -> EntityInfo:
"""Edit the defining geometry of an existing entity in place.
- CIRCLE: cx / cy / radius
- LINE: x1 / y1 (start), x2 / y2 (end)
- ARC: cx / cy / radius / start_angle / end_angle (degrees)
Any argument left None is unchanged. Raises for unsupported types."""
...
@abstractmethod
async def entity_list(
self,
type_filter: str | None = None,
layer_filter: str | None = None,
limit: int = 200,
offset: int = 0,
) -> list[EntityInfo]: ...
@abstractmethod
async def entity_count(
self,
type_filter: str | None = None,
layer_filter: str | None = None,
) -> int:
"""How many entities ``entity_list`` would match, ignoring limit/offset.
Exists so a *paged* listing can report an honest total without paying
for the rows it is not going to return. ``entity_list`` stops building
``EntityInfo`` objects the moment it has ``limit`` of them, so it can
never tell a caller whether anything followed the page — and a
truncated list read as complete is a correctness bug, not a token one.
Two obligations, both load-bearing:
* apply **exactly** the filter semantics of ``entity_list`` (same
case-insensitive type/layer comparison), or ``truncated`` would be
computed against a different set than the one that was paged;
* do not build ``EntityInfo`` rows. A count implemented by listing
would cost more than the listing it is meant to make cheap.
"""
...
# ── extended entity data (XDATA) ─────────────────────────────────────────
@abstractmethod
async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict:
"""Extended entity data as ``{"handle", "xdata": {app: [values]}, "backend"}``.
``app_name=None`` returns every app; an absent app is ``{}``, not an error."""
...
@abstractmethod
async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict:
"""Replace the app's XDATA (registering the APPID if needed). Values are typed
by ``backends/xdata_specs.py``; an empty list removes the app's XDATA."""
...
+178
View File
@@ -0,0 +1,178 @@
"""Environment: documents, layer states, named views, UCS, application,
preferences, drawing properties and operator prompts (v1.6 track E).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
Ownership: group V owns this module. The ``# ── properties ──`` block is
declared here and *implemented* by group C on both backends; the six
``@capability`` members are implemented on the COM backend only and refuse on
ezdxf with a key both capability maps declare.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from backends.capability import capability
class EnvironmentContract(ABC):
# ── documents ────────────────────────────────────────────────────────────
@abstractmethod
async def document_list(self) -> list[dict]:
"""``[{"name", "path", "active": bool, "saved": bool, "entity_count"}]``."""
...
@abstractmethod
async def document_activate(self, name_or_path: str) -> dict:
"""Make one open document the target of every later call.
→ ``{"ok", "active", "previous"}``."""
...
@abstractmethod
async def document_close(
self, name_or_path: str | None = None, save: bool = False, discard: bool = False
) -> dict:
"""Close one document (default: the active one).
Unsaved and neither ``save`` nor ``discard`` → ``ValueError`` naming the
document; ``save`` on a document that has never been saved →
``ValueError`` (no path to write, and the live engine would open a
modal Save dialog). → ``{"ok", "closed", "saved": bool, "active": name|None}``.
"""
...
# ── layer states (portable ACADMCP_LAYERSTATES XRECORDs, both engines) ──
@abstractmethod
async def layer_state_save(self, name: str, description: str | None = None) -> dict:
"""→ ``{"ok", "name", "layer_count", "replaced"}``."""
...
@abstractmethod
async def layer_state_restore(self, name: str, properties: list[str] | None = None) -> dict:
"""``properties`` ⊆ {"on","frozen","locked","color","linetype","lineweight","plot","current"};
→ ``{"ok", "name", "applied", "missing_layers", "new_layers"}``."""
...
@abstractmethod
async def layer_state_list(self) -> list[dict]:
"""→ ``[{"name", "description", "layer_count"}]``."""
...
@abstractmethod
async def layer_state_delete(self, name: str) -> dict: ...
# ── named views ─────────────────────────────────────────────────────────
@abstractmethod
async def view_named_save(
self, name: str, center=None, height: float | None = None, width: float | None = None
) -> dict:
"""→ ``{"ok", "name", "center": [x, y], "height", "width", "replaced"}``."""
...
@abstractmethod
async def view_named_restore(self, name: str) -> dict:
"""COM: ZOOM Window on the saved rectangle (``"applied": "zoom_window"``
plus the read-back ``viewctr`` / ``viewsize``); ezdxf: fits the window
into the ``*Active`` VPORT without touching its aspect ratio,
``"applied": "header_only"`` plus ``vport_height`` / ``aspect_ratio``."""
...
@abstractmethod
async def view_named_list(self) -> list[dict]: ...
# ── UCS (stored and made current; tool coordinates stay WCS) ────────────
@abstractmethod
async def ucs_list(self) -> list[dict]:
"""→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world".
Exactly one row is current. An *unnamed* current UCS (``UCS Origin`` /
``3P`` without saving) is a trailing row with ``name: None`` carrying
its frame — "world" is current only when the frame is the WCS
(WORLDUCS live, the ``$UCS*`` header headless), never because the
name is empty.
"""
...
@abstractmethod
async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict:
"""non-orthogonal → ``ValueError`` with the measured angle."""
...
@abstractmethod
async def ucs_restore(self, name: str) -> dict:
"""``"world"`` resets."""
...
# ── application, preferences, operator prompts (COM only) ───────────────
@capability( # noqa: B027 — @capability supplies the body
"live_application",
reason="only a live CAD application can be launched or attached",
)
async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
"""→ ``{"launched": bool, "attached": bool, "version", "document"}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"preferences",
reason="AutoCAD preferences live in the running application, not in a file",
)
async def preferences_get(self, keys: list[str] | None = None) -> dict:
"""→ ``{"values": {key: value}, "read_only": [keys]}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"preferences",
reason="AutoCAD preferences live in the running application, not in a file",
)
async def preferences_set(self, key: str, value) -> dict:
"""→ ``{"ok", "key", "old", "new"}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"interactive_prompt",
reason="prompting the operator needs a live AutoCAD session",
)
async def user_pick_point(self, prompt: str) -> dict:
"""→ ``{"cancelled": bool, "x", "y"}`` | ``{"timed_out": True}``."""
...
@capability( # noqa: B027 — @capability supplies the body
"interactive_prompt",
reason="prompting the operator needs a live AutoCAD session",
)
async def user_select(self, prompt: str, mode: str = "single") -> dict:
"""→ ``{"cancelled", "handles": [...]}``; ``mode="single"`` adds ``"picked": [x, y]`` in WCS."""
...
@capability( # noqa: B027 — @capability supplies the body
"interactive_prompt",
reason="prompting the operator needs a live AutoCAD session",
)
async def system_prompt_message(self, text: str) -> dict:
"""→ ``{"ok"}``."""
...
# ── properties (declared here; implemented by group C on both engines) ───
@abstractmethod
async def drawing_properties_get(self) -> dict:
"""→ ``{"summary": {"title","subject","author","keywords","comments"}
(None each headless), "summary_available": bool, "custom": {k: v}}``."""
...
@abstractmethod
async def drawing_properties_set(
self, summary: dict | None = None, custom: dict | None = None
) -> dict:
"""ezdxf: ``summary`` with any non-None field → raise
``UnsupportedCapabilityError("dwgprops")``; ``custom`` → header
custom_vars (replace keys given; value None deletes).
→ ``{"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}``."""
...
+157
View File
@@ -0,0 +1,157 @@
"""Geometric tolerancing.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
class GdtContract:
# Composed from LINE + TEXT primitives so the exact same frame renders on
# both COM and ezdxf (ezdxf's native TOLERANCE entity renders blank through
# the matplotlib frontend). Deterministic layout math lives in engineering/
# gdt.py; datum letters are tracked so the `gdt` critique focus can verify
# every referenced datum is actually established on the part.
async def _place_boxed_text(
self,
text: str,
cx: float,
cy: float,
text_h: float,
layer: str,
) -> str:
"""Create a TEXT entity roughly centred on (cx, cy). Returns its handle.
entity_create_text has no alignment arg, so approximate the centring with
the same width heuristic the title block uses."""
approx_w = len(text) * text_h * 0.7
t = await self.entity_create_text(
text,
cx - approx_w / 2.0,
cy - text_h / 2.0,
height=text_h,
layer=layer,
)
return t.handle
async def draw_feature_control_frame(
self,
symbol: str,
tolerance: str | float,
x: float,
y: float,
datums: list[str] | None = None,
height: float = 5.0,
diameter: bool = False,
modifier: str | None = None,
layer: str | None = None,
) -> dict:
"""Draw an ISO 1101 feature control frame with bottom-left corner at (x, y).
symbol: one of the 14 geometric characteristics (straightness, flatness,
circularity, cylindricity, profile_line, profile_surface, angularity,
perpendicularity, parallelism, position, concentricity, symmetry,
circular_runout, total_runout). `datums` is the ordered datum reference
list (e.g. ["A", "B"]); `diameter=True` prefixes ⌀ for a cylindrical zone;
`modifier` ∈ {M, L, S} appends the material-condition symbol.
Orientation/location/runout characteristics require at least one datum
(raises otherwise). The referenced datums are recorded for the `gdt`
critique focus.
"""
from engineering.gdt import fcf_compartments, fcf_layout
comps = fcf_compartments(
symbol,
tolerance,
datums,
diameter=diameter,
modifier=modifier,
)
layer = layer or self._role_layer("dim")
await self._ensure_layer(layer, color=2)
lay = fcf_layout(comps, float(x), float(y), float(height))
handles: list[str] = []
box = lay["box"]
box_poly = await self.entity_create_polyline(
[[bx, by] for bx, by in box],
closed=False,
layer=layer,
)
handles.append(box_poly.handle)
for (sx, sy), (ex, ey) in lay["dividers"]:
ln = await self.entity_create_line(sx, sy, ex, ey, layer=layer)
handles.append(ln.handle)
th = lay["text_height"]
for text, tcx, tcy in lay["labels"]:
handles.append(await self._place_boxed_text(text, tcx, tcy, th, layer))
refs = getattr(self, "_gdt_datums_referenced", None)
if refs is None:
refs = set()
self._gdt_datums_referenced = refs
for d in datums or []:
d = str(d).strip().upper()
if d:
refs.add(d)
return {
"ok": True,
"symbol": symbol,
"compartments": comps,
"handles": handles,
"width": lay["width"],
"height": lay["height"],
"layer": layer,
}
async def draw_datum_feature(
self,
letter: str,
x: float,
y: float,
size: float = 5.0,
layer: str | None = None,
) -> dict:
"""Place an ISO 1101 datum feature symbol (filled triangle + boxed letter)
with its apex at (x, y). Records the datum letter so a feature control
frame that references it passes the `gdt` critique focus."""
from engineering.gdt import datum_triangle, fcf_layout
letter = str(letter).strip().upper()
if not letter:
raise RuntimeError("draw_datum_feature: a datum letter is required.")
layer = layer or self._role_layer("dim")
await self._ensure_layer(layer, color=2)
size = float(size)
handles: list[str] = []
tri = datum_triangle(float(x), float(y), size, down=True)
tri_poly = await self.entity_create_polyline(
[[px, py] for px, py in tri],
closed=True,
layer=layer,
)
handles.append(tri_poly.handle)
box_h = size * 1.4
lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h)
box_poly = await self.entity_create_polyline(
[[bx, by] for bx, by in lay["box"]],
closed=False,
layer=layer,
)
handles.append(box_poly.handle)
for text, tcx, tcy in lay["labels"]:
handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer))
defined = getattr(self, "_gdt_datums_defined", None)
if defined is None:
defined = set()
self._gdt_datums_defined = defined
defined.add(letter)
return {"ok": True, "datum": letter, "handles": handles, "layer": layer}
+33
View File
@@ -0,0 +1,33 @@
"""Connecting and describing the backend.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import CapabilityMap
class IdentityContract(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@property
@abstractmethod
def is_connected(self) -> bool: ...
@abstractmethod
async def connect(self) -> None: ...
@abstractmethod
async def disconnect(self) -> None: ...
@abstractmethod
def capabilities(self) -> CapabilityMap: ...
+61
View File
@@ -0,0 +1,61 @@
"""Layers.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import LayerInfo
class LayerContract(ABC):
@abstractmethod
async def layer_list(self) -> list[LayerInfo]: ...
@abstractmethod
async def layer_create(
self,
name: str,
color: int = 7,
linetype: str = "Continuous",
lineweight: float = -3,
) -> LayerInfo: ...
@abstractmethod
async def layer_delete(self, name: str) -> dict: ...
@abstractmethod
async def layer_set_current(self, name: str) -> dict: ...
@abstractmethod
async def layer_modify(
self,
name: str,
color: int | None = None,
linetype: str | None = None,
lineweight: float | None = None,
) -> LayerInfo: ...
@abstractmethod
async def layer_freeze(self, name: str) -> dict: ...
@abstractmethod
async def layer_thaw(self, name: str) -> dict: ...
@abstractmethod
async def layer_lock(self, name: str) -> dict: ...
@abstractmethod
async def layer_unlock(self, name: str) -> dict: ...
@abstractmethod
async def layer_hide(self, name: str) -> dict: ...
@abstractmethod
async def layer_show(self, name: str) -> dict: ...
+170
View File
@@ -0,0 +1,170 @@
"""Layouts, viewports and change-space.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from backends.capability import capability
class LayoutContract(ABC):
@abstractmethod
async def layout_list(self) -> dict:
"""Return {ok, layouts: [names], current: name} incl. 'Model'."""
...
@abstractmethod
async def layout_create(self, name: str) -> dict:
"""Create a new paper-space layout."""
...
@abstractmethod
async def layout_set_current(self, name: str) -> dict:
"""Activate a layout tab ('Model' or a paper-space layout name)."""
...
@abstractmethod
async def layout_delete(self, name: str) -> dict:
"""Delete a paper-space layout and everything drawn on it.
Returns ``{ok, deleted, entities_destroyed, current}``. ``current`` is
the space geometry goes into afterwards, which the implementation must
keep consistent with ``layout_list()`` — deleting the active tab
otherwise leaves the reported current layout, the DXF tile mode and the
space entities actually land in disagreeing with each other.
Refuses ``'Model'``, a blank name (which ezdxf resolves to the *first*
paper-space layout), a name that does not exist, and the last remaining
paper-space layout.
"""
...
@abstractmethod
async def layout_rename(self, old_name: str, new_name: str) -> dict:
"""Rename a paper-space layout, keeping its entities and their handles.
Returns ``{ok, old, new, current}``. Refuses ``'Model'`` on either side,
a blank or otherwise invalid new name, and a name already in use.
"""
...
@abstractmethod
async def layout_copy(self, source: str, new_name: str) -> dict:
"""Copy a paper-space layout, its page setup and its geometry.
Returns ``{ok, source, layout, entities_copied, skipped,
associativity_remapped, associativity_dropped}``. ``skipped`` names the
DXF types that could not be cloned — a copy missing geometry must never
report a bare success. Associative boundaries (hatches) are re-pointed
at the cloned entities where possible and cleared otherwise, because a
boundary still referencing the source layout dangles as soon as that
layout is deleted and no auditor reports it.
"""
...
@abstractmethod
async def viewport_create(
self,
layout: str,
center_x: float,
center_y: float,
width: float,
height: float,
view_center_x: float,
view_center_y: float,
scale: float = 1.0,
) -> dict:
"""Place a scaled model-space viewport on a paper-space layout.
``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows
the model window centered at (view_center_x, view_center_y) with
view height = height / scale.
"""
...
@abstractmethod
async def viewport_list(self, layout: str | None = None) -> dict:
"""List the viewports on one paper-space layout, or on all of them.
Returns ``{ok, viewports: [...], count}``. Each row carries ``handle``,
``layout``, ``center``, ``width``, ``height``, ``view_center``,
``view_height``, ``scale``, ``locked``, ``status``, ``id`` and
``is_main``.
The layout's own main viewport is reported with ``is_main: True`` rather
than filtered out — it is what remains after deleting every drafting
viewport, and a caller who cannot see it cannot explain that. ``scale``
and ``locked`` are ``None`` on documents that cannot store them (R12),
never a fabricated number. Viewports are addressed by handle; ``id`` is
metadata only.
"""
...
@abstractmethod
async def viewport_set_scale(self, handle: str, scale: float) -> dict:
"""Set a viewport's paper:model scale. Returns ``{ok, handle, scale,
view_height}`` with ``scale`` read back from the entity.
Refuses the main viewport (whose view height is the tab's own pan/zoom
state, not a drafting scale) and documents that cannot persist it.
Geometric scale only: annotative text and dimensions do not resize.
"""
...
@abstractmethod
async def viewport_lock(self, handle: str, locked: bool = True) -> dict:
"""Lock or unlock a viewport's display scale. Returns ``{ok, handle,
locked}`` with ``locked`` read back from the entity."""
...
# B027 (empty method, no @abstractmethod) is exactly what this pattern
# looks like from the outside: the decorator replaces the `...` body with
# the refusing default, so the source really is empty on purpose.
@capability( # noqa: B027
"chspace",
reason=(
"ActiveX exposes no change-space member, so a live backend would have to recreate "
"geometry through CopyObjects or drive the CHSPACE command blind — neither has been "
"verified against a live AutoCAD. Use the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf), or run CHSPACE in AutoCAD directly."
),
)
async def entity_change_space(
self,
handles: list[str],
viewport_handle: str,
direction: str = "to_paper",
freeze_dimensions: bool = False,
) -> dict:
"""Move entities between model space and a sheet, *through* a viewport.
Returns ``{ok, moved, refused, scale, viewport}``. Each ``moved`` row
carries ``handle``, ``from``, ``to``, ``inside_viewport`` and
``on_sheet``; each ``refused`` row carries ``handle`` and ``reason``.
The geometric transform is the whole point: a move without it leaves a
100 mm feature as 100 mm of paper inside a 1:2 viewport, produces a file
that audits clean, and is indistinguishable from success. Backends that
cannot apply the viewport transform must refuse rather than move.
Refuses per entity for dimensions (unless ``freeze_dimensions`` bakes
the pre-transform measurement into the text first), ACIS bodies, tables
and proxies, viewports, and entities already in the target space; and
refuses the whole call for a twisted or non-top-view viewport.
"""
...
@abstractmethod
async def viewport_delete(self, handle: str, force: bool = False) -> dict:
"""Delete a viewport. Returns ``{ok, handle, layout, was_main}``.
The layout's main viewport needs ``force=True`` and the implementation
must repair the layout's current-viewport pointer afterwards; a dangling
pointer is invisible to every headless audit and shows up only in CAD.
"""
...
+22
View File
@@ -0,0 +1,22 @@
"""Linetypes.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class LinetypeContract(ABC):
@abstractmethod
async def linetype_list(self) -> list[str]: ...
@abstractmethod
async def linetype_load(
self,
name: str,
file: str | None = None,
) -> dict: ...
+82
View File
@@ -0,0 +1,82 @@
"""Page setup, plot styles and templates (v1.6 track E, group P).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class PageSetupContract(ABC):
@abstractmethod
async def page_setup_list(self, layout: str | None = None) -> list[dict]:
"""Page setup of one paper-space layout, or of every one.
Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h],
orientation, plot_style, scale, plot_area, device,
margins_mm: [top, bottom, left, right], paper_units, center}``.
``size_mm`` and ``margins_mm`` are the sheet *as plotted*: under
``plot_rotation`` 1/3 (AutoCAD's landscape-on-portrait-media
convention) both are turned together, through the one mapping the
renderer plots by (``papers.turned_sheet``), so the printable area a
row implies is the area the same engine plots. ``paper`` is the
catalogue name when the size matches one within 0.5 mm, otherwise the
media name as stored. ``paper_units`` (``"mm"`` | ``"inches"`` |
``"pixels"``) is what one paper-space unit means at the plot scale;
sizes and margins are millimetres whatever it says. ``center`` is a
bool for an extents plot and ``None`` for a layout plot, where
centring is not applicable (AutoCAD greys it out; ActiveX refuses
``CenterPlot = True`` under ``acLayout``). Raises ``ValueError`` for
an unknown layout.
``Model`` is never listed: model space is plotted through
``drawing_export_pdf(layout=None)`` and carries no sheet.
"""
...
@abstractmethod
async def page_setup_apply(self, layout: str, setup: dict) -> dict:
"""Write a page setup resolved by
``engineering.standards.papers.resolve_page_setup`` to a layout.
Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]},
warnings: [str], plot_style_known, viewports_kept}``. ``changed`` is
computed from the read-back before and after (the rows
``page_setup_list`` reports), so re-applying the same setup reports
``{}``. The write resets ``plot_rotation`` to 0 (orientation rides on
the media's width/height); headlessly, with no ``margins_mm`` asked
for, the turned margins are re-written un-turned so the sheet keeps
the margins it had, and on the live engine (margins come from the
.pc3) the ones that moved show in ``changed``. ``setup["paper_units"]``
(``"mm"`` default, ``"inches"``, or ``None`` to keep the layout's
units as AutoCAD's own Page Setup does) is written when given; a move
rescales every paper-space unit by 25.4 at the plot scale and is named
in ``changed`` *and* ``warnings`` (``papers.page_setup_warnings``).
Refuses ``Model``, an unknown layout, and a ``setup`` missing a
resolved key, before anything is written. A write the engine refuses
part-way through is unwound (every landed value put back) before the
``ValueError`` names the refused property. Never deletes viewports.
"""
...
@abstractmethod
async def plot_style_list(self) -> list[dict]:
"""``[{name, source: "catalog"|"installed", installed: bool|None}]`` —
the authored ctb catalogue plus, on a live engine, the files present
in the plot style search path. ``installed`` is ``None`` where no
installation can be scanned."""
...
@abstractmethod
async def drawing_template_save(
self, path: str, name: str | None = None, description: str | None = None
) -> dict:
"""Save the current drawing as a template.
``.dxf`` writes on both engines; ``.dwt`` writes on the live engine
(``SaveAs(path, ac2018_Template)``) and raises
``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns
``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``.
"""
...
+777
View File
@@ -0,0 +1,777 @@
"""Planning, critique, snapping and construction geometry.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from backends.quarantine import DocumentQuarantineError
if TYPE_CHECKING:
from backends.base import EntityInfo
from engineering.plan_spec import (
CritiqueFocus,
DimStyle,
Issue,
LayerSetId,
PlanSpec,
SheetSize,
SnapType,
)
class PremiumContract(ABC):
# These are CONCRETE and backend-agnostic: they orchestrate the abstract
# primitives above plus the shared EntityInfo.properties contract (start/
# end/center/radius/start_angle/end_angle — populated identically by both
# backends), so the ezdxf and COM backends inherit identical premium
# behaviour. The only backend-specific primitive is `_create_xline` — XLINE
# has no cross-backend equivalent among the standard entity_create_* tools.
_plan_spec: PlanSpec | None = None
_preflight_result: Any = None
# The standard `drawing_apply_iso_layers` last bootstrapped on *this*
# drawing (None until it runs) — `_active_layer_set_id` reads it ahead of
# the PlanSpec. Drawing-scoped like the plan: a live backend clears it
# when AutoCAD's active document changes, the headless registry keeps one
# per open document.
_active_layer_set: LayerSetId | None = None
def _reset_document_state(self) -> None:
"""Clear metadata that is valid only for the current drawing."""
self._plan_spec = None
self._preflight_result = None
self._active_layer_set = None
self._gdt_datums_defined = set()
self._gdt_datums_referenced = set()
async def _ensure_document_state(self) -> None:
"""Allow live backends to detect an out-of-band document switch."""
return None
async def drawing_preflight(
self,
intent: str,
requirements: dict | None = None,
sheet_size: SheetSize = "A3",
scale: float = 1.0,
layer_set_id: LayerSetId = "mech",
view_count: int = 1,
dim_style: DimStyle = "chain",
allow_assumptions: bool = False,
):
from engineering.preflight import preflight_drawing
await self._ensure_document_state()
result = preflight_drawing(
intent,
requirements,
sheet_size,
scale,
layer_set_id,
view_count,
dim_style,
allow_assumptions=allow_assumptions,
)
self._preflight_result = result
return result
async def drawing_plan(
self,
intent: str,
sheet_size: SheetSize = "A3",
scale: float = 1.0,
layer_set_id: LayerSetId = "mech",
view_count: int = 1,
dim_style: DimStyle = "chain",
notes: list[str] | None = None,
requirements: dict | None = None,
spec_hash: str | None = None,
) -> PlanSpec:
"""Commit a drawing intent before any geometry is created.
Returns a PlanSpec and stores it on the backend; read it back with
`get_plan_spec()`. (N8: the previous claim that `drawing_critique`
replays this plan was untrue — critique runs geometric checks, not a
plan diff.)"""
from engineering.plan_spec import PlanSpec
await self._ensure_document_state()
preflight_status = "skipped"
normalized_plan: dict[str, Any] | None = None
if spec_hash is not None:
from engineering.preflight import preflight_drawing
preflight = getattr(self, "_preflight_result", None)
if preflight is None or preflight.spec_hash != spec_hash:
raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.")
if not preflight.ready:
raise RuntimeError(
"drawing_plan: preflight is not ready; resolve questions/conflicts first."
)
candidate = preflight_drawing(
intent,
requirements
if requirements is not None
else preflight.normalized_spec["requirements"],
sheet_size,
scale,
layer_set_id,
view_count,
dim_style,
)
if candidate.spec_hash != spec_hash:
raise RuntimeError(
"drawing_plan: supplied fields do not match the hashed preflight spec."
)
normalized_plan = preflight.normalized_spec
preflight_status = "ready"
if normalized_plan is not None:
intent = normalized_plan["intent"]
sheet_size = normalized_plan["sheet_size"]
scale = normalized_plan["scale"]
layer_set_id = normalized_plan["layer_set_id"]
view_count = normalized_plan["view_count"]
dim_style = normalized_plan["dim_style"]
requirements = normalized_plan["requirements"]
plan = PlanSpec(
intent=str(intent),
sheet_size=sheet_size,
scale=float(scale),
layer_set_id=layer_set_id,
view_count=int(view_count),
dim_style=dim_style,
notes=list(notes) if notes else [],
requirements=dict(requirements or {}),
spec_hash=spec_hash,
preflight_status=preflight_status,
)
self._plan_spec = plan
return plan
async def drawing_critique(
self,
focus: list[CritiqueFocus] | None = None,
) -> list[Issue]:
"""Run premium-quality geometric checks; return zero issues before
`drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT
replay the committed PlanSpec — it inspects the live drawing geometry.)"""
from engineering.critique import run_critique
await self._ensure_document_state()
return await run_critique(self, focus)
def get_plan_spec(self) -> dict | None:
"""Return the PlanSpec committed by `drawing_plan` as a dict, or None
if no plan has been committed. (N8: makes the stored `_plan_spec`
actually readable instead of being write-only dead state.)"""
plan = getattr(self, "_plan_spec", None)
return plan.to_dict() if plan is not None else None
async def point_from_snap(
self,
handle: str,
snap: SnapType,
ref_x: float | None = None,
ref_y: float | None = None,
) -> tuple[float, float]:
"""Return a deterministic snap point on `handle`.
snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`,
`ref_x/y` is the reference point. Sources geometry from the shared
EntityInfo.properties contract — works on every backend."""
ent = await self.entity_get(handle)
et = ent.type
p = ent.properties
ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None
def _line_pts():
s = p.get("start")
e = p.get("end")
if s is None or e is None:
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.")
return (float(s[0]), float(s[1])), (float(e[0]), float(e[1]))
def _circle_geom():
c = p.get("center")
r = p.get("radius")
if c is None or r is None:
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.")
return float(c[0]), float(c[1]), float(r)
def _arc_angles():
# N7: use .get() + descriptive RuntimeError (matching _line_pts /
# _circle_geom) instead of bracket indexing that raises an opaque
# KeyError when start_angle/end_angle are missing.
sa = p.get("start_angle")
ea = p.get("end_angle")
if sa is None or ea is None:
raise RuntimeError(
f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle."
)
return math.radians(float(sa)), math.radians(float(ea))
if snap == "end":
if et == "LINE":
s, e = _line_pts()
if ref is None:
return s
ds = (s[0] - ref[0]) ** 2 + (s[1] - ref[1]) ** 2
de = (e[0] - ref[0]) ** 2 + (e[1] - ref[1]) ** 2
return s if ds <= de else e
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_angles()
sp = (cx + r * math.cos(sa), cy + r * math.sin(sa))
ep = (cx + r * math.cos(ea), cy + r * math.sin(ea))
if ref is None:
return sp
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
return sp if ds <= de else ep
raise RuntimeError(f"point_from_snap: 'end' snap not supported on {et}")
if snap == "mid":
if et == "LINE":
s, e = _line_pts()
return ((s[0] + e[0]) / 2.0, (s[1] + e[1]) / 2.0)
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_angles()
if ea < sa:
ea += 2 * math.pi
ma = (sa + ea) / 2.0
return (cx + r * math.cos(ma), cy + r * math.sin(ma))
raise RuntimeError(f"point_from_snap: 'mid' snap not supported on {et}")
if snap == "center":
if et in ("CIRCLE", "ARC", "ELLIPSE"):
c = p.get("center")
if c is None:
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.")
return (float(c[0]), float(c[1]))
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
# NEW-snap-quad-arc / NEW-snap-near-arc: an ARC only covers part of the
# full circle, so quad/near candidates derived from the circle may fall
# outside the [start_angle, end_angle] sweep. Restrict (quad) / clamp
# (near) the projected angle to the sweep, with 2π wraparound.
def _arc_sweep():
sa, ea = _arc_angles()
if ea < sa:
ea += 2 * math.pi
return sa, ea
def _angle_in_sweep(theta: float, sa: float, ea: float) -> bool:
# Normalise theta into [sa, sa+2π) and test against [sa, ea].
t = sa + ((theta - sa) % (2 * math.pi))
return t <= ea + 1e-9
def _arc_endpoints(cx: float, cy: float, r: float, sa: float, ea: float):
return (
(cx + r * math.cos(sa), cy + r * math.sin(sa)),
(cx + r * math.cos(ea), cy + r * math.sin(ea)),
)
if snap == "quad":
if et == "CIRCLE":
cx, cy, r = _circle_geom()
pts = [(cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r)]
if ref is None:
return pts[0]
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_sweep()
quad_angles = (0.0, math.pi / 2.0, math.pi, 3 * math.pi / 2.0)
pts = [
(cx + r * math.cos(a), cy + r * math.sin(a))
for a in quad_angles
if _angle_in_sweep(a, sa, ea)
]
if not pts:
# No quadrant lies on the sweep — fall back to arc endpoints.
pts = list(_arc_endpoints(cx, cy, r, sa, ea))
if ref is None:
return pts[0]
return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2)
raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}")
if snap == "perp":
if ref is None:
raise RuntimeError("point_from_snap: 'perp' requires ref_x/ref_y.")
if et == "LINE":
s, e = _line_pts()
dx = e[0] - s[0]
dy = e[1] - s[1]
L2 = dx * dx + dy * dy
if L2 < 1e-18:
raise RuntimeError("point_from_snap: line has zero length.")
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
return (s[0] + t * dx, s[1] + t * dy)
raise RuntimeError(f"point_from_snap: 'perp' snap not supported on {et}")
if snap == "near":
if ref is None:
raise RuntimeError("point_from_snap: 'near' requires ref_x/ref_y.")
if et == "LINE":
s, e = _line_pts()
dx = e[0] - s[0]
dy = e[1] - s[1]
L2 = dx * dx + dy * dy
if L2 < 1e-18:
return s
t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2
t = max(0.0, min(1.0, t))
return (s[0] + t * dx, s[1] + t * dy)
if et == "CIRCLE":
cx, cy, r = _circle_geom()
vx, vy = ref[0] - cx, ref[1] - cy
L = math.hypot(vx, vy)
if L < 1e-18:
return (cx + r, cy)
return (cx + r * vx / L, cy + r * vy / L)
if et == "ARC":
cx, cy, r = _circle_geom()
sa, ea = _arc_sweep()
vx, vy = ref[0] - cx, ref[1] - cy
L = math.hypot(vx, vy)
if L < 1e-18:
# Degenerate ref at center: return the start endpoint.
return (cx + r * math.cos(sa), cy + r * math.sin(sa))
theta = math.atan2(vy, vx)
if _angle_in_sweep(theta, sa, ea):
return (cx + r * math.cos(theta), cy + r * math.sin(theta))
# Projected angle is off the sweep — fall back to the nearer endpoint.
sp, ep = _arc_endpoints(cx, cy, r, sa, ea)
ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2
de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2
return sp if ds <= de else ep
raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}")
if snap == "int":
raise RuntimeError(
"point_from_snap: 'int' (intersection) requires a 2nd entity; "
"compute via two endpoints. V2 will accept a 2nd handle."
)
raise RuntimeError(
f"point_from_snap: unknown snap type '{snap}'. "
"Use one of: end, mid, center, quad, perp, near."
)
async def point_intersection(
self,
handle1: str,
handle2: str,
ref_x: float | None = None,
ref_y: float | None = None,
) -> tuple[float, float]:
"""Return the intersection point of two geometry entities.
Supports LINE-LINE, LINE-CIRCLE, and CIRCLE-CIRCLE cases. When
multiple intersections exist (e.g. two circles), ``ref_x``/``ref_y``
selects the candidate nearest to that reference point. If omitted the
first candidate is returned.
Raises RuntimeError when no real intersection exists or entity types
are unsupported.
"""
info1 = await self.entity_get(handle1)
info2 = await self.entity_get(handle2)
t1, t2 = info1.type.upper(), info2.type.upper()
props1, props2 = info1.properties, info2.properties
candidates: list[tuple[float, float]] = []
if t1 == "LINE" and t2 == "LINE":
# Line-line intersection via parametric form
x1, y1 = props1["start"][0], props1["start"][1]
x2, y2 = props1["end"][0], props1["end"][1]
x3, y3 = props2["start"][0], props2["start"][1]
x4, y4 = props2["end"][0], props2["end"][1]
dx1, dy1 = x2 - x1, y2 - y1
dx2, dy2 = x4 - x3, y4 - y3
denom = dx1 * dy2 - dy1 * dx2
if abs(denom) < 1e-12:
raise RuntimeError("point_intersection: lines are parallel or coincident.")
t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom
candidates = [(x1 + t * dx1, y1 + t * dy1)]
elif {t1, t2} == {"LINE", "CIRCLE"}:
line_p = props1 if t1 == "LINE" else props2
circ_p = props2 if t1 == "LINE" else props1
lx1, ly1 = line_p["start"][0], line_p["start"][1]
lx2, ly2 = line_p["end"][0], line_p["end"][1]
cx, cy = circ_p["center"][0], circ_p["center"][1]
r = circ_p["radius"]
dx, dy = lx2 - lx1, ly2 - ly1
fx, fy = lx1 - cx, ly1 - cy
a = dx * dx + dy * dy
b = 2.0 * (fx * dx + fy * dy)
c = fx * fx + fy * fy - r * r
disc = b * b - 4.0 * a * c
if disc < 0:
raise RuntimeError("point_intersection: line does not intersect circle.")
sq = math.sqrt(disc)
for sign in (1, -1):
t = (-b + sign * sq) / (2.0 * a)
candidates.append((lx1 + t * dx, ly1 + t * dy))
elif t1 == "CIRCLE" and t2 == "CIRCLE":
cx1, cy1, r1 = props1["center"][0], props1["center"][1], props1["radius"]
cx2, cy2, r2 = props2["center"][0], props2["center"][1], props2["radius"]
dx, dy = cx2 - cx1, cy2 - cy1
d = math.hypot(dx, dy)
if d < 1e-12:
raise RuntimeError("point_intersection: circles are concentric.")
if d > r1 + r2 + 1e-9 or d < abs(r1 - r2) - 1e-9:
raise RuntimeError("point_intersection: circles do not intersect.")
a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d)
h_sq = r1 * r1 - a * a
h = math.sqrt(max(h_sq, 0.0))
mx = cx1 + a * dx / d
my = cy1 + a * dy / d
candidates = [
(mx + h * dy / d, my - h * dx / d),
(mx - h * dy / d, my + h * dx / d),
]
else:
raise RuntimeError(
f"point_intersection: unsupported entity combination {t1}+{t2}. "
"Supported: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE."
)
if len(candidates) == 1:
return candidates[0]
if ref_x is not None and ref_y is not None:
return min(
candidates,
key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2,
)
return candidates[0]
async def point_tangent(
self,
circle_handle: str,
from_x: float,
from_y: float,
ref_x: float | None = None,
ref_y: float | None = None,
) -> tuple[float, float]:
"""Return the tangent point on a circle from an external point.
When two tangent points exist, ``ref_x``/``ref_y`` selects the
nearer one; without it, the first is returned (counter-clockwise from
the from-point—circle-center line).
Raises RuntimeError if the point is inside the circle.
"""
info = await self.entity_get(circle_handle)
if info.type.upper() != "CIRCLE":
raise RuntimeError(
f"point_tangent: handle {circle_handle!r} is {info.type}, expected CIRCLE."
)
cx, cy = info.properties["center"][0], info.properties["center"][1]
r = info.properties["radius"]
fx, fy = float(from_x), float(from_y)
dx, dy = cx - fx, cy - fy
d = math.hypot(dx, dy)
if d < r - 1e-9:
raise RuntimeError(
f"point_tangent: from-point ({fx}, {fy}) is inside the circle "
f"(center ({cx}, {cy}), radius {r})."
)
if d < 1e-12:
raise RuntimeError("point_tangent: from-point coincides with circle center.")
# Tangent point T lies on the circle such that (T-F)⊥(T-C).
# Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two
# circle angles θ at which the tangent touches the perimeter.
alpha = math.atan2(dy, dx) # direction from F to C
# NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the
# narrow band [r-1e-9, r) (past the internal-point guard but with
# |-r/d| marginally > 1 from float error) degenerates gracefully to the
# tangent-at-perimeter case instead of raising a raw ValueError
# ("math domain error"). Genuine internal points (d < r) are already
# rejected by the RuntimeError guard above.
acos_neg = math.acos(max(-1.0, min(1.0, -r / d))) # (π/2, π] for external pt
candidates: list[tuple[float, float]] = [
(cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)),
(cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)),
]
if ref_x is not None and ref_y is not None:
return min(
candidates,
key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2,
)
return candidates[0]
@abstractmethod
async def _create_xline(
self,
x: float,
y: float,
dx: float,
dy: float,
layer: str,
) -> EntityInfo:
"""Create an infinite construction line (XLINE) through (x, y) with
unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no
cross-backend entity_create_* equivalent."""
...
async def _ensure_layer(self, name: str, color: int = 7) -> None:
"""Idempotently make sure `name` exists (no-op if already present)."""
try:
existing = {lyr.name for lyr in await self.layer_list()}
except Exception:
existing = set()
if name not in existing:
try:
await self.layer_create(name, color=color)
except Exception:
pass # already created / racy create — harmless
def _active_layer_set_id(self) -> str:
"""Best-effort active layer set: an explicit `drawing_apply_iso_layers`
wins, else the committed PlanSpec, else 'mech' (the default bootstrap)."""
explicit = getattr(self, "_active_layer_set", None)
if explicit:
return explicit
plan = getattr(self, "_plan_spec", None)
if plan is not None:
return getattr(plan, "layer_set_id", "mech")
return "mech"
def _role_layer(self, role: str) -> str:
"""Resolve the layer name for a role ('dim' | 'construction') in the
active layer set, so dims/scaffolding land on the right layer for
iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION)."""
from engineering.layers import resolve_role_layer
return resolve_role_layer(self._active_layer_set_id(), role)
async def construction_xline(
self,
x: float,
y: float,
angle_deg: float,
layer: str | None = None,
) -> EntityInfo:
"""Create an infinite construction line (XLINE) on the active layer set's
construction layer (auto-created with color 250 = lightest if missing)."""
layer = layer or self._role_layer("construction")
await self._ensure_layer(layer, color=250)
ang = math.radians(float(angle_deg))
return await self._create_xline(
float(x),
float(y),
math.cos(ang),
math.sin(ang),
layer,
)
async def construction_clear(
self,
layer: str | None = None,
) -> dict:
"""Delete every entity on the active layer set's construction layer.
Must be called before `drawing_finalize`."""
layer = layer or self._role_layer("construction")
try:
ents = await self.entity_list(layer_filter=layer, limit=5000)
except DocumentQuarantineError:
# "deleted: 0" here would read as "there was nothing to clear" on a
# document the backend has declared untrusted -- and this is step 9
# of the standard workflow, immediately before finalize.
raise
except Exception:
ents = []
deleted = 0
for ent in ents:
try:
await self.entity_delete(ent.handle)
deleted += 1
except Exception:
continue
return {"ok": True, "layer": layer, "deleted": deleted}
async def drawing_apply_iso_layers(
self,
standard: LayerSetId = "mech",
) -> dict:
"""Bootstrap a full ISO-conformant layer set with correct colors
and lineweights. Idempotent."""
from engineering.layers import apply_layer_set
result = await apply_layer_set(self, standard)
self._active_layer_set = standard
return {"ok": True, "standard": standard, "layers": result}
async def dimension_auto(
self,
handles: list[str],
style: DimStyle = "chain",
offset: float = 10.0,
layer: str | None = None,
) -> list[EntityInfo]:
"""Generate ISO 129 dimensions across `handles` in the chosen style
(chain | baseline | ordinate). `offset` is the dimension-line
distance from the reference geometry (mm). `layer` defaults to the
active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567)."""
if not handles:
return []
dim_layer = layer or self._role_layer("dim")
if style not in ("chain", "baseline", "ordinate"):
raise RuntimeError(
f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'."
)
segs: list[tuple[float, float, float, float]] = []
for h in handles:
ent = await self.entity_get(h)
if ent.type != "LINE":
raise RuntimeError(
f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})."
)
s = ent.properties.get("start")
e = ent.properties.get("end")
if s is None or e is None:
raise RuntimeError(f"dimension_auto: line {h} missing start/end.")
segs.append((float(s[0]), float(s[1]), float(e[0]), float(e[1])))
off = float(offset)
results: list[EntityInfo] = []
if style == "chain":
# Each line gets its own linear (rotated) dimension, offset clear of
# the geometry along its own perpendicular.
for sx, sy, ex, ey in segs:
mx = (sx + ex) / 2.0
my = (sy + ey) / 2.0
dx, dy = ex - sx, ey - sy
L = math.hypot(dx, dy) or 1.0
nx, ny = -dy / L, dx / L
dim = await self.dimension_linear(
sx,
sy,
ex,
ey,
mx + nx * off,
my + ny * off,
rotation=math.degrees(math.atan2(dy, dx)),
layer=dim_layer,
)
results.append(dim)
elif style == "baseline":
sx0, sy0, ex0, ey0 = segs[0]
dx, dy = ex0 - sx0, ey0 - sy0
L0 = math.hypot(dx, dy) or 1.0
ux, uy = dx / L0, dy / L0
base_rot = math.degrees(math.atan2(dy, dx))
for i, (_sx, _sy, ex, ey) in enumerate(segs):
dim = await self.dimension_linear(
sx0,
sy0,
ex,
ey,
sx0 - uy * (off + i * off),
sy0 + ux * (off + i * off),
# Dimension line must run parallel to the (possibly rotated)
# baseline, not horizontal.
rotation=base_rot,
layer=dim_layer,
)
results.append(dim)
else: # ordinate — stacked X/Y distances; each feature offset so dims don't overlap
for i, (sx, sy, ex, ey) in enumerate(segs):
rung = off + i * off # stagger per feature to avoid coincident dims
if abs(ex - sx) > 1e-9:
dim = await self.dimension_linear(
sx,
sy,
ex,
sy,
sx + (ex - sx) / 2.0,
max(sy, ey) + rung,
layer=dim_layer,
)
results.append(dim)
if abs(ey - sy) > 1e-9:
dim = await self.dimension_linear(
ex,
sy,
ex,
ey,
max(sx, ex) + rung,
(sy + ey) / 2.0,
rotation=90.0,
layer=dim_layer,
)
results.append(dim)
return results
async def entity_select_smart(
self,
predicate: dict,
) -> list[EntityInfo]:
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
- type: "LINE" | "CIRCLE" | ...
- layer: layer name
- near: [x, y, radius] — entity reference point within circle
- length_range: [min, max] — applies to LINE/ARC
- color: ACI int
"""
if not isinstance(predicate, dict):
raise RuntimeError("entity_select_smart: predicate must be a dict.")
ptype = predicate.get("type")
player = predicate.get("layer")
pnear = predicate.get("near") # [x, y, radius]
plen = predicate.get("length_range") # [min, max]
pcolor = predicate.get("color")
ents = await self.entity_list(
type_filter=ptype.upper() if isinstance(ptype, str) else None,
layer_filter=player if isinstance(player, str) else None,
limit=5000,
)
def _ok(e: EntityInfo) -> bool:
if pcolor is not None and int(e.color) != int(pcolor):
return False
if pnear is not None:
try:
nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2])
except Exception:
return False
pt = (
e.properties.get("start")
or e.properties.get("center")
or e.properties.get("insertion")
or e.properties.get("insertion_point")
)
if pt is None:
return False
if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr**2:
return False
if plen is not None and e.type in ("LINE", "ARC"):
try:
lmin, lmax = float(plen[0]), float(plen[1])
except Exception:
return False
length = e.properties.get("length")
if length is None or not (lmin <= float(length) <= lmax):
return False
return True
return [e for e in ents if _ok(e)]
+103
View File
@@ -0,0 +1,103 @@
"""External references, raster images and DWG output (v1.6 tracks B+G, group G).
These are the only four operations of the sheet track that genuinely differ per
engine, so they are the only four that become contract members. Everything else
in `engineering/sheet/` composes the generic members and is written once.
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
#: An xref is attached (its own xrefs come with it) or overlaid (they do not).
XREF_KINDS = ("attach", "overlay")
#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live
#: seat and are refused with the `xref_live` capability on the ezdxf engine.
XREF_ACTIONS = ("list", "reload", "bind", "detach", "path")
#: DWG formats both engines name the same way. COM maps them to AcSaveAsType
#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through
#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more.
DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
class RefsContract(ABC):
@abstractmethod
async def xref_attach(
self,
path: str,
at,
scale: float,
rotation: float,
kind: str,
) -> dict:
"""Attach an external reference and insert it once.
The block name is the referenced file's stem, which is what both
engines do. Returns ``{ok, name, path, kind, handle, at, scale,
rotation, backend}``.
Refuses, before anything is written: a `kind` outside
:data:`XREF_KINDS`, a file that does not exist, a non-finite
coordinate/scale/rotation, a non-positive scale, and a block name the
drawing already holds (re-attaching would either silently replace the
definition or raise mid-write).
"""
...
@abstractmethod
async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict:
"""One of :data:`XREF_ACTIONS` on one external reference.
``list`` ignores `name` and returns
``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``kind`` and
``inserts`` are ``None`` on a live seat -- ActiveX's ``IAcadBlock``
carries no overlay indicator and no per-xref insert count (measured on
the registered type library), and a constant would read as measured.
``path`` rewrites the saved path to `new_path` and returns the new one.
``detach`` removes every insert of the xref -- in *every* entity space,
model space and each paper-space layout, because an insert left behind
with no block definition is a corrupt drawing -- then the definition,
and reports ``inserts_removed``. ``inserts`` counts the same set.
``reload`` and ``bind`` need a live seat's xref manager: the headless engine raises
``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending,
which is why the key exists; ``list``/``detach``/``path`` are *not*
behind it.
Refuses an action outside the vocabulary, an unknown xref name, and a
``path`` action with no `new_path`.
"""
...
@abstractmethod
async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict:
"""Attach a raster image as an underlay.
`scale` is drawing units per pixel, so the placed size is the image's
pixel size times `scale` and the aspect ratio is the file's. Returns
``{ok, path, handle, at, scale, rotation, pixels: [w, h],
size_mm: [w, h], backend}``.
Refuses a file that does not exist, a file no image reader can open
(naming it), a non-positive scale and a non-finite number -- all before
anything is written.
"""
...
@abstractmethod
async def drawing_export_dwg(self, path: str, version: str) -> dict:
"""Write the current drawing as a real DWG.
Returns ``{ok, path, version, bytes, backend}``. Refuses a `version`
outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA
File Converter and otherwise raises
``UnsupportedCapabilityError("dwg_write", ...)`` naming the install
route; on a live seat it is ``Document.SaveAs`` with the matching
``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the
session bound to the file it just wrote.
"""
...
+684
View File
@@ -0,0 +1,684 @@
"""Friendly facade over system variables.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
v1.6 (track E) grows the facade from 15 keys to 29: drawing limits, grid and
snap, ortho and polar tracking, PSLTSCALE, the annotation scale, linear and
angular unit formats, and the current dimension / text style. Every key still
goes through ``system_get_variable`` / ``system_set_variable`` so the facade
stays backend-agnostic; the *engines* are where a variable's real home is
known (header, VPORT, the variable dictionary, or — headlessly — nowhere).
"""
from __future__ import annotations
import math
import re
from typing import Any
class SettingsContract:
# A user-facing wrapper over system_get_variable / system_set_variable that
# maps memorable names ("units", "dimscale", …) to AutoCAD system variables.
# Concrete + backend-agnostic: both engines accept the bare sysvar name, so
# the same call reads/writes on live COM and headless ezdxf alike.
async def drawing_settings(self, settings: dict | None = None) -> dict:
"""Read (no args) or apply (with args) common drawing settings.
With ``settings=None`` returns a snapshot of every known setting. With a
dict, applies each provided key and returns ``{applied, changed,
errors}`` — ``changed`` holds ``[old, new]`` only for keys whose value
actually moved (re-setting a value to itself is not a change).
Friendly keys: units, linear_precision, angular_precision, ltscale,
dimscale, dim_text_height, dim_arrow_size, dim_decimals,
decimal_separator, zero_suppression, text_size, point_mode, point_size,
osmode, fillet_radius, limits, grid, grid_spacing, snap, snap_spacing,
ortho, polar, polar_angle, psltscale, annotation_scale, linear_units,
angular_units, dimstyle, textstyle.
Refusals (per key, in ``errors``; the other keys still apply): an
unknown key; a value outside ``_SETTING_RANGES``; a malformed
``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file
headlessly (no OBJECTS section, so the value would vanish at save —
save as R2000 or newer first); ``osmode`` / ``polar`` / ``polar_angle``
on the headless engine (registry-saved — ``capability: registry_sysvar``;
a snapshot reports them as ``None`` there, a file holds no value);
``dimstyle`` / ``textstyle`` when the backend has no styles contract.
Note that a no-argument snapshot is one ``system_get_variable`` per key
(two for ``limits``). Free headless; a COM round trip each on a live
seat, and a write costs a read before and after it for ``changed``."""
if not settings:
snapshot: dict = {}
for key, spec in _SETTING_MAP.items():
try:
snapshot[key] = await self._read_setting(key, spec)
except Exception as exc:
snapshot[key] = {"error": str(exc)}
return {"ok": True, "settings": snapshot}
applied: dict = {}
changed: dict = {}
errors: dict = {}
for key, value in settings.items():
spec = _SETTING_MAP.get(key)
if spec is None:
errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
continue
try:
before = await self._read_setting(key, spec)
await self._write_setting(key, spec, value)
after = await self._read_setting(key, spec)
applied[key] = value
if after != before:
changed[key] = [before, after]
except Exception as exc:
errors[key] = str(exc)
result = {"ok": not errors, "applied": applied, "changed": changed}
if errors:
result["errors"] = errors
return result
async def _read_setting(self, key: str, spec: tuple[str, str]) -> Any:
var, kind = spec
if kind == "limits":
low = await self.system_get_variable("LIMMIN")
high = await self.system_get_variable("LIMMAX")
if low is None or high is None:
return None
return [_point2(low), _point2(high)]
return _decode_setting(key, kind, await self.system_get_variable(var))
async def _write_setting(self, key: str, spec: tuple[str, str], value: Any) -> None:
var, kind = spec
if kind == "limits":
low, high = _encode_limits(key, value) # validated before either write
await self.system_set_variable("LIMMIN", low)
await self.system_set_variable("LIMMAX", high)
return
if kind == "name":
name = _encode_name(key, value)
setter = getattr(self, _STYLE_SETTERS[key], None)
if not callable(setter):
raise ValueError(
f"{key}: this backend has no {_STYLE_SETTERS[key]} (styles contract "
f"absent); the current {key} can be read but not changed here."
)
await setter(name)
return
if kind == "bit":
flag = _encode_bool(key, value)
raw = await self.system_get_variable(var)
current = int(raw) if raw is not None else 0
mask = _BIT_MASKS[key]
await self.system_set_variable(var, (current | mask) if flag else (current & ~mask))
return
await self.system_set_variable(var, _encode_setting(key, kind, value))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Friendly setting name -> (AutoCAD system variable, value kind).
_SETTING_MAP: dict[str, tuple[str, str]] = {
"units": ("INSUNITS", "units"),
"linear_precision": ("LUPREC", "int"),
"angular_precision": ("AUPREC", "int"),
"ltscale": ("LTSCALE", "float"),
"dimscale": ("DIMSCALE", "float"),
# The dimension variables had no friendly name until v1.5.2, so the only
# way to reach dimension lettering was a raw `system_set_variable` — which
# is precisely the path that silently did nothing on the headless engine.
# `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
# never had anything to do with the numbers on a dimension.
"dim_text_height": ("DIMTXT", "float"),
"dim_arrow_size": ("DIMASZ", "float"),
"dim_decimals": ("DIMDEC", "int"),
"decimal_separator": ("DIMDSEP", "char"),
"zero_suppression": ("DIMZIN", "int"),
"text_size": ("TEXTSIZE", "float"),
"point_mode": ("PDMODE", "int"),
"point_size": ("PDSIZE", "float"),
# OSMODE is registry-saved (a DXF R2000+ file has no $OSMODE), so the
# headless engine refuses it like the polar pair below.
"osmode": ("OSMODE", "int"),
"fillet_radius": ("FILLETRAD", "float"),
# ── track E: the environment a drawing lives in ──────────────────────
# `limits` is one friendly key over two variables; `_read_setting` and
# `_write_setting` special-case the kind, the variable named here is the
# first of the pair.
"limits": ("LIMMIN", "limits"),
# GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT are not header variables in a
# DXF file — AutoCAD keeps them on the active VPORT — so the headless engine
# routes them there (see `EzdxfBackend.system_set_variable`).
"grid": ("GRIDMODE", "bool"),
"grid_spacing": ("GRIDUNIT", "spacing"),
"snap": ("SNAPMODE", "bool"),
"snap_spacing": ("SNAPUNIT", "spacing"),
"ortho": ("ORTHOMODE", "bool"),
# Polar tracking on/off is AUTOSNAP bit 8 (the F10 toggle), not POLARMODE
# — POLARMODE's bits choose *how* polar angles are measured. Both AUTOSNAP
# and POLARANG are registry-saved, so a headless file cannot hold them.
"polar": ("AUTOSNAP", "bit"),
"polar_angle": ("POLARANG", "degrees"),
"psltscale": ("PSLTSCALE", "bool"),
# CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary, not a header
# variable; CANNOSCALEVALUE is derived from it and read-only.
"annotation_scale": ("CANNOSCALE", "scale"),
"linear_units": ("LUNITS", "lunits"),
"angular_units": ("AUNITS", "aunits"),
# The current style names read through the header / GetVariable; a write
# delegates to the styles contract (`dimstyle_set_current` /
# `textstyle_set_current`) because DIMSTYLE is a read-only variable.
"dimstyle": ("DIMSTYLE", "name"),
"textstyle": ("TEXTSTYLE", "name"),
}
_STYLE_SETTERS = {"dimstyle": "dimstyle_set_current", "textstyle": "textstyle_set_current"}
#: AUTOSNAP bit 8 = polar tracking on. (1 marker, 2 magnet, 4 tooltip, 16
#: object snap tracking, 32 tracking tooltips, 64 dynamic input aperture.)
_BIT_MASKS = {"polar": 8}
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
_UNIT_TO_CODE: dict[str, int] = {
"unitless": 0,
"inch": 1,
"inches": 1,
"in": 1,
"feet": 2,
"ft": 2,
"foot": 2,
"mm": 4,
"millimeter": 4,
"millimeters": 4,
"cm": 5,
"centimeter": 5,
"centimeters": 5,
"m": 6,
"meter": 6,
"meters": 6,
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
# LUNITS (linear unit format) and AUNITS (angular unit format) code tables.
_LUNITS_TO_CODE: dict[str, int] = {
"scientific": 1,
"decimal": 2,
"engineering": 3,
"architectural": 4,
"fractional": 5,
}
_CODE_TO_LUNITS: dict[int, str] = {code: name for name, code in _LUNITS_TO_CODE.items()}
_AUNITS_TO_CODE: dict[str, int] = {
"degrees": 0,
"dms": 1,
"grads": 2,
"radians": 3,
"surveyor": 4,
}
_CODE_TO_AUNITS: dict[int, str] = {code: name for name, code in _AUNITS_TO_CODE.items()}
#: 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 = (".", ",")
#: "1:50", "2:1", "1:2.5" — paper units : drawing units, both strictly positive.
SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")
def parse_scale(text: Any) -> tuple[str, float, float]:
"""``"1:50"`` → ``("1:50", 1.0, 50.0)``; raises ``ValueError`` naming the input.
Returns the canonical name (whitespace stripped, integers without a
trailing ``.0``), the paper units and the drawing units. The annotation
scale value AutoCAD reports as CANNOSCALEVALUE is ``paper / drawing``.
"""
match = SCALE_RE.match(str(text)) if not isinstance(text, bool) else None
if match is None:
raise ValueError(
f"annotation_scale: {text!r} is not a scale — use 'paper:drawing' such as "
"'1:1', '1:50' or '2:1'."
)
paper, drawing = float(match.group(1)), float(match.group(2))
if paper <= 0 or drawing <= 0:
raise ValueError(f"annotation_scale: both sides of {text!r} must be greater than 0.")
return f"{_trim(paper)}:{_trim(drawing)}", paper, drawing
def scale_value(text: Any) -> float:
"""CANNOSCALEVALUE for a scale name: ``"1:50"`` → ``0.02``."""
_, paper, drawing = parse_scale(text)
return paper / drawing
def _trim(number: float) -> str:
return str(int(number)) if number == int(number) else f"{number:g}"
def _point2(raw: Any) -> list[float]:
"""A 2D point from whatever the engine returned (tuple, list, Vec2/Vec3)."""
try:
return [float(raw[0]), float(raw[1])]
except (TypeError, IndexError, ValueError) as exc:
raise ValueError(f"expected a point, got {raw!r}") from exc
def _finite(key: str, value: Any, what: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{key}: {what} must be a number, got {value!r}")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{key}: {what} must be finite")
return number
def _encode_limits(key: str, value: Any) -> tuple[tuple[float, float], tuple[float, float]]:
"""``[[xmin, ymin], [xmax, ymax]]`` → two 2D tuples; refused before any write."""
if (
not isinstance(value, (list, tuple))
or len(value) != 2
or any(not isinstance(corner, (list, tuple)) or len(corner) != 2 for corner in value)
):
raise ValueError(f"{key}: expected [[xmin, ymin], [xmax, ymax]], got {value!r}")
xmin = _finite(key, value[0][0], "xmin")
ymin = _finite(key, value[0][1], "ymin")
xmax = _finite(key, value[1][0], "xmax")
ymax = _finite(key, value[1][1], "ymax")
if xmax <= xmin or ymax <= ymin:
raise ValueError(
f"{key}: the upper-right corner must be above and right of the lower-left "
f"(got {[[xmin, ymin], [xmax, ymax]]})"
)
return (xmin, ymin), (xmax, ymax)
_TRUE_WORDS = {"1", "on", "true", "yes"}
_FALSE_WORDS = {"0", "off", "false", "no"}
def _encode_bool(key: str, value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and value in (0, 1):
return bool(value)
if isinstance(value, str):
word = value.strip().lower()
if word in _TRUE_WORDS:
return True
if word in _FALSE_WORDS:
return False
raise ValueError(f"{key}: expected true/false (or 1/0, on/off), got {value!r}")
def _encode_name(key: str, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{key}: expected a non-empty style name, got {value!r}")
return value.strip()
def _encode_enum(key: str, value: Any, table: dict[str, int]) -> int:
if isinstance(value, (int, float)) and not isinstance(value, bool):
code = int(value)
if code in table.values():
return code
raise ValueError(f"{key}: code {code} is not one of {sorted(table.values())}")
name = str(value).strip().lower()
if name in table:
return table[name]
raise ValueError(f"{key}: unknown value {value!r}. Use one of {sorted(table)}.")
def _encode_setting(key: str, kind: str, value: Any) -> Any:
"""Coerce a friendly value into the raw system-variable value."""
if kind == "units":
if isinstance(value, (int, float)) and not isinstance(value, bool):
return int(value)
code = _UNIT_TO_CODE.get(str(value).strip().lower())
if code is None:
raise ValueError(
f"units: unknown value {value!r}. Use one of "
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
)
return code
if kind == "lunits":
return _encode_enum(key, value, _LUNITS_TO_CODE)
if kind == "aunits":
return _encode_enum(key, value, _AUNITS_TO_CODE)
if kind == "bool":
return 1 if _encode_bool(key, value) else 0
if kind == "scale":
name, _paper, _drawing = parse_scale(value)
return name
if kind == "spacing":
if isinstance(value, (list, tuple)):
if len(value) != 2:
raise ValueError(f"{key}: expected a number or [x, y], got {value!r}")
x = _in_range(key, _finite(key, value[0], "x"))
y = _in_range(key, _finite(key, value[1], "y"))
return (x, y)
step = _in_range(key, _finite(key, value, "spacing"))
return (step, step)
if kind == "degrees":
degrees = _in_range(key, _finite(key, value, "angle"))
return math.radians(degrees)
if kind == "char":
if isinstance(value, bool):
marker = ""
elif isinstance(value, (int, float)):
code = int(value)
marker = chr(code) if 0 < code < 0x110000 else ""
else:
marker = str(value).strip()
if marker not in _DECIMAL_MARKERS:
raise ValueError(
f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
)
return ord(marker)
if kind == "int":
return _in_range(key, int(float(value)))
if kind == "float":
return _in_range(key, float(value))
return value
#: AutoCAD's own ranges for the settings that now reach the renderer.
#:
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
#: header. Folding it into the per-dimension override is what gave it teeth:
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
#: with `ValueError: Format specifier missing precision` raised from inside
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
#: printed forty digits of float noise onto the sheet; and a non-positive
#: length was reported applied and silently discarded. `applied` has to mean
#: applied.
_SETTING_RANGES: dict[str, tuple[float, float]] = {
"dim_decimals": (0, 8), # DIMDEC
"zero_suppression": (0, 15), # DIMZIN is a bitmask
"linear_precision": (0, 8), # LUPREC
"angular_precision": (0, 8), # AUPREC
"dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive
"dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive
"text_size": (1e-9, 1e6), # TEXTSIZE
"dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal
"ltscale": (1e-9, 1e6),
"point_size": (-100, 1e6), # negative is a percentage of the screen
"fillet_radius": (0, 1e6),
# track E
"grid_spacing": (1e-9, 1e9), # GRIDUNIT, strictly positive per axis
"snap_spacing": (1e-9, 1e9), # SNAPUNIT, strictly positive per axis
"polar_angle": (1e-9, 360), # POLARANG increment, degrees at the facade
}
def _in_range(key: str, value: float) -> float:
"""Refuse a value the renderer cannot use, naming the range."""
bounds = _SETTING_RANGES.get(key)
if bounds is None:
return value
low, high = bounds
if not low <= value <= high:
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
raise ValueError(
f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
)
return value
def _decode_setting(key: str, kind: str, raw: Any) -> Any:
"""Present a raw system-variable value in friendly form."""
if raw is None:
return None
if kind == "units":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
if kind == "lunits":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_LUNITS.get(code, "unknown")}
if kind == "aunits":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_AUNITS.get(code, "unknown")}
if kind == "bool":
try:
return bool(int(raw))
except (TypeError, ValueError):
return raw
if kind == "bit":
try:
return bool(int(raw) & _BIT_MASKS[key])
except (TypeError, ValueError):
return raw
if kind == "degrees":
try:
return round(math.degrees(float(raw)), 6)
except (TypeError, ValueError):
return raw
if kind == "spacing":
try:
x, y = _point2(raw)
except ValueError:
return raw
return x if x == y else [x, y]
if kind == "scale":
try:
name, paper, drawing = parse_scale(raw)
except ValueError:
return {"name": str(raw), "value": None}
return {"name": name, "value": paper / drawing}
if kind == "name":
return str(raw)
if kind == "char":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
# ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
# so report the marker that would actually be drawn.
return "," if code == 0 else chr(code)
if kind == "int":
try:
return int(raw)
except (TypeError, ValueError):
return raw
if kind == "float":
try:
return float(raw)
except (TypeError, ValueError):
return raw
return raw
# ---------------------------------------------------------------------------
# Document properties (DWGPROPS) — validation shared by both engines
# ---------------------------------------------------------------------------
#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab.
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
#: The printable characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects
#: anywhere inside a custom key with ``Invalid key``. Measured on AutoCAD 2026
#: by sweeping every character 0x20-0x7E through ``AddCustomInfo`` on a scratch
#: document: exactly these thirteen are refused; every other printable ASCII
#: character, every control character (0x01-0x1F, including tab), internal
#: spaces, a no-break space and other unicode are accepted. Leading or
#: trailing whitespace (space, tab or no-break space) is refused separately.
_CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|')
def custom_key_fold(key: str) -> str:
"""Fold a custom-property key the way AutoCAD compares them.
AutoCAD's ``AddCustomInfo`` / ``SetCustomByKey`` / ``RemoveCustomByKey``
match keys by a *simple* one-to-one case compare, not Unicode full case
folding. Measured on AutoCAD 2026 (``AddCustomInfo`` of the second
spelling over the first): ``Project``/``PROJECT``, ``Grün``/``GRÜN``,
``é``/``É``, ``ÿ``/``Ÿ``, ``ł``/``Ł``, ``σ``/``Σ``, ``я``/``Я`` and
``dž``/``DŽ`` are the same key (``Duplicate key``), while ``Straße``/
``STRASSE``, ``ẞ``/``ß``, ``kelvin``/``Kelvin`` (Kelvin sign), ``µ``/``Μ``,
``σς``/``ΣΣ`` and ``Dž``/``DŽ`` are distinct (both stored) — every one of
which Python's ``str.casefold()`` calls equal. So this folds each character
to its lowercase only when the mapping is a single character that
round-trips (``lower().upper() == upper()``, ``upper().lower() == lower()``
and the character is one of the two); anything else — ``ß`` (uppercases
to ``SS``), the final sigma ``ς``, the micro sign ``µ``, the Kelvin sign,
the title-case digraph ``Dž``, dotless ``ı`` and dotted ``İ`` — is kept as
is. The result never changes length. This is what the headless engine and
the validator match on; the live engine lets AutoCAD decide (see
``ComBackend.drawing_properties_set``).
"""
out: list[str] = []
for c in key:
lo, up = c.lower(), c.upper()
if (
len(lo) == 1
and len(up) == 1
and lo.upper() == up
and up.lower() == lo
and c in (lo, up)
):
out.append(lo)
else:
out.append(c)
return "".join(out)
def _check_custom_text(key: str, text: str, what: str) -> None:
"""A newline in a custom key or value corrupts the DXF on save.
ezdxf writes custom properties verbatim as ``9 / $CUSTOMPROPERTYTAG / 1 /
{key}`` and ``9 / $CUSTOMPROPERTY / 1 / {value}`` header lines — a line
break inside either text starts a new (invalid) group and the reopened
file fails with ``DXFStructureError``. Refused here so neither engine
writes it.
"""
if "\n" in text or "\r" in text:
raise ValueError(
f"custom[{key!r}]: the {what} contains a line break, which corrupts the "
"DXF on save (the whole drawing would fail to reopen, not just the "
"property). Custom properties are single-line."
)
def _check_custom_key_for_write(key: str) -> None:
"""Mirror AutoCAD's ``AddCustomInfo`` key syntax so the live engine cannot
refuse mid-write. Measured on AutoCAD 2026: ``Invalid key`` for a key with
leading/trailing whitespace (space, tab, no-break space) or any of
``_CUSTOM_KEY_FORBIDDEN`` anywhere in it — raised after the summary fields
and the earlier keys were already applied, while the headless engine
writes the same key without complaint. Only a *write* goes through
``AddCustomInfo``; ``RemoveCustomByKey`` does not validate syntax (measured:
``Key not found``, never ``Invalid key``), so a delete is not held to these
rules — a key that reached the drawing by other means (a DXF written by
ezdxf, say) must stay removable."""
if key != key.strip():
raise ValueError(
f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's "
"AddCustomInfo rejects it as 'Invalid key'. Strip the key."
)
bad = sorted(set(key) & _CUSTOM_KEY_FORBIDDEN)
if bad:
shown = " ".join(repr(c) for c in bad)
raise ValueError(
f"custom: key {key!r} contains {shown} — AutoCAD's AddCustomInfo rejects "
f"it as 'Invalid key' (refused characters: {''.join(sorted(_CUSTOM_KEY_FORBIDDEN))}). "
"Use another separator."
)
def validate_drawing_properties(
summary: dict | None, custom: dict | None
) -> tuple[dict[str, str], dict[str, str], list[str]]:
"""→ (summary fields to write, custom keys to write, custom keys to delete).
Refuses the whole request by name before either engine writes anything:
an unknown summary field is a ``ValueError``; a non-string summary value,
a non-string or empty custom key, or a custom value that is neither a
string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never
coerced with ``str()`` — a number the caller meant as text is theirs to
format.
Custom keys and values are also held to the rules the *other* engine would
enforce or the file format would break on, so the same call cannot succeed
on one engine and half-apply on the other (``ValueError`` naming the key):
* a key to *write* with leading or trailing whitespace, or containing any
of the thirteen characters ``" * , / : ; < = > ? \\ ` |`` — AutoCAD's
``AddCustomInfo`` rejects exactly these as ``Invalid key`` mid-write
(measured on AutoCAD 2026 over every printable ASCII character), after
the summary fields and the earlier keys were already applied; the
headless engine writes them without complaint, and AutoCAD then loads
them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey``
never validates syntax, and such keys do reach drawings by other routes,
so they must stay removable;
* two keys in one request that AutoCAD would treat as the same key — its
key compare is a simple one-to-one case compare (``custom_key_fold``;
measured: ``AddCustomInfo("PROJECT")`` over an existing ``Project``
raises ``Duplicate key``, while ``Straße`` and ``STRASSE`` are two
keys), so the second would fail mid-write on the live engine and write
a second tag headlessly;
* a key or value containing a line break (LF or CR) — ezdxf emits custom
properties unescaped, one header line per text, so the saved DXF is
corrupt and the whole drawing fails to reopen (``DXFStructureError``),
not just the property.
"""
if summary is not None and not isinstance(summary, dict):
raise TypeError("summary: must be an object of {field: text}")
written: dict[str, str] = {}
for field, value in (summary or {}).items():
if field not in SUMMARY_FIELDS:
raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})")
if value is None:
continue
if not isinstance(value, str):
raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}")
written[field] = value
if custom is not None and not isinstance(custom, dict):
raise TypeError("custom: must be an object of {key: text | null}")
to_write: dict[str, str] = {}
to_delete: list[str] = []
seen: dict[str, str] = {} # folded key (AutoCAD's rule) -> the spelling seen first
for key, value in (custom or {}).items():
if not isinstance(key, str) or not key.strip():
raise TypeError(f"custom: key {key!r} must be a non-empty string")
_check_custom_text(key, key, "key")
folded = custom_key_fold(key)
if folded in seen:
raise ValueError(
f"custom: keys {seen[folded]!r} and {key!r} differ only by case — "
"AutoCAD compares custom keys with a simple per-character case "
"compare and calls these the same key (AddCustomInfo raises "
"'Duplicate key'). Mention the key once."
)
seen[folded] = key
if value is None:
to_delete.append(key)
elif isinstance(value, str):
_check_custom_key_for_write(key)
_check_custom_text(key, value, "value")
to_write[key] = value
else:
raise TypeError(
f"custom[{key!r}]: must be a string or null (null deletes the key), "
f"got {type(value).__name__}"
)
return written, to_write, to_delete
+79
View File
@@ -0,0 +1,79 @@
"""ACIS solids (live AutoCAD only).
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class SolidContract(ABC):
@abstractmethod
async def solid_box(
self, cx: float, cy: float, cz: float, length: float, width: float, height: float
) -> dict:
"""Create a 3D solid box centered at (cx, cy, cz)."""
...
@abstractmethod
async def solid_cylinder(
self, cx: float, cy: float, cz: float, radius: float, height: float
) -> dict:
"""Create a 3D solid cylinder centered at (cx, cy, cz)."""
...
@abstractmethod
async def solid_extrude(
self, profile_handle: str, height: float, taper_angle: float = 0.0
) -> dict:
"""Extrude a closed profile (circle/closed polyline) into a solid."""
...
@abstractmethod
async def solid_revolve(
self,
profile_handle: str,
axis_x1: float,
axis_y1: float,
axis_x2: float,
axis_y2: float,
angle: float = 360.0,
) -> dict:
"""Revolve a closed profile around an axis into a solid."""
...
@abstractmethod
async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict:
"""Boolean two solids: operation is union | subtract | intersect."""
...
@abstractmethod
async def drawing_audit(self) -> dict:
"""Audit the drawing, applying every repair the backend can make.
This MUTATES the drawing. Returns ``{"ok", "repaired", "fixes",
"fix_count", "errors", "error_count"}``, where ``fixes`` are problems
already repaired and ``errors`` are problems that could not be; both are
lists of ``{"code": int, "name": str, "message": str, "handle": str |
None}``.
A backend that repairs but cannot observe its own result reports
``repaired``/``fix_count``/``error_count`` as ``None`` — never ``0``,
which would claim nothing was wrong — plus ``"detail": "unavailable"``
and ``"capability": "audit_detail"`` so a client can pre-check the
boundary. Any implementation that repairs MUST mark the document dirty,
or the repair is discarded the next time the document is closed.
"""
...
@abstractmethod
async def drawing_close(self, save: bool = True) -> dict: ...
@abstractmethod
async def drawing_undo(self) -> dict: ...
@abstractmethod
async def drawing_redo(self) -> dict: ...
+136
View File
@@ -0,0 +1,136 @@
"""Dimension, text and multileader styles (track E, group S).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
Names are matched case-insensitively — AutoCAD's rule for every symbol
table — and reported in the table's own spelling. Every refusal is raised
before a table is touched: ``ValueError`` naming the key (a missing style, a
clash, a variable outside ``engineering.standards.dimstyles.DIM_VARIABLE_RANGES``),
``TypeError`` for a wrong type.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class StylesContract(ABC):
@abstractmethod
async def dimstyle_list(self) -> list[dict]:
"""Every DIMSTYLE entry: ``[{name, current, values, values_available}]``.
``values`` holds the seventeen preset variables
(``engineering.standards.dimstyles.PRESET_VARIABLES``) with ``DIMDSEP``
as the character. Headless every row carries them (the DXF's stored
value, else the schema default AutoCAD applies to an omitted code).
On the live engine ActiveX has no per-style getters — ``AcadDimStyle``
exposes ``Name`` and ``CopyFrom`` only — so ``values`` is read for the
*current* style and the other rows carry ``values: None`` with
``values_available: False`` rather than cycling the active style
behind the operator's back.
"""
...
@abstractmethod
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
"""Create a dimension style from a fully resolved ``values`` dict
(``engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)``).
Returns ``{ok, name, values, written, current, textstyle_created}``.
``DIMTXSTY`` must name a text style that exists or a bundled preset
(ISOCP / ISOCPEUR / ARIAL / ROMANS), which is created first and
reported in ``textstyle_created``; anything else is refused by name.
A name clash is refused (``dimstyle_modify`` changes an existing
style). With ``set_current`` the style becomes ``$DIMSTYLE`` *and* its
values become the current ``DIM*`` settings — what a live seat does —
so the next dimension carries them on both engines.
"""
...
@abstractmethod
async def dimstyle_modify(self, name: str, values: dict) -> dict:
"""Change variables on an existing style.
Returns ``{ok, name, changed: {VAR: [old, new]}, dimensions_using_style,
rerender_required}``. ``changed`` names only the variables that moved
(the ``hatch_edit`` rule: re-setting a value to itself is not a
change). Dimensions already drawn with the style are listed by handle;
``rerender_required`` is ``True`` headlessly when any exist and
something changed, because a headless DIMENSION carries its rendered
block and shows the old style until it is redrawn — AutoCAD re-renders
on the next regen, so the live engine reports ``False``. Modifying the
current style also updates the ``DIM*`` current settings.
"""
...
@abstractmethod
async def dimstyle_set_current(self, name: str) -> dict:
"""Make ``name`` the current dimension style.
Returns ``{ok, current, previous, changed}``. Sets ``$DIMSTYLE`` and
loads the style's stored variables into the current ``DIM*`` settings
(AutoCAD's ``-DIMSTYLE Restore``; on the live engine unsaved overrides
of the previous style are discarded, which is AutoCAD's own rule).
The dimension tools then create dimensions with it; the per-dimension
override path (tolerances, fits, text override) is unchanged.
"""
...
@abstractmethod
async def textstyle_list(self) -> list[dict]:
"""``[{name, font, height, width_factor, oblique_deg, current}]``."""
...
@abstractmethod
async def textstyle_create(
self,
name: str,
font: str,
height: float = 0.0,
width_factor: float = 1.0,
oblique_deg: float = 0.0,
set_current: bool = False,
) -> dict:
"""Create a text style. Returns ``{ok, name, font, font_resolved, current}``.
``font`` is a preset (``ISOCP`` / ``isocp.shx`` / ``isocp``, any case)
or any file name. A file that is neither a preset nor found on the
engine's font path is *written* and reported ``font_resolved: False``
— a DXF stores only the name and AutoCAD substitutes at open — never
refused. Refuses a clash, ``width_factor <= 0``, ``height < 0``, an
oblique angle outside ±85°, an empty font.
"""
...
@abstractmethod
async def textstyle_set_current(self, name: str) -> dict:
"""``$TEXTSTYLE``. Returns ``{ok, current, previous, changed}``; new
TEXT/MTEXT then carry the style on both engines."""
...
@abstractmethod
async def mleaderstyle_list(self) -> list[dict]:
"""``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``.
Both engines report the four values (``values_available: True``).
Headless they are read from the MLEADERSTYLE object (schema defaults
for an unset field); live from the ``AcadMLeaderStyle`` objects the
``ACAD_MLEADERSTYLE`` dictionary holds (``ArrowSize`` / ``LandingGap``
/ ``TextHeight`` in drawing units, ``TextStyle`` a name) — ActiveX
exposes no *collection* property for them, but the dictionary items
are full ``IAcadMLeaderStyle`` objects.
"""
...
@abstractmethod
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
"""Create a multileader style from a resolved ``values`` dict
(``engineering.standards.mleaderstyles.resolve_mleaderstyle``).
Both engines: ezdxf on ``doc.mleader_styles``; live through
``Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name,
"AcDbMLeaderStyle")`` and the object's property writes. Returns
``{ok, name, values, textstyle_created}``; the text style follows the
``dimstyle_create`` rule (exists or preset, else refused by name); a
clash is refused — before anything is written."""
...
+28
View File
@@ -0,0 +1,28 @@
"""System variables and raw commands.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class SystemContract(ABC):
@abstractmethod
async def system_status(self) -> dict: ...
@abstractmethod
async def system_get_variable(self, name: str) -> Any: ...
@abstractmethod
async def system_set_variable(self, name: str, value: Any) -> dict: ...
@abstractmethod
async def system_run_command(self, command: str) -> dict: ...
@abstractmethod
async def system_run_lisp(self, expression: str) -> dict: ...
+21
View File
@@ -0,0 +1,21 @@
"""Transactions.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class TransactionContract(ABC):
@abstractmethod
async def transaction_begin(self) -> dict: ...
@abstractmethod
async def transaction_commit(self) -> dict: ...
@abstractmethod
async def transaction_rollback(self) -> dict: ...
+43
View File
@@ -0,0 +1,43 @@
"""Views and screenshots.
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class ViewContract(ABC):
@abstractmethod
async def view_zoom_extents(self) -> dict: ...
@abstractmethod
async def view_zoom_window(
self,
x1: float,
y1: float,
x2: float,
y2: float,
) -> dict: ...
@abstractmethod
async def view_screenshot(self, overlay_handles: bool = False) -> bytes | None: ...
@abstractmethod
async def view_screenshot_grounded(
self,
overlay_handles: bool = True,
max_labels: int = 40,
) -> dict:
"""Screenshot plus the handle-to-position mapping it drew.
Returns ``{"png", "labels", "labelled", "total", "truncated"}``.
``labels`` maps handle -> ``[x, y]`` in drawing units, so the picture and
the handle space a caller can act on are the same thing. ``truncated``
says a crowded drawing was capped rather than letting a labelled subset
read as the whole sheet.
"""
...
+6972 -263
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
"""OCS (Object Coordinate System) to WCS translation, shared by both backends.
DXF does not store 2D geometry in world coordinates. CIRCLE, ARC, LWPOLYLINE,
TEXT, INSERT and a dimension's text position live in a per-entity frame derived
from the entity's ``extrusion`` vector by the Arbitrary Axis Algorithm. Mirroring
flips extrusion to ``(0, 0, -1)`` and leaves the stored x *unnegated*, so a raw
``dxf.center`` read reports a point the entity is not at — by 60 mm in the
measured case, while the same response's bounding box (which ezdxf computes
through the renderer, in WCS) said otherwise.
The rule this module exists to enforce: **everything crossing the MCP boundary is
WCS.** OCS never leaves the backends.
Both engines share this. ``ezdxf`` is a core dependency, not an extra, so the COM
backend may import it too — and should, rather than hand-rolling the Arbitrary
Axis Algorithm a second time. The functions take a raw extrusion/normal triple
rather than an entity, because a COM object exposes ``.Normal`` where an ezdxf
one exposes ``.dxf.extrusion``.
Not every entity that *carries* extrusion is affected: ezdxf makes ``ocs()`` a
pass-through for LINE, MTEXT, ELLIPSE, SPLINE and POINT, which are WCS-native.
Keying any fix off "has an extrusion attribute" breaks those.
"""
from __future__ import annotations
import math
from ezdxf.math import OCS, Vec3
_IDENTITY = (0.0, 0.0, 1.0)
def is_wcs_frame(extrusion) -> bool:
"""True when the entity's frame is the identity — nothing to translate."""
try:
return OCS(Vec3(extrusion)).uz.isclose(_IDENTITY)
except Exception:
return True # an unreadable extrusion is not evidence of a rotated frame
def is_flat_frame(extrusion) -> bool:
"""True when the entity's plane is parallel to WCS XY (extrusion is +/-Z).
Only these frames admit an exact 2D write: given a tilted plane, an ``(x, y)``
pair does not determine a point in it, so writing one would displace the
entity out of its own plane by an amount nobody supplied.
"""
try:
uz = OCS(Vec3(extrusion)).uz
except Exception:
return True
return abs(uz.x) < 1e-12 and abs(uz.y) < 1e-12
def to_wcs_2d(extrusion, x, y, z=0.0) -> list[float]:
"""OCS ``(x, y, z)`` to WCS ``[x, y]``.
``z`` is ``dxf.elevation`` for an LWPOLYLINE (whose vertices carry no z of
their own) and the point's own z otherwise.
"""
v = OCS(Vec3(extrusion)).to_wcs(Vec3(float(x), float(y), float(z)))
return [float(v.x), float(v.y)]
def from_wcs(extrusion, x, y, z=0.0) -> tuple[float, float, float]:
"""WCS ``(x, y, z)`` to OCS ``(x, y, z)`` — the inverse of :func:`to_wcs_2d`.
Needed on every write path. The read and write halves used to be *equally*
wrong, which made a read-then-write round trip an accidental no-op; fixing
reads alone would have converted that no-op into a real move.
"""
v = OCS(Vec3(extrusion)).from_wcs(Vec3(float(x), float(y), float(z)))
return (float(v.x), float(v.y), float(v.z))
def wcs_arc_angles(extrusion, center_ocs, radius, start_deg, end_deg) -> tuple[float, float]:
"""OCS sweep angles to CCW-in-WCS angles about the WCS centre.
Computed by mapping the two endpoints through the frame rather than by
arithmetic on the angles, so it stays right for any frame. A left-handed
frame (``uz.z < 0``, which is what a mirror produces) reverses the direction
of the sweep, so the endpoints swap roles.
"""
ocs = OCS(Vec3(extrusion))
c0 = Vec3(center_ocs)
centre = ocs.to_wcs(c0)
angles = []
for angle in (float(start_deg), float(end_deg)):
point = ocs.to_wcs(Vec3.from_deg_angle(angle, abs(float(radius))) + c0)
angles.append(math.degrees(math.atan2(point.y - centre.y, point.x - centre.x)) % 360.0)
if ocs.uz.z < 0:
angles.reverse()
return angles[0], angles[1]
def plane_normal(extrusion) -> list[float]:
"""The entity's plane normal, for reporting a frame that xy cannot express."""
uz = OCS(Vec3(extrusion)).uz
return [float(uz.x), float(uz.y), float(uz.z)]
def wcs_bulge(extrusion, bulge) -> float:
"""An LWPOLYLINE vertex bulge as the arc reads in WCS XY.
The bulge is ``tan(sweep/4)``, signed counter-clockwise *in the entity's
own frame*. A left-handed frame (``uz.z < 0`` — what a mirror produces) is
a reflection of WCS XY, and a reflection reverses every arc's sense: the
same stored ``+1`` that bowed to -y before ``entity_mirror`` bows to +y
after it, while ``points`` translated through :func:`to_wcs_2d` land where
the vertices really are. Handing the stored sign out next to translated
points describes the arc's mirror image, and nothing in the payload can
show it (``length`` is frame-invariant). Same rule as
:func:`wcs_arc_angles` reversing an ARC's endpoints.
The magnitude is left alone: it is exact for every flat frame, and in a
tilted plane the projected arc is no longer circular at all, which
``plane_normal`` already reports.
"""
value = float(bulge or 0.0)
if value == 0.0:
return 0.0
try:
return -value if OCS(Vec3(extrusion)).uz.z < 0 else value
except Exception:
return value # an unreadable extrusion is not evidence of a reflected frame
+208
View File
@@ -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,
}
+196
View File
@@ -0,0 +1,196 @@
"""XDATA value typing and limits, shared by both engines.
DXF XDATA is a list of (group code, value) pairs under a registered APPID.
This module is the one place that decides how a JSON value becomes a group
code and enforces the two limits AutoCAD enforces silently: 255 characters
per string and 16 KB per *entity* -- across every application on it, the ACAD
app's own overrides (DSTYLE on a toleranced dimension) included, not just the
app being written. ``encode_values`` refuses a single call that cannot fit;
``check_entity_budget`` is the real gate, run by both engines against the
entity's existing XDATA before anything is written, so the headless engine
refuses exactly the write the live engine's ``SetXData`` would reject.
It also refuses the values neither engine can store: a line separator inside a
string, and a non-finite or non-numeric "number". DXF is a text format of
alternating "group code" / "value" lines, and ezdxf writes a 1000 tag verbatim,
so a LF or CR in the value splits the tag pair and the saved file -- and every
undo/transaction snapshot, which is also a DXF save -- becomes unreadable. A
``nan``/``inf`` real is written as the literal text ``nan``/``inf``, which is
not a DXF double, and a string coerced into a point coordinate is a silent
wrong number; both are refused before any write, the same way
``backends/block_specs.py`` refuses them for block primitives.
"""
from __future__ import annotations
import math
import re
from collections.abc import Iterable, Mapping
APP_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,30}$")
RESERVED_APPS = {"ACAD"}
MAX_STRING = 255
MAX_BYTES = 16 * 1024
INT_MIN, INT_MAX = -(2**31), 2**31 - 1
LINE_SEPARATORS = ("\n", "\r")
Tag = tuple[int, object]
def validate_app_name(app) -> str:
if not isinstance(app, str) or not APP_NAME_RE.match(app):
raise ValueError(f"xdata: app name must match {APP_NAME_RE.pattern}, got {app!r}")
if app.upper() in RESERVED_APPS:
raise ValueError(f"xdata: app name {app!r} is reserved")
return app
def _real(value, where: str) -> float:
"""A finite float from an int or float; strings, bools and nan/inf are refused."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"xdata: {where} must be a number, got {value!r}")
try:
number = float(value)
except OverflowError:
number = math.inf
if not math.isfinite(number):
raise ValueError(f"xdata: {where} must be finite, got {value!r}")
return number
def tag_size(code: int, value) -> int:
"""Bytes one XDATA tag occupies in AutoCAD's accounting (what ``xdsize`` reports).
Every tag carries a one-byte group code; strings (1000-1004) add a two-byte
length, reals are 8 bytes, points 3 reals, a 32-bit int 4, a 16-bit int 2,
a handle 8. The 1001 application marker is a string and counts like one.
"""
if code in (1000, 1001, 1002, 1003, 1004, 1005):
if isinstance(value, str):
return 3 + len(value.encode("utf-8"))
if isinstance(value, (bytes, bytearray, list, tuple)): # 1004 binary chunk
return 3 + len(value)
return 3 + len(str(value).encode("utf-8"))
if code in (1010, 1011, 1012, 1013):
return 1 + 3 * 8
if code in (1040, 1041, 1042):
return 1 + 8
if code == 1070:
return 1 + 2
if code == 1071:
return 1 + 4
raise ValueError(f"xdata: {code} is not an XDATA group code")
def app_size(app: str, tags: Iterable[Tag]) -> int:
"""Bytes one application's XDATA occupies: its 1001 marker plus every tag.
``tags`` may or may not carry the 1001 marker (ezdxf stores it, its
``get_xdata`` strips it); it is counted exactly once either way.
"""
return tag_size(1001, app) + sum(tag_size(code, value) for code, value in tags if code != 1001)
def encode_values(values) -> list[Tag]:
"""JSON values → (group code, value). Raises TypeError/ValueError; writes nothing."""
if not isinstance(values, (list, tuple)):
raise TypeError("xdata: values must be a list")
tags: list[Tag] = []
for i, value in enumerate(values):
where = f"values[{i}]"
if isinstance(value, bool):
raise TypeError(f"xdata: {where} booleans have no XDATA group code")
if isinstance(value, str):
if len(value) > MAX_STRING:
raise ValueError(
f"xdata: {where} is {len(value)} characters; the limit is {MAX_STRING}"
)
if any(sep in value for sep in LINE_SEPARATORS):
raise ValueError(
f"xdata: {where} contains a newline; "
"line separators are not allowed in XDATA strings"
)
tags.append((1000, value))
elif isinstance(value, int):
if not INT_MIN <= value <= INT_MAX:
raise ValueError(f"xdata: {where} is outside the 32-bit integer range")
tags.append((1071, int(value)))
elif isinstance(value, float):
tags.append((1040, _real(value, where)))
elif isinstance(value, (list, tuple)) and len(value) in (2, 3):
coords = tuple(_real(c, f"{where}[{j}]") for j, c in enumerate(value))
if len(coords) == 2:
coords = (coords[0], coords[1], 0.0)
tags.append((1010, coords))
else:
raise TypeError(f"xdata: {where} must be a string, int, float or [x, y(, z)] point")
# A single call that cannot fit on an empty entity is refused here; the
# per-entity budget (existing apps included) is check_entity_budget().
size = sum(tag_size(code, value) for code, value in tags)
if size > MAX_BYTES:
raise ValueError(f"xdata: encoded size {size} bytes exceeds the 16 KB per-entity limit")
return tags
def check_entity_budget(
app: str, tags: Iterable[Tag], existing: Mapping[str, Iterable[Tag]]
) -> int:
"""Bytes the entity's XDATA occupies once ``app`` is replaced by ``tags``.
``existing`` is every application currently on the entity (ACAD included)
with its tags; ``app``'s own entry is what the write replaces, so it is
dropped from the sum. Raises ValueError over ``MAX_BYTES``; writes nothing.
An empty ``tags`` removes the app and is never refused, whatever the other
applications already hold (a file opened with too much XDATA must still be
shrinkable).
"""
tags = list(tags)
others = 0
for other, other_tags in existing.items():
if other == app:
continue
others += app_size(other, other_tags)
if not tags:
return others
total = app_size(app, tags) + others
if total > MAX_BYTES:
raise ValueError(
f"xdata: entity would carry {total} bytes of XDATA ({others} under other "
f"applications); the limit is 16 KB per entity across every application"
)
return total
def decode_values(tags) -> list:
"""(group code, value) → JSON values. Skips the 1001 app marker and 1002 braces."""
out: list = []
for code, value in tags:
if code in (1001, 1002):
continue
if code == 1010 or code in (1011, 1012, 1013):
out.append([float(c) for c in value])
elif code in (1040, 1041, 1042):
out.append(float(value))
elif code in (1070, 1071):
out.append(int(value))
else:
out.append(str(value))
return out
def split_tags_by_app(codes, values) -> dict[str, list[Tag]]:
"""A flat ActiveX GetXData stream (1001 markers inline) → {app: raw tags}."""
out: dict[str, list[Tag]] = {}
current: str | None = None
for code, value in zip(codes, values, strict=True):
if code == 1001:
current = str(value)
out.setdefault(current, [])
elif current is not None:
out[current].append((code, value))
return out
def split_by_app(codes, values) -> dict[str, list]:
"""A flat ActiveX GetXData stream (1001 markers inline) → {app: values}."""
return {app: decode_values(tags) for app, tags in split_tags_by_app(codes, values).items()}
+349 -23
View File
@@ -1,15 +1,17 @@
# Benchmarks
![Public AutoCAD MCP capability leaderboard](../docs/assets/autocad-mcp-benchmark.svg)
## Two evidence layers
This repository publishes two deliberately separate forms of evidence:
1. **Source-reviewed capability benchmark.** Six named AutoCAD MCP projects are
1. **Source-reviewed capability benchmark.** Nine named AutoCAD MCP projects are
scored with a fixed 100-point rubric covering CAD breadth, correctness and
delivery, backend reach, engineering production, tests, and security. The
dated data lives in [`source_review.json`](source_review.json), and the README
graphic is generated—not hand-edited—from that file.
2. **Fixed-task runtime benchmark.** Adapters execute the same ten tasks and
2. **Fixed-task runtime benchmark.** Adapters execute the same task matrix and
return `pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`.
AutoCAD MCP Pro currently has the reference adapter; other projects do not
receive runtime scores until an adapter actually runs their public interface.
@@ -24,28 +26,258 @@ python -m benchmarks.render_chart
The source-review chart is not presented as a shared live AutoCAD run. Review
dates, evidence grades, project URLs, and the boundary are stored with the data.
## Fixed-task runtime runner v2
## Fixed-task runtime runner
The v2 runner separates runtime evidence from the source-review rubric. Every
adapter receives the same ten tasks and produces the same closed result enum:
The runner separates runtime evidence from the source-review rubric. Every
adapter receives the same tasks and produces the same closed result enum:
`pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`. A timeout is
reported per task. Execution is in-process, so adapters that delegate blocking
work must provide cancellation-safe task implementations or be wrapped by an
external process supervisor. Reports include commit SHA, Python/platform
details, backend capability claims, durations, coverage, and hashes for returned
artifacts. Unsupported tasks remain in the fixed-matrix denominator with score
zero, preventing partial implementations from receiving an inflated score.
reported per task, and a capability refusal is recorded `unsupported` rather
than `fail` — "cannot reach this" and "got this wrong" are different findings.
Execution is in-process, so adapters that delegate blocking work must provide
cancellation-safe task implementations or be wrapped by an external process
supervisor. Reports include commit SHA, Python/platform details, backend
capability claims, durations, coverage, and hashes for returned artifacts.
Unsupported tasks remain in the fixed-matrix denominator with score zero,
preventing partial implementations from receiving an inflated score.
```bash
python -m benchmarks.run_competitors --list
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json
python -m benchmarks.run_competitors --task table_mleader --task auditable_delivery --json
python -m benchmarks.run_competitors --list --matrix v7
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v7 --json
python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json
```
The release-machine v1.3 ezdxf self-check is **10/10 (100.0)**. It is not a
competitor ranking. Cross-project scores become comparable only when another
adapter runs this exact task matrix; repository stars and raw tool counts do not
contribute to the score. Adapter registration lives in `competitors.yaml`.
The release-machine ezdxf self-check is **21/21 (100.0)** on the v7 matrix
(`--matrix v6` … `--matrix v2` reproduce the earlier sets); the published
report is [`results/published/autocad-mcp-pro.json`](results/published/autocad-mcp-pro.json).
Repository stars and raw tool counts do not contribute to the score. Adapter
registration lives in `competitors.yaml`.
### Matrix v3 (v1.5.0) — five tasks that can fail
v2 scored this server 10/10, and that number carried no information: every task
in it exercised something the server was designed around. `tasks_v3.py` keeps
the v2 ten unchanged — their ids and weights key the published v1.4 reports —
and adds five chosen because they *can* fail. Three of them did, while being
written:
| Task | Category | Verified against |
|---|---|---|
| `tool_discovery` | discovery | six AutoCAD command names, each found in the top 3 of the 154-tool registry the harness ranks — all six came back #1 in the published run |
| `token_budget` | efficiency | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
| `hatch_islands` | hatch | 300 filled with the island, 400 ignoring it |
| `selection_filter` | selection | window 1, crossing 2, bounding box 3, polygon 1 |
| `measure_from_handle` | measurement | 139.2699 against the 100.0 a vertex shoelace gives |
Token counts come from the offline ratio estimator and the report labels them
`ratio/v1 (estimate)`; `token_suite.py --tokenizer anthropic` counts for real.
**Two renames from the release plan, and they are the point.**
`tool_discovery_bilingual` → `tool_discovery`: a Turkish query normalizer was
written and then removed, because this repository is English throughout and the
Turkish was inferred from the author's chat language rather than required by the
product. `measurement_massprops` → `measure_from_handle`: no centroid or
moment-of-inertia tool ships.
**The competitor reports are not re-scored.** Those servers are pinned at v1.4
commits and were run against the v2 matrix; they have never been asked these
five questions. Their scores stand over the ten they ran, and the matrix chart
leaves the five new rows blank (`not run`) instead of scoring them zero — a
zero we invented would be indistinguishable from a zero we measured.
`--matrix v2` stays available so a v1.4 report can be reproduced rather than
only described, and `--publish` writes the artifact-path-free file that
`results/published/` holds.
### Matrix v4 (v1.6) — the P&ID round trip and the page-setup truth
`tasks_v4.py` keeps the v3 fifteen unchanged and adds two tasks that can each
fail on their own:
| Task | Category | Verified against |
|---|---|---|
| `pid_roundtrip` | pid | the example spec drawn through `pid_from_spec`'s code, then read back by the graph builder, which never sees the spec: 5 nodes, 4 edges, 0 dangling ends, `confidence_min` 1.0, zero critique issues, an instrument index of exactly `FIC-101` wired to `FCV-101`, and the two authored line numbers back on the two process lines while the two unnumbered lines carry sequence-built numbers from XDATA |
| `page_setup_truth` | pagesetup | `page_setup_apply("Layout1", ANSI_B landscape 1:1)` then `batch_plot` — the PDF's own `/MediaBox` must parse to 432 × 279 mm (±0.5), a size no fresh document has; then `page_setup_apply("Layout1", ISO_A3 landscape 1:1)` and `batch_plot` again — 420 × 297, the setter's `changed.size_mm` must read `[[432, 279], [420, 297]]`, the page-setup list must say `ISO_A3` / `landscape` / `[420, 297]`, and the ctb must be a known one. A setter that writes nothing fails at the first PDF (`tests/test_benchmark_v4.py` stubs it to prove that) |
The competitor reports carry no result for either, for the same reason they
carry none for the v3 five. `--matrix v3` reproduces the v1.5 set exactly.
### Matrix v5 (v1.6) — the mechanical sheet
`tasks_v5.py` keeps the v4 seventeen unchanged and adds roadmap criterion 3:
| Task | Category | Verified against |
|---|---|---|
| `mech_assembly` | mech | a flange-coupling A3 sheet built through the code the tools call — `apply_titleblock` (ISO 5457 frame with zones and marks, ISO 7200 title block), a hub drawn by `draw_part` with a full section by `add_view` and chain dimensions by `dimension_part`, a flange with its bolt-circle end view, two ISO 4014 M12x60 bolts and two ISO 4032 M12 nuts by `insert_std_part`, the ISO 7573 parts list *read off the drawing* by `extract_records` and drawn by `draw_bom_table`, and one balloon per row linked to that row's inserts by `add_balloon`. The sheet is saved, then gated on two numbers that cannot be negotiated: `run_critique(focus=None)` returns **zero** issues and `combine(validator, critique)` scores at least **90**. The focuses that fired come back in the metrics, and `tests/test_benchmark_v5.py` drops one balloon to prove the gate fails and names `mech_bom_balloon_mismatch` |
Nothing in the task tidies up after the tools: the view engine draws the ISO
128-23 centre lines of every circle it emits, so the sheet is clean because it
was drawn clean. Building it found four defects that every headless test had
missed — the headless diameter text on the wrong side of the chord, the
validator reading a fastener designation (`M12x60`) as a fake dimension, and on
the live seat a missing `MECH` layer and a parts list that could not see a
`BLOCKREFERENCE`; each is fixed with a test. The competitor reports carry no
result for it.
### Matrix v6 (v1.6) — the architectural plan
`tasks_v6.py` keeps the v5 eighteen unchanged and adds track F's evidence:
| Task | Category | Verified against |
|---|---|---|
| `arch_roundtrip` | arch | the plan of `engineering/arch/spec.py::EXAMPLE_SPEC` — a 9 × 6 m brick ring at 250 mm split by a 100 mm AAC wall, an entrance door, an interior door, two windows, a straight stair, two labelled rooms, the exterior chains and the door / window / room schedules — drawn in one transaction by `draw_plan_from_spec`, then read back without the spec: `rooms_detect` on the wall layer must find each labelled room within 0.1 % of the area its label carries **and** of the net floor computed by hand (4825 × 5750 = 27 743 750 and 3825 × 5750 = 21 993 750 mm²), `schedule_rows` must list exactly D1, D2 and W1, W2, and the saved plan is gated like `mech_assembly`: `run_critique(focus=None)` returns **zero** issues and `combine(validator, critique)` scores at least **90**. `tests/test_benchmark_v6.py` removes one room label to prove the gate fails and names `arch_room_unlabelled` |
The competitor reports carry no result for it.
### Matrix v7 (v1.6) — understanding foreign drawings
`tasks_v7.py` keeps the v6 nineteen unchanged and adds track H's evidence, both on the synthetic plant pair of `tests/fixtures/plant_pair.py` (invented names; its truth is computed from the positions it placed, never typed):
| Task | Category | Verified against |
|---|---|---|
| `takeoff_roundtrip` | plant | `scale_check(pid, layout)` must say **schematic** (room 1 is a 1.3× stretch, room 2 is rearranged: 21.8 % of the 55 tag pairs within ±10 % of the median ratio); `pipe_rows(..., length_source="auto")` must report all nine runs with the truth's status, each routable run's layout length equal to the rectilinear MST of its tags to a micron (PR1 5500, PR2 13200, CS1 20700 mm, …), its size and service as drawn — the CIP return drawn on the supply layer included — and both sizes across the reducer; `cable_rows` must give M11 / T102 / M12 / T202 their panels, 6900 / 14100 / 15800 / 8000 mm and 9 / 17 / 19 / 10 m after the 20 % allowance, the heater's power empty. `tests/test_benchmark_v7.py` deletes the wiring callouts and the gate names all four loads |
| `understand_foreign` | understand | `describe(layout)` must infer millimetres and warn about `INSUNITS` (the file says inches), name the stray line's handle in its extents and report two clusters (the plan is drawn twice, 100 m apart); `describe(pid)` must classify `P_product piping`, `P_cipsupplyline`, `P_cipreturnline`, `P_ijswater` and `E_power` as product, CIP supply, CIP return, ice water and electrical. `tests/test_benchmark_v7.py` makes the layout declare millimetres and the units gate fails alone |
The competitor reports carry no result for them.
## Live competitor lane (v1.4)
Two competitor adapters now execute the exact same task matrix, black-box over
MCP stdio, against commits pinned in `competitors.yaml`:
| Server | Matrix | Pinned | Score | Pass | Coverage |
|---|---|---|---:|---:|---:|
| autocad-mcp-pro (reference) | v7 (21) | 1.6.0 tree | 100.0 | 21/21 | 100% |
| beiming183-cloud/AutoCAD-MCP | v2 (10) | `11f7c47e` | 50.0 | 5/10 | 50% |
| puran-water/autocad-mcp | v2 (10) | `95476a33` | 45.0 | 4/10 | 50% |
The competitor rows are the v1.4 runs, unchanged. Read the matrix column
before the score column: the denominators differ because the eleven tasks
added since v2 came after those runs and have not been put to them.
Method and boundaries:
- `benchmarks/competitors_env.py` clones the pinned SHA into
`benchmarks/.competitors/<id>/` (gitignored), builds an isolated venv, and
installs the competitor from its own `pyproject.toml`.
- `benchmarks/adapters/mcp_stdio.py` drives the competitor exactly like an MCP
host (fastmcp `Client` over stdio). No competitor code is imported.
- Task playbooks call each server's **own documented tool contract**, read from
the pinned source (consolidated `operation` + payload tools; beiming's
`doc_id`/`expected_revision` discipline is honored via
`transaction(operation="context")`).
- **Verification never trusts the competitor's response**: every geometry claim
must survive `save`/`save_as_dxf` and re-opening the DXF with ezdxf inside
the harness.
- Tasks with no documented equivalent are reported `unsupported` with the
reason string; they stay in the fixed-matrix denominator at score zero.
- This lane is headless (ezdxf backends only). File-IPC / live-AutoCAD lanes
(including COM-only servers such as best-cad-mcp or daobataotie/CAD-MCP)
require a local AutoCAD session and are out of CI scope by design.
Published machine-readable reports (artifact paths sanitized to filenames)
live under [`results/published/`](results/published/); the README charts are
regenerated from them:
```bash
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf --matrix v2
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf --matrix v2
python -m benchmarks.render_live_chart # score bars
python -m benchmarks.render_matrix_chart # tasks x servers status heatmap
```
`--publish <path>` writes the same report with artifact paths reduced to
filenames, which is how `results/published/` is produced; the sha256 stays,
because that is what makes an artifact checkable.
## Headless performance lane
`perf_suite.py` measures wall time on fixed workloads through the same backend
methods the MCP tools call (server-side overhead included): 2,000 individual
line creates, a 10,000-entity build → DXF export → reopen roundtrip, a region
query over 10,000 entities, and a full premium quality pass (ISO layers,
geometry, dimensions, complete critique). The report records the machine
fingerprint; numbers move with hardware, workload definitions do not.
**Self-measurement only** — competitor servers would pay an extra stdio
serialization cost that in-process runs do not, so no cross-server timing
claims are made.
```bash
python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.json
python -m benchmarks.render_perf_chart
```
### v1.4.0 against v1.6.0, and what the call-timeout guard costs
The published v1.4 report was recorded on CPython 3.14 and the later ones on
3.11.15, so comparing those files directly shows an apparent speedup that is
almost entirely the interpreter. The comparison that means something runs one
interpreter and one ezdxf over each tree's own code: 1.6.0's `perf_suite.py`
(v1.4.0 had none) against a v1.4.0 worktree, against 1.6.0, and against 1.6.0
with the guard off — five rounds, the three configurations alternating within
a round. Medians, from
[`results/published/perf-v1.4.0-vs-v1.6.0.json`](results/published/perf-v1.4.0-vs-v1.6.0.json),
which keeps every run:
| Workload | v1.4.0 | v1.6.0 | v1.6.0, `EZDXF_CALL_TIMEOUT=0` |
|---|---:|---:|---:|
| `create_lines_2k` | 244.5 ms | 306.1 ms (1.25×) | 284.9 ms (1.17×) |
| `roundtrip_10k` | 1,847.9 ms | 2,553.5 ms (1.38×) | 2,216.4 ms (1.20×) |
| `region_query_10k` | 665.2 ms | 712.2 ms (1.07×) | 783.9 ms (1.18×) |
| `premium_pass` | 150.6 ms | 65.3 ms (0.43×) | 69.9 ms (0.46×) |
The guard explains part of the creation cost, not all of it. v1.5.1 rebuilt
the wrapper around one `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 its
overhead; the rest is the Task the abandon-on-timeout design needs. With the
guard off, creation is still 1.17× and 1.20× v1.4.0's time. A cProfile of the
event-loop thread over the 2,000 creates counts **491,071** function calls on
1.6.0 against **319,038** on v1.4.0: per-call work added since 1.4 that 1.6
has not removed. The region-query row, whose guard-off median is the slower
one, is this machine's noise floor (about ±10 %).
Roadmap criterion 7 — headless creation throughput back to v1.4.0's numbers —
is therefore **not met** and moves to 1.7. Earlier versions of this page said
that `EZDXF_CALL_TIMEOUT=0` returned creation to v1.4.0's numbers; measured
this way, it does not. The guard stays: it is what stops one hung ezdxf call
wedging a server whose document lock is a single `asyncio.Lock`.
`perf-ezdxf.json`, the chart's input, is the median of the five
default-configuration runs, with every run kept in `runs_ms`.
Reproduce (the v1.4.0 tree needs 1.6.0's `benchmarks/perf_suite.py` copied in):
```bash
git worktree add ../v140 v1.4.0 && cp benchmarks/perf_suite.py ../v140/benchmarks/
for i in 1 2 3 4 5; do
python -m benchmarks.perf_suite --json > main-$i.json
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --json > off-$i.json
(cd ../v140 && python -m benchmarks.perf_suite --json) > v140-$i.json
done
```
The profile, run from each tree's root:
```python
import asyncio, cProfile, pstats
from backends.ezdxf_backend import EzdxfBackend
async def work():
b = EzdxfBackend()
await b.connect()
await b.drawing_new()
for i in range(2000):
await b.entity_create_line(0, i, 100, i)
await b.disconnect()
pr = cProfile.Profile()
pr.enable()
asyncio.run(work())
pr.disable()
print(pstats.Stats(pr).total_calls)
```
## Correctness A/B suite
@@ -63,6 +295,103 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json results.json
```
### Result — v1.6.0 vs v1.5.1 (release gate)
48 checks, ezdxf backend, one subprocess per check. Machine-readable report:
[`results/published/ab-v1.5.1-vs-v1.6.0.json`](results/published/ab-v1.5.1-vs-v1.6.0.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| **v1.5.1** (baseline) | 26 / 48 | 54.2 % | — | — |
| **v1.6.0** (this release) | 48 / 48 | 100 % | 22 | **0** |
The twenty-two are all `miss → pass` — v1.5.1 has none of the methods. Five are track H: `scale_check_detects_schematic` (the synthetic P&ID is schematic against its layout; a uniform 2× copy is to scale with factor 2), `pipe_takeoff_rmst_exact` (eight routable runs, each exactly the rectilinear MST of its tags), `cable_takeoff_roundup` (9 / 17 / 19 / 10 m, the unstated power left empty), `diff_detects_known_edits` (five known edits and nothing else) and `topology_known_defects` (one near miss, one crossing, a clean T). Four are track F: `arch_junction_l_t_x` (L, T and X junction outlines against hand-computed corners and wall areas), `arch_room_area_net` (a 4 × 5 m room between 200 mm walls is labelled 20.00 m², not its 21.84 m² axis area), `arch_opening_cuts_wall` (both faces interrupted across a door, two jambs) and `arch_rooms_detect_foreign` (plain lines on a WALLS layer read as 20 and 15 m² at confidence 0.6, nothing written). Seven
are tracks B and G: `mech_part_roundtrip` (a part read back from its own
`ACADMCP_MECH` XDATA equals the part drawn), `mech_section_hatch_area` (a
sleeve 60 long, ⌀40 outside, ⌀20 bore: a full section cuts two 60 × 10 faces,
1200 mm² exactly, measured out of the drawing by `entity_measure` — never from
the code that drew it), `mech_iso286_on_dimension` (ISO 286-1: 40 H7 is
+0.025 / 0, and the fit survives `layout_dimensions`),
`mech_thread_unrepresented_is_caught` (the ISO 6410 focus fires on a thread
drawn as a plain circle and stops firing once the thin ¾ arc is there — the
mechanical twin of `dim_overlap_critique_fires`), `std_part_iso4014_m12` (ISO
4014:2011 table 1, M12: s = 18, k = 7.5, matched by value; the inserted block
carries its designation back), `sheet_frame_iso5457_a3` (the frame runs 20,10
to 410,287 inside the 420 × 297 trimmed sheet) and `bom_balloon_link` (two
identical bolts collapse into one row of quantity 2 with a stable item number,
and that row's balloon carries it). The other six are the three
P&ID checks track A added (`pid_block_define_attdef_roundtrip`,
`pid_tag_parse_fic`, `pid_graph_edge_count`) and the three settings checks of
track E (`settings_dimstyle_iso25_values`: the ISO-25 preset reaches the
DIMSTYLE table with ISO 129-1's numbers, read back through ezdxf and not
through the lister; `settings_layer_state_roundtrip`: save → change → restore
puts the layer table back and the state survives save/reopen as an XRECORD in
the file; `settings_pdf_mediabox_a3`: the plotted PDF's own `/MediaBox` reads
420 × 297 mm). Every one of the 26 checks it was released on still passes, so
the five tracks added capability without moving a number it had already earned.
### Result — v1.5.1 vs v1.5.0
26 checks at the time, ezdxf backend, one subprocess per check. Machine-readable report:
[`results/published/ab-v1.5.0-vs-v1.5.1.json`](results/published/ab-v1.5.0-vs-v1.5.1.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| v1.5.0 (baseline) | 24 / 26 | 92.3 % | — | — |
| v1.5.1 (that release) | 26 / 26 | 100 % | 2 | **0** |
v1.5.0 fails `diameter_dim_measures_the_diameter` and
`radius_dim_ignores_the_leader_length`: it has both methods and gets both
wrong. That is the defect 1.5.1 exists to fix and the reason 1.5.0 is yanked.
### Result — v1.5.1 vs v1.4.0
Same 26-check 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 (that release) | 26 / 26 | 100 % | 5 | **0** |
> This row used to read `v1.5.0 | 24 / 26 | 92.3 % | 5`, which was impossible on
> its face — 21 passing plus 5 fixed is 26, not 24 — and it contradicted the
> report it linked. The cause is worth recording: the file named
> `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that already carried
> the dimension fixes, so it never measured the v1.5.0 *tag*. The v1.5.0 tag
> really does score 24/26, which is what the v1.5.0-baseline table above
> reports. This table now names the tree it actually measured.
The pass rate is against the 26-check current suite; v1.4.0 passed 21/21 of the
checks that existed when it shipped. The five splits into two kinds, and the
difference is the whole reason this lane distinguishes `miss` from `fail`:
* **Three are new capability.** v1.4.0 has no `entity_measure` and no boundary
tracing, so it misses `hatch_area_subtracts_its_island`,
`boundary_area_agrees_with_measure` and `two_vertex_circle_has_area` by not
having the method (`miss → pass`). They are in the suite because all three
broke during v1.5.0 development and nothing outside their own unit tests
would have caught it.
* **Two are repaired defects.** v1.4.0 *and v1.5.0* both have
`dimension_diameter` and `dimension_radius` and both get them wrong
(`fail → pass`): `leader_length`, a text placement, was being measured as
geometry, so at default settings every diameter callout came out
2 × `leader_length` too large. Only 1.5.1 passes them.
The 21 pre-existing checks are unchanged and all still pass: the discovery
layer, `cad_batch`, the 19-module contract split, the layout/viewport family
and Wave A landed with **zero correctness regressions**.
### Result — v1.4.0 vs v1.3.0
21 checks. Report:
[`results/published/ab-v1.3.0-vs-v1.4.0.json`](results/published/ab-v1.3.0-vs-v1.4.0.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| **v1.3.0** (baseline) | 21 / 21 | 100 % | — | — |
| **v1.4.0** | 21 / 21 | 100 % | 0 | **0** |
### Result — v1.1.0 vs v1.0.0 (`origin/main`, commit 15fa2bc)
21 checks, ezdxf backend, CPython 3.14, one subprocess per check.
@@ -109,8 +438,5 @@ not a cherry-picked list of failures; the baseline genuinely does basic CAD work
- COM-backend behaviour is not covered here (no live AutoCAD); see the mocked-COM
suites in `tests/`.
## Performance (throughput) — coming
Scale / throughput numbers (1k–100k entities, render timings, model-by-model
tool-call success rate) are tracked in the main README and will be published here
with a hardware footprint.
Model-by-model tool-call success rates remain future work; scale/throughput
numbers live in the headless performance lane above.
+791 -6
View File
@@ -1,15 +1,54 @@
"""Reference v2 adapter for this repository."""
"""Reference adapter for this repository, covering the v7 task matrix.
Every task verifies against a value worked out independently of the code under
test — a closed-form area, a count of entities placed on purpose, a token
ceiling fixed before the measurement. A task that only checks the server agrees
with itself is a task that cannot fail.
"""
from __future__ import annotations
import math
from pathlib import Path
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.tasks_v2 import TaskSpec
from benchmarks.tasks_v7 import TaskSpec
from engineering.delivery import deliver_drawing
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from engineering.refiner import refine_drawing
#: AutoCAD command names a drafter types, none of which appeared in any tool
#: name or description before v1.5.0 — measured `df = 0` against the stock BM25
#: index, so no amount of tuning could have surfaced them.
#: Each command is answered by a *set*, because more than one tool can be a
#: correct answer -- QSELECT is both "filter by properties" and "select
#: similar", and picking a winner between them would test the tie-break rather
#: than the discovery.
DISCOVERY_QUERIES: tuple[tuple[str, frozenset[str]], ...] = (
("FILLET", frozenset({"entity_fillet"})),
("BPOLY", frozenset({"boundary_trace"})),
("QSELECT", frozenset({"selection_filter", "entity_select_smart"})),
("WBLOCK", frozenset({"block_create_from_entities"})),
("OVERKILL", frozenset({"drawing_refine"})),
("CHSPACE", frozenset({"entity_change_space"})),
)
#: How far down the ranking still counts as found. A drafter reads the top few.
DISCOVERY_TOP_N = 3
#: The advertised catalog must stay under this in discovery mode. Fixed here
#: rather than derived from the measurement, so the measurement can miss it.
TOKEN_CEILING = 2_000
#: The net floor of the two rooms of ``engineering/arch/spec.py::EXAMPLE_SPEC``,
#: computed by hand: the 250 mm ring on axis (0,0)-(9000,6000) has its inner
#: faces at x = 125 / 8875 and y = 125 / 5875, the 100 mm wall on x = 5000 its
#: faces at 4950 / 5050. Room 01: 4825 x 5750; room 02: 3825 x 5750.
ROUNDTRIP_AREAS_MM2: dict[str, float] = {
"01": 4825.0 * 5750.0,
"02": 3825.0 * 5750.0,
}
class AutoCADMCPProAdapter(BenchmarkAdapter):
name = "autocad-mcp-pro"
@@ -90,10 +129,11 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
table = await self.backend.entity_create_table(
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
)
leader = await self.backend.leader_create_mleader(
[[0, 0], [10, 10]], "NOTE", layer="DIM"
)
representations = [table.properties.get("representation"), leader.properties.get("representation")]
leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM")
representations = [
table.properties.get("representation"),
leader.properties.get("representation"),
]
return all(representations), {"representations": representations}, []
async def _task_transactions(self):
@@ -137,6 +177,751 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
await reopened.disconnect()
return count == 1, {"entity_count": count}, [str(path)]
# ── v3 (v1.5.0) ─────────────────────────────────────────────────────────
async def _task_tool_discovery(self):
"""Can a drafter find the tool by typing the command they know?
Ranked through the real transform, not a stub: the corpus is what makes
these commands findable at all, and testing anything else would test
the fixture.
"""
import server
from discovery.transform import CadSearchTransform
tools = list(await server.mcp._list_tools())
transform = CadSearchTransform()
ranks: dict[str, int | None] = {}
for command, accepted in DISCOVERY_QUERIES:
hits = [
tool.name for tool in transform.rank(tools, command, limit=DISCOVERY_TOP_N).hits
]
found = next((index for index, name in enumerate(hits) if name in accepted), None)
ranks[command] = None if found is None else found + 1
passed = all(rank is not None for rank in ranks.values())
return passed, {"ranks": ranks, "top_n": DISCOVERY_TOP_N}, []
async def _task_token_budget(self):
"""What the client pays before it has asked for anything.
The ceiling is a constant, so a regression that doubles the catalog
fails this rather than quietly reporting a bigger number.
"""
import server
from benchmarks.token_suite import (
FORMAT_JSON_SCHEMA,
_client,
_wire_payload,
build_counter,
)
async def _advertised(mode: str) -> tuple[int, int]:
"""Tokens on the wire for `tools/list`, and how many tools that is.
Measured through a real client rather than the registry: the
registry is what the transform hides, so counting it would make
discovery mode look free.
"""
server._apply_discovery_mode(mode)
async with _client() as client:
tools = await client.list_tools()
return counter.count(_wire_payload(tools), FORMAT_JSON_SCHEMA), len(tools)
counter = build_counter("ratio")
try:
full, catalog_count = await _advertised("off")
discovery, advertised_count = await _advertised("search")
finally:
server._apply_discovery_mode("off")
passed = discovery < TOKEN_CEILING and discovery < full
return (
passed,
{
"catalog_tokens": full,
"discovery_tokens": discovery,
"ceiling": TOKEN_CEILING,
"reduction_x": round(full / discovery, 1) if discovery else None,
"catalog_tools": catalog_count,
"advertised_tools": advertised_count,
# Named, because "40,305 tokens" reads like a count and is not
# one. `benchmarks/token_suite.py --tokenizer anthropic` counts
# for real; this lane has to stay offline and deterministic.
"tokenizer": f"{counter.name} (estimate)" if counter.estimated else counter.name,
},
[],
)
async def _task_hatch_islands(self):
"""A section view is mostly holes, so the island is the measurement.
20x20 outer square with a 10x10 island: the filled area is 300, and a
server that records the island without subtracting it reports 400 while
looking entirely correct on screen.
"""
await ensure_engineering_layers(self.backend)
hatch = await self.backend.entity_create_hatch(
"ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]], layer="HATCH"
)
island = [
{"type": "line", "start": [5, 5], "end": [15, 5]},
{"type": "line", "start": [15, 5], "end": [15, 15]},
{"type": "line", "start": [15, 15], "end": [5, 15]},
{"type": "line", "start": [5, 15], "end": [5, 5]},
]
added = await self.backend.hatch_add_boundary(hatch.handle, island)
measured = await self.backend.entity_measure(hatch.handle)
ignored = await self.backend.hatch_edit(hatch.handle, style="ignore")
over_island = await self.backend.entity_measure(hatch.handle)
passed = (
added["path_count"] == 2
and abs(measured["area"] - 300.0) < 1e-6
and measured["hatch_style"] == "outer" # what entity_create_hatch writes
and ignored["changed"] == ["style"]
and abs(over_island["area"] - 400.0) < 1e-6
)
return (
passed,
{
"path_count": added["path_count"],
"filled_area": measured["area"],
"area_ignoring_islands": over_island["area"],
"expected_filled": 300.0,
"hatch_style": measured["hatch_style"],
},
[],
)
async def _task_selection_filter(self):
"""Window is not crossing, and a polygon is not its bounding box.
Three circles placed so each distinction has exactly one right answer:
one wholly inside, one straddling the edge, one out in the corner that
the bounding box catches and the triangle does not.
"""
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_circle(5, 5, 1, layer="GEOMETRY")
await self.backend.entity_create_circle(10, 5, 3, layer="GEOMETRY")
await self.backend.entity_create_circle(18, 18, 0.5, layer="GEOMETRY")
await self.backend.entity_create_circle(40, 40, 1, layer="TEXT")
window = await self.backend.selection_window(0, 0, 10, 10, mode="window")
crossing = await self.backend.selection_window(0, 0, 10, 10, mode="crossing")
bbox = await self.backend.selection_window(0, 0, 20, 20, mode="window")
triangle = await self.backend.selection_polygon([[0, 0], [20, 0], [0, 20]], mode="window")
by_layer = await self.backend.selection_filter(entity_type="CIRCLE", layer="TEXT")
passed = (
window["count"] == 1
and crossing["count"] == 2
and bbox["count"] == 3
and triangle["count"] == 1
and by_layer["count"] == 1
and by_layer["filtered_by"] == ["entity_type", "layer"]
)
return (
passed,
{
"window": window["count"],
"crossing": crossing["count"],
"bounding_box": bbox["count"],
"polygon": triangle["count"],
"by_layer": by_layer["count"],
},
[],
)
async def _task_measure_from_handle(self):
"""The 28.2% that reading coordinates back loses.
A 10x10 square whose top edge is a semicircle encloses 139.2699. Chain
the loose edges into a boundary, then measure that boundary by handle;
shoelacing the four corners the model would have remembered gives 100.
"""
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
await self.backend.entity_create_line(10, 0, 10, 10, layer="GEOMETRY")
await self.backend.entity_create_line(0, 10, 0, 0, layer="GEOMETRY")
await self.backend.entity_create_arc(5, 10, 5, 0, 180, layer="GEOMETRY")
loop = await self.backend.boundary_trace(5.0, 5.0)
measured = await self.backend.entity_measure(loop["handle"])
naive = await self.backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]])
true_area = 100.0 + math.pi * 25.0 / 2.0
lost = (true_area - naive) / true_area
passed = (
loop["ok"] is True
and abs(measured["area"] - true_area) < 1e-5
and abs(loop["area"] - measured["area"]) < 1e-9
and measured["method"] == "analytic_bulge"
and measured["exact"] is True
and 0.28 < lost < 0.29
)
return (
passed,
{
"measured_area": measured["area"],
"true_area": round(true_area, 6),
"vertex_shoelace_area": naive,
"fraction_lost_by_reading_points_back": round(lost, 4),
"boundary_agrees": abs(loop["area"] - measured["area"]) < 1e-9,
},
[],
)
async def _task_pid_roundtrip(self):
"""Five symbols, four lines, read back with nothing dangling.
The spec is drawn through the same code ``pid_from_spec`` calls; the
graph, the instrument index and the line list are then read back from
the drawing by the reader, which never sees the spec. Every expected
number below is counted from the spec by hand.
"""
from engineering.layers import apply_layer_set
from engineering.pid.deliverables import instrument_index, line_list
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
await apply_layer_set(self.backend, "pid")
result = await run_spec(self.backend, EXAMPLE_SPEC)
graph = result["graph"]
index = instrument_index(graph)
lines = line_list(graph)
# The spec numbers its two process lines; the connector line is left
# to the default ``number_format``, which with no size/service/spec/
# insulation collapses to the bare sequence number, and it must come
# back through XDATA, not a label search. The electric signal line is
# unnumbered by rule (ISA-5.1 signal lines carry no pipe line number)
# and must not have consumed a sequence number: the connector is "3".
authored = "100-P-1001-CS1"
numbers = {(r["from_tag"], r["to_tag"]): r["line_number"] for r in lines}
authored_ok = (
numbers.get(("P-101", "FCV-101")) == authored
and numbers.get(("FCV-101", "V-201")) == authored
)
others = [r for r in lines if r["line_number"] != authored]
signal = [r for r in others if r["line_class"] == "electric"]
sequenced = [r for r in others if r["line_class"] != "electric"]
sequenced_ok = (
len(signal) == 1
and signal[0]["line_number"] is None
and len(sequenced) == 1
and sequenced[0]["line_number"] == "3"
and sequenced[0]["number_source"] == "xdata"
)
passed = (
len(graph["nodes"]) == 5
and len(graph["edges"]) == 4
and graph["stats"]["dangling"] == 0
and graph["stats"]["confidence_min"] == 1.0
and result["critique"] == []
and [r["tag"] for r in index] == ["FIC-101"]
and index[0]["connected_to"] == "FCV-101"
and len(lines) == 4
and authored_ok
and sequenced_ok
)
return (
passed,
{
"nodes": len(graph["nodes"]),
"edges": len(graph["edges"]),
"dangling": graph["stats"]["dangling"],
"confidence_min": graph["stats"]["confidence_min"],
"critique_issues": len(result["critique"]),
"instrument_index_rows": len(index),
"line_list_rows": len(lines),
"authored_line_numbers_kept": authored_ok,
"unnumbered_lines_sequenced": sequenced_ok,
},
[],
)
async def _task_page_setup_truth(self):
"""ISO A3 landscape on Layout1, plotted, and the sheet read back from the PDF.
The number that matters is parsed from the file's ``/MediaBox`` — the
setter's ``applied`` block is what we asked for, the page-setup list is
what the LAYOUT now says, and the PDF is what a printer would get. All
three have to agree with ISO 216's 420 × 297.
A fresh document's Layout1 is already A3 landscape, so that read-back
alone would pass with the setter stubbed out. The task first moves the
sheet to ANSI B — 432 × 279, a size no fresh document has — and reads
that PDF, then applies A3 and requires the setter to report the layout
moved from B to A3. The stub fails at the first PDF.
"""
from engineering.standards.papers import resolve_page_setup
from engineering.standards.plot import batch_plot
out_dir = self.artifact_dir / "page_setup_truth"
out_dir.mkdir(parents=True, exist_ok=True)
# Stage 1: a non-default sheet, proven by its PDF.
await self.backend.page_setup_apply(
"Layout1",
resolve_page_setup("ANSI_B", orientation="landscape", scale="1:1"),
)
before = await batch_plot(
self.backend, ["Layout1"], str(out_dir), "{drawing}-{layout}-ansi_b.pdf"
)
row_before = before["sheets"][0]
width_before, height_before = (float(v) for v in row_before["mediabox_mm"])
# Stage 2: the sheet under test.
setup = resolve_page_setup(
"ISO_A3",
orientation="landscape",
plot_style="monochrome.ctb",
scale="1:1",
)
applied = await self.backend.page_setup_apply("Layout1", setup)
listed = (await self.backend.page_setup_list("Layout1"))[0]
report = await batch_plot(
self.backend, ["Layout1"], str(out_dir), "{drawing}-{layout}-iso_a3.pdf"
)
row = report["sheets"][0]
width_mm, height_mm = (float(v) for v in row["mediabox_mm"])
size_listed = tuple(float(v) for v in listed["size_mm"])
size_changed = applied["changed"].get("size_mm")
# ±0.5 mm, the spec's own tolerance: the live engine measures ANSI B
# as 431.8 × 279.4 (11 × 17 in), the headless one as 432 × 279.
moved = size_changed or [[0.0, 0.0], [0.0, 0.0]]
moved_from_b = (
abs(float(moved[0][0]) - 432.0) < 0.5 and abs(float(moved[0][1]) - 279.0) < 0.5
)
moved_to_a3 = (
abs(float(moved[1][0]) - 420.0) < 0.5 and abs(float(moved[1][1]) - 297.0) < 0.5
)
passed = (
abs(width_before - 432.0) < 0.5
and abs(height_before - 279.0) < 0.5
and applied["ok"] is True
and applied["plot_style_known"] is True
and moved_from_b
and moved_to_a3
and listed["paper"] == "ISO_A3"
and listed["orientation"] == "landscape"
and size_listed == (420.0, 297.0)
and abs(width_mm - 420.0) < 0.5
and abs(height_mm - 297.0) < 0.5
and row["bytes"] > 0
)
return (
passed,
{
"paper": listed["paper"],
"orientation": listed["orientation"],
"size_mm_listed": list(size_listed),
"mediabox_mm": [width_mm, height_mm],
"mediabox_mm_before": [width_before, height_before],
"size_mm_changed": size_changed,
"pdf_bytes": row["bytes"],
"plot_style_known": applied["plot_style_known"],
},
[str(row_before["path"]), str(row["path"])],
)
async def _task_mech_assembly(self):
"""A flange-coupling sheet built from the part model and the sheet standard.
Roadmap criterion 3. Two parts (a hub with a bore and a flange), a full
section through the hub, four standard fasteners with their parts-list
rows and balloons, an ISO 5457 A3 frame and an ISO 7200 title block.
Every step goes through the code the tools call: the frame and title
block through ``apply_titleblock``, the parts list is *read* off the
drawing by ``extract_records`` and drawn by ``draw_bom_table``, and each
balloon is linked to its row's inserts by ``add_balloon``.
The gate is two numbers, and both are asserted rather than reported:
``run_critique(focus=None)`` must return zero issues, and the score the
finalize gate computes - ``combine(validator.summary, critique)`` over
the saved sheet - must be at least 90. A regression in either fails the
task; the focuses that fired come back in the metrics so the failure
names itself.
No step here repairs what the critique would flag: the view engine
draws the ISO 128-23 centre lines of every circle it emits (the end
view's bore and bolt holes included), so the sheet is clean because
the tools drew it clean, not because the benchmark tidied up after them.
"""
from engineering import DrawingValidator
from engineering.critique import run_critique
from engineering.layers import apply_layer_set
from engineering.mech.draw import add_view, dimension_part, draw_part
from engineering.mech.part import build_part
from engineering.mech.stdparts import insert_std_part
from engineering.scoring import combine
from engineering.sheet.bom import (
add_balloon,
draw_bom_table,
extract_records,
rows_from_records,
)
from engineering.sheet.titleblock import (
TB_HEIGHT,
TitleBlockMetadata,
apply_titleblock,
titleblock_origin,
)
await apply_layer_set(self.backend, "mech")
await apply_titleblock(
self.backend,
size="A3",
metadata=TitleBlockMetadata(
title="FLANGE COUPLING",
drawing_no="MA-001",
drawn_by="autocad-mcp-pro",
scale="1:1",
material="S235JR",
),
projection="first",
frame=True,
zones=True,
marks=True,
)
hub = build_part(
{
"kind": "revolved",
"name": "HUB",
"material": "steel",
"segments": [
{"length": 40.0, "d_outer": 40.0, "d_inner": 20.0},
{"length": 15.0, "d_outer": 90.0, "d_inner": 20.0},
],
}
)
flange = build_part(
{
"kind": "revolved",
"name": "FLANGE",
"material": "cast_iron",
"segments": [{"length": 15.0, "d_outer": 90.0, "d_inner": 40.0}],
# Four M12 clearance holes (ISO 273 medium, 13.5) on a 65 PCD.
"features": [
{
"kind": "hole_circle",
"id": "bolts",
"x": 7.5,
"pcd": 65.0,
"count": 4,
"diameter": 13.5,
"start_angle": 45.0,
}
],
}
)
# A3 landscape: the frame runs (20, 10)-(410, 287) and the title block
# fills its lower-right corner, 180 x 60. First angle throughout: the
# hub's front view sits at x = 125 so its section lands left of it
# inside the frame; the flange's front view stands at x = 345 so its
# end view (the bolt circle) lands between the two parts; the
# fasteners take the free band below, left of the title block.
drawn_hub = await draw_part(
self.backend, hub, at=(125.0, 160.0), views=("front",), dimension=False
)
# A full longitudinal section along the hub's axis (part-local frame).
await add_view(
self.backend,
drawn_hub["part_id"],
"section",
plane={"p1": [0.0, 0.0], "p2": [55.0, 0.0], "label": "A"},
style="full",
)
await dimension_part(self.backend, drawn_hub["part_id"], style="chain")
await draw_part(
self.backend, flange, at=(345.0, 160.0), views=("front", "side"), dimension=True
)
# Two bolt-and-nut pairs: each nut sits on the thread of its bolt.
stations = (60.0, 110.0, 160.0, 210.0)
fasteners = []
for index, x in enumerate(stations):
designation = "ISO 4014 - M12x60" if index % 2 == 0 else "ISO 4032 - M12"
fasteners.append(
await insert_std_part(self.backend, designation, at=(x, 90.0), view="side")
)
# The parts list is a read of the drawing, not a list typed beside it.
records = await extract_records(self.backend)
rows = rows_from_records(records)
tb_x, tb_y = titleblock_origin("A3")
await draw_bom_table(self.backend, rows, at=(tb_x, tb_y + TB_HEIGHT))
station_of = {
str(result.get("handle")): x for result, x in zip(fasteners, stations, strict=True)
}
for row in rows:
targets = [str(handle) for handle in row.get("handles") or ()]
x = station_of.get(targets[0], stations[0]) if targets else stations[0]
await add_balloon(
self.backend,
item=int(row["item"]),
at=(x, 135.0),
leader_to=(x, 95.0),
targets=targets,
)
# drawing_finalize saves before it validates; so does the benchmark,
# or the validator's not_saved/no_path findings would grade the
# harness instead of the sheet.
sheet = self.artifact_dir / "mech_assembly.dxf"
await self.backend.drawing_save_as(str(sheet))
issues = await run_critique(self.backend, None)
validation = await DrawingValidator().run(self.backend)
summary = {"error": 0, "warning": 0, "info": 0}
for issue in issues:
summary[issue.severity] = summary.get(issue.severity, 0) + 1
score = combine(validation.summary, summary)
passed = len(issues) == 0 and float(score["score"]) >= 90.0
return (
passed,
{
"critique_issues": len(issues),
"critique_focuses": sorted({issue.focus for issue in issues}),
"score": float(score["score"]),
"grade": score["grade"],
"invalidity_ratio": score["invalidity_ratio"],
"parts_list_rows": len(rows),
"fasteners": len(fasteners),
"validator_findings": sorted(f.code for f in validation.findings),
},
[str(sheet)],
)
async def _task_arch_roundtrip(self):
"""A two-room plan drawn from one spec and read back off the drawing.
Track F's evidence (spec §10). ``arch_plan_from_spec`` draws the plan of
``engineering/arch/spec.py::EXAMPLE_SPEC`` - a 9 x 6 m brick ring at 250
mm split by a 100 mm AAC wall, an entrance door, an interior door, two
windows, a straight stair, two labelled rooms, the exterior chains and
the three schedules. Then the drawing is read, never the spec:
* ``rooms_detect`` on the wall layer must find each labelled room, and
its area must agree with the area the label's record carries within
0.1 %, and with the net floor computed by hand in
``ROUNDTRIP_AREAS_MM2`` - so a label that typed the axis area fails;
* the door and window schedules read off the records must list exactly
the drawn tags;
* ``run_critique(focus=None)`` returns zero issues and the finalize
score - ``combine(validator.summary, critique)`` over the saved plan -
is at least 90.
Any one slipping fails the task, and the metrics name what slipped.
"""
from engineering import DrawingValidator
from engineering.arch.critique import point_in_loop
from engineering.arch.draw import read_plan
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.arch.rooms import rooms_detect
from engineering.arch.schedule import schedule_rows
from engineering.arch.spec import EXAMPLE_SPEC, draw_plan_from_spec
from engineering.critique import run_critique
from engineering.scoring import combine
drawn = await draw_plan_from_spec(self.backend, EXAMPLE_SPEC)
plan = await read_plan(self.backend)
detected = await rooms_detect(self.backend, layers=[ARCH_ROLE_LAYER["wall"]])
faces = list(detected.get("rooms") or [])
areas: dict[str, dict] = {}
for room in plan["rooms"]:
face = next((f for f in faces if point_in_loop(room.at, f["loop"])), None)
detected_area = float(face["area"]) if face is not None else 0.0
hand = ROUNDTRIP_AREAS_MM2.get(str(room.number))
areas[str(room.number)] = {
"label_mm2": float(room.area),
"detected_mm2": detected_area,
"hand_mm2": hand,
"agrees": face is not None
and hand is not None
and abs(detected_area - float(room.area)) <= 1e-3 * float(room.area)
and abs(detected_area - hand) <= 1e-3 * hand,
}
rooms_ok = (
set(areas) == set(ROUNDTRIP_AREAS_MM2)
and len(faces) == len(ROUNDTRIP_AREAS_MM2)
and all(row["agrees"] for row in areas.values())
)
door_tags = sorted(str(row["tag"]) for row in schedule_rows("doors", plan, lang="en"))
window_tags = sorted(str(row["tag"]) for row in schedule_rows("windows", plan, lang="en"))
tags_ok = door_tags == ["D1", "D2"] and window_tags == ["W1", "W2"]
sheet = self.artifact_dir / "arch_roundtrip.dxf"
await self.backend.drawing_save_as(str(sheet))
issues = await run_critique(self.backend, None)
validation = await DrawingValidator().run(self.backend)
summary = {"error": 0, "warning": 0, "info": 0}
for issue in issues:
summary[issue.severity] = summary.get(issue.severity, 0) + 1
score = combine(validation.summary, summary)
passed = rooms_ok and tags_ok and len(issues) == 0 and float(score["score"]) >= 90.0
return (
passed,
{
"rooms": areas,
"faces_detected": len(faces),
"confidence_min": detected.get("confidence_min"),
"door_tags": door_tags,
"window_tags": window_tags,
"omitted": drawn.get("omitted"),
"critique_issues": len(issues),
"critique_focuses": sorted({issue.focus for issue in issues}),
"score": float(score["score"]),
"grade": score["grade"],
"invalidity_ratio": score["invalidity_ratio"],
"validator_findings": sorted(f.code for f in validation.findings),
},
[str(sheet)],
)
async def _task_takeoff_roundtrip(self):
"""Pipe and cable quantities off the synthetic plant pair, against its truth.
Track H's evidence (spec §15). The pair is written fresh into the
artifact folder by ``tests/fixtures/plant_pair.py``, whose truth is
computed from the positions it placed. Then only the two DXFs are read:
* ``scale_check(pid, layout)`` must say ``schematic`` - room 1 is the
layout stretched 1.3x, room 2 is rearranged, and 21.8 % of the 55 tag
pairs lie within ±10 % of the median ratio;
* ``pipe_rows(..., length_source="auto")`` must report every run of the
P&ID (the ice-water utility included, because ``services`` names it)
with the truth's status; a routable run's layout length must equal the
rectilinear MST of its tags exactly, its sizes (both across the
reducer) and its service must be the drawn ones (the CIP return drawn
on the supply layer included); the run ending on T105, which the
layout lacks, is status C with no layout length;
* ``cable_rows(pid, layout)`` must give every load its panel from the
layout's wiring callouts, its stated power (the heater's stays empty),
the Manhattan length to the panel and the metres rounded up after the
20 % allowance.
Any one slipping fails the task, and the metrics name what slipped.
"""
from engineering.understand.network import build_network
from engineering.understand.scale import scale_check
from engineering.understand.snapshot import read_snapshot
from engineering.understand.takeoff import cable_rows, pipe_rows
from tests.fixtures.plant_pair import build_plant_pair
truth = build_plant_pair(self.artifact_dir / "plant_pair")
pid = read_snapshot(truth["pid"])
layout = read_snapshot(truth["layout"])
scale = scale_check(pid, layout)
services = tuple(sorted({run["service"] for run in truth["pipe_runs"]}))
pipe = pipe_rows(
build_network(pid), pid, layout, scale=scale, services=services, length_source="auto"
)
found = {(run["service"], tuple(sorted(run["tags"]))): run for run in pipe["runs"]}
pipe_mismatch = []
for run in truth["pipe_runs"]:
got = found.get((run["service"], tuple(run["tags"])))
if got is None:
pipe_mismatch.append({"run": run["id"], "reason": "no run with these tags"})
continue
reasons = []
if got["status"] != run["status"]:
reasons.append(f"status {got['status']} != {run['status']}")
want, have = run["layout_length_mm"], got.get("layout_m")
if want is None and have is not None:
reasons.append(f"a layout length ({have} m) for an unroutable run")
elif want is not None and (have is None or abs(float(have) * 1000.0 - want) > 1e-6):
reasons.append(f"layout length {have} m != {want} mm")
if set(got["diameters"]) != set(run["diameters"]):
reasons.append(f"sizes {sorted(got['diameters'])} != {sorted(run['diameters'])}")
if reasons:
pipe_mismatch.append({"run": run["id"], "reason": "; ".join(reasons)})
# panel_rule="stated": the gate reads what the drawing says; a nearest-panel
# guess is no evidence
cable = {row["tag"]: row for row in cable_rows(pid, layout, panel_rule="stated")["rows"]}
cable_mismatch = []
for want in truth["cable_rows"]:
got = cable.get(want["tag"])
if got is None:
cable_mismatch.append({"tag": want["tag"], "wrong": ["missing"]})
continue
wrong = [key for key in ("panel", "kw") if got.get(key) != want[key]]
if got.get("cable_m") != want["roundup_m"]:
wrong.append("cable_m")
length = got.get("length_m")
if length is None or abs(float(length) * 1000.0 - want["length_mm"]) > 1e-6:
wrong.append("length_m")
if wrong:
cable_mismatch.append({"tag": want["tag"], "wrong": wrong})
passed = scale["verdict"] == truth["scale_verdict"] and not pipe_mismatch
passed = passed and not cable_mismatch
return (
passed,
{
"scale_verdict": scale["verdict"],
"within_10pct": scale.get("within_10pct"),
"length_source": pipe.get("length_source"),
"runs_checked": len(truth["pipe_runs"]),
"pipe_mismatch": pipe_mismatch,
"loads_checked": len(truth["cable_rows"]),
"cable_mismatch": cable_mismatch,
},
[truth["pid"], truth["layout"]],
)
async def _task_understand_foreign(self):
"""One call on a foreign layout reports what is wrong with it, by evidence.
Track H's evidence (spec §5, §15), on the synthetic plant pair: the
layout declares inches (``$INSUNITS = 1``) over millimetre geometry, a
stray LINE 5,000 km away stretches its declared extents, and the plan is
drawn twice, 100 m apart. ``describe`` of the layout must infer
millimetres, report that they disagree with the declared unit and warn
about ``INSUNITS``, name the stray entity's handle in its extents, and
report exactly two clusters; ``describe`` of the P&ID must classify its
four service layers and the electrical layer as the generator names them
(English and Dutch names). A warning or an extents report is searched as
JSON, so the gate holds whatever shape the evidence takes.
"""
import json
from engineering.understand.describe import describe
from engineering.understand.snapshot import read_snapshot
from tests.fixtures.plant_pair import build_plant_pair
truth = build_plant_pair(self.artifact_dir / "plant_pair")
layout = describe(read_snapshot(truth["layout"]))
pid = describe(read_snapshot(truth["pid"]))
units = layout["units"]
units_flagged = (
str(units.get("inferred")) == truth["layout_true_unit"]
and units.get("agree") is False
and "INSUNITS" in json.dumps(layout["warnings"], ensure_ascii=False)
)
outlier_found = f'"{truth["outlier_handle"]}"' in json.dumps(layout["extents"])
copies_separated = len(layout["clusters"]) == truth["cluster_count"]
read = {row["name"]: row["service"] for row in pid["layers"]}
misread = {
name: read.get(name)
for name, service in truth["layer_services"].items()
if read.get(name) != service
}
passed = units_flagged and outlier_found and copies_separated and not misread
return (
passed,
{
"units": units,
"units_flagged": units_flagged,
"outlier_found": outlier_found,
"clusters": len(layout["clusters"]),
"copies_separated": copies_separated,
"misread_layers": misread,
},
[truth["layout"], truth["pid"]],
)
async def _task_auditable_delivery(self):
await ensure_engineering_layers(self.backend)
await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
+301
View File
@@ -0,0 +1,301 @@
"""Black-box adapter for beiming183-cloud/AutoCAD-MCP (headless ezdxf lane).
Contract verified against the pinned checkout's ``server.py`` (v3.10 line):
- document identity comes from ``transaction(operation="context")`` and every
mutation must carry ``doc_id`` + ``expected_revision`` — top-level for the
``entity``/``annotation`` tools, inside ``data`` for ``drawing`` operations;
- ``entity create_line`` takes top-level ``x1..y2``; circle/dimension payloads
sit under ``data`` (same field spellings as the upstream puran-water base);
- ``transaction begin/commit/rollback`` are compatibility operations that the
headless backend may reject with ``E_UNSUPPORTED*`` — that outcome is
reported as ``unsupported`` rather than ``fail``.
Verification is artifact-based: geometry claims are checked by exporting a DXF
(``drawing save_as_dxf``) and re-reading it with ezdxf in the harness.
"""
from __future__ import annotations
import math
from typing import Any
from benchmarks.adapters.base import TaskResult
from benchmarks.adapters.mcp_stdio import (
McpStdioAdapter,
ToolCallFailed,
extract_handle,
)
from benchmarks.competitors_env import CompetitorSpec
PINNED_SHA = "11f7c47e5038796a20451b38b23032e625b5aa26"
def _find_key(payload: Any, *keys: str) -> Any:
if isinstance(payload, dict):
for key in keys:
if key in payload and payload[key] not in (None, ""):
return payload[key]
for value in payload.values():
found = _find_key(value, *keys)
if found is not None:
return found
elif isinstance(payload, (list, tuple)):
for item in payload:
found = _find_key(item, *keys)
if found is not None:
return found
return None
class BeimingAutocadAdapter(McpStdioAdapter):
name = "beiming183-autocad-mcp"
spec = CompetitorSpec(
competitor_id="beiming183-autocad-mcp",
repo_url="https://github.com/beiming183-cloud/AutoCAD-MCP.git",
pinned_sha=PINNED_SHA,
entry_candidates=(
("-m", "autocad_mcp"),
("server.py",),
("src/server.py",),
),
pip_installs=(("-e", "."),),
launch_env={"AUTOCAD_MCP_BACKEND": "ezdxf"},
pythonpath="src",
)
def extra_launch_env(self) -> dict[str, str]:
# The server redirects writes outside its managed output root; point
# the root at the benchmark artifact directory so exports land where
# the harness verifies them.
return {
"AUTOCAD_MCP_OUTPUT_ROOT": str(self.artifact_dir),
"AUTOCAD_MCP_ALLOW_EXTERNAL_OUTPUTS": "true",
}
def warmup_calls(self):
# Absorb lazy backend initialization once during setup.
return (
("system", {"operation": "status"}),
("drawing", {"operation": "create", "data": {}}),
)
async def _ctx(self) -> dict[str, Any]:
"""Read doc_id + revision from ``transaction(operation="context")``."""
try:
payload = await self.call("transaction", {"operation": "context"})
except ToolCallFailed:
return {}
doc_id = _find_key(payload, "doc_id", "document_id")
revision = _find_key(payload, "revision", "expected_revision", "document_revision")
context: dict[str, Any] = {}
if doc_id is not None:
context["doc_id"] = doc_id
if revision is not None:
context["expected_revision"] = revision
return context
async def _new_drawing(self) -> None:
await self.call("drawing", {"operation": "create", "data": {}})
async def _save_dxf(self, filename: str) -> str:
target = self.save_target(filename)
payload: dict[str, Any] = {"path": str(target)}
try:
await self.call("drawing", {"operation": "save_as_dxf", "data": payload})
except ToolCallFailed:
context = await self._ctx()
await self.call("drawing", {"operation": "save_as_dxf", "data": {**payload, **context}})
return str(target)
async def _entity(self, operation: str, **fields: Any) -> Any:
context = await self._ctx()
return await self.call("entity", {"operation": operation, **context, **fields})
async def _create_line(self, x1: float, y1: float, x2: float, y2: float):
return await self._entity("create_line", x1=x1, y1=y1, x2=x2, y2=y2)
# -- tasks ----------------------------------------------------------------
async def _task_core_geometry(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 3, 4)
await self._entity("create_circle", data={"cx": 10, "cy": 10, "radius": 2})
artifact = await self._save_dxf("beiming_core_geometry.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_core_geometry.dxf"))
lines = doc.modelspace().query("LINE")
circles = doc.modelspace().query("CIRCLE")
length = None
if lines:
start, end = lines[0].dxf.start, lines[0].dxf.end
length = math.dist((start.x, start.y), (end.x, end.y))
passed = (
len(lines) == 1
and len(circles) == 1
and length is not None
and abs(length - 5.0) < 1e-6
and abs(circles[0].dxf.radius - 2.0) < 1e-6
)
return TaskResult(
"core_geometry",
"pass" if passed else "fail",
100.0 if passed else 0.0,
metrics={"line_length": length, "lines": len(lines), "circles": len(circles)},
artifacts=[artifact],
)
async def _task_modify_query(self) -> TaskResult:
await self._new_drawing()
created = await self._create_line(0, 0, 10, 0)
handle = extract_handle(created)
if not handle:
return TaskResult(
"modify_query",
"fail",
0.0,
"could not extract created-entity handle from the create_line response",
)
await self._entity("move", entity_id=handle, data={"dx": 5, "dy": 2})
artifact = await self._save_dxf("beiming_modify_query.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_modify_query.dxf"))
lines = doc.modelspace().query("LINE")
moved = (
bool(lines)
and abs(lines[0].dxf.start.x - 5.0) < 1e-6
and (abs(lines[0].dxf.start.y - 2.0) < 1e-6)
)
return TaskResult(
"modify_query",
"pass" if moved else "fail",
100.0 if moved else 0.0,
artifacts=[artifact],
)
async def _task_layers_linetypes(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
await self.call("drawing", {"operation": "setup_mechanical", "data": {**context}})
artifact = await self._save_dxf("beiming_layers.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_layers.dxf"))
layers = {layer.dxf.name.upper() for layer in doc.layers}
linetypes = {lt.dxf.name.upper() for lt in doc.linetypes}
expected_layers = {"OUTLINE", "CENTER", "DIM"}
got_layers = expected_layers.issubset(layers)
got_linetype = "CENTER" in linetypes
if got_layers and got_linetype:
return TaskResult("layers_linetypes", "pass", 100.0, artifacts=[artifact])
if got_layers:
return TaskResult(
"layers_linetypes",
"partial",
50.0,
"GB/T layers created but CENTER linetype missing from the table",
artifacts=[artifact],
)
return TaskResult(
"layers_linetypes",
"fail",
0.0,
f"layers={sorted(layers)}",
artifacts=[artifact],
)
async def _task_dimensions(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
await self.call(
"annotation",
{
"operation": "create_dimension_linear",
**context,
"data": {"x1": 0, "y1": 0, "x2": 50, "y2": 0, "dim_x": 25, "dim_y": 10},
},
)
artifact = await self._save_dxf("beiming_dimensions.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_dimensions.dxf"))
dims = [e for e in doc.modelspace() if e.dxftype() == "DIMENSION"]
return TaskResult(
"dimensions",
"pass" if dims else "fail",
100.0 if dims else 0.0,
metrics={"dimension_count": len(dims)},
artifacts=[artifact],
)
async def _task_transactions(self) -> TaskResult:
await self._new_drawing()
context = await self._ctx()
try:
begin = await self.call("transaction", {"operation": "begin", **context})
except ToolCallFailed as exc:
if "E_UNSUPPORTED" in str(exc):
return TaskResult(
"transactions",
"unsupported",
0.0,
"transaction begin is rejected as unsupported on the headless backend",
)
raise
transaction_id = _find_key(begin, "transaction_id", "txn_id")
await self._entity("create_circle", data={"cx": 0, "cy": 0, "radius": 1})
context = await self._ctx()
rollback_args: dict[str, Any] = {"operation": "rollback", **context}
if transaction_id is not None:
rollback_args["transaction_id"] = transaction_id
await self.call("transaction", rollback_args)
artifact = await self._save_dxf("beiming_transactions.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_transactions.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"transactions",
"pass" if count == 0 else "fail",
100.0 if count == 0 else 0.0,
metrics={"entity_count_after_rollback": count},
artifacts=[artifact],
)
async def _task_dxf_roundtrip(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 10, 10)
artifact = await self._save_dxf("beiming_roundtrip.dxf")
_, doc = self.verify_dxf(self.save_target("beiming_roundtrip.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"dxf_roundtrip",
"pass" if count == 1 else "fail",
100.0 if count == 1 else 0.0,
metrics={"entity_count": count},
artifacts=[artifact],
)
# -- documented coverage gaps ----------------------------------------------
async def _task_table_mleader(self) -> TaskResult:
return TaskResult(
"table_mleader",
"unsupported",
0.0,
"no TABLE or MLEADER operation in the annotation tool (v3.10)",
)
async def _task_preflight(self) -> TaskResult:
return TaskResult("preflight", "unsupported", 0.0, "no requirements-preflight equivalent")
async def _task_quality_refiner(self) -> TaskResult:
return TaskResult(
"quality_refiner",
"unsupported",
0.0,
"audits detect issues but there is no automated repair loop",
)
async def _task_auditable_delivery(self) -> TaskResult:
return TaskResult(
"auditable_delivery",
"unsupported",
0.0,
"drawing.deliver is documented as File-IPC only (not headless)",
)
__all__ = ["BeimingAutocadAdapter"]
+304
View File
@@ -0,0 +1,304 @@
"""Generic MCP-stdio benchmark adapter.
Drives a *competitor* MCP server as a black box over the standard stdio
transport (fastmcp ``Client``), exactly the way an agent host would. No
competitor code is imported; verification never trusts the competitor's
response payload — every task that claims to have produced geometry must
save a DXF which the harness re-opens with ezdxf and inspects itself.
Subclasses provide the launch runtime (via ``benchmarks.competitors_env``)
and per-task playbooks named ``_task_<task_id>``; anything unmapped is
reported as ``unsupported`` (coverage-aware scoring already accounts for it).
"""
from __future__ import annotations
import dataclasses
import json
import re
from pathlib import Path
from typing import Any
import ezdxf
from fastmcp import Client
from fastmcp.client.transports import StdioTransport
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.competitors_env import CompetitorRuntime, CompetitorSpec, ensure_competitor
from benchmarks.tasks_v2 import TaskSpec
CALL_TIMEOUT_SECONDS = 15.0
WARMUP_TIMEOUT_SECONDS = 90.0
class ToolCallFailed(RuntimeError):
"""A competitor tool call returned an error result."""
def read_dxf(path: Path):
return ezdxf.readfile(str(path))
def modelspace_types(doc) -> list[str]:
return [entity.dxftype() for entity in doc.modelspace()]
def find_dxf(artifact_dir: Path, preferred: Path | None = None) -> Path | None:
"""Locate the DXF a competitor produced.
Servers with managed output roots may relocate writes, so fall back to
the newest ``*.dxf`` anywhere under the artifact directory.
"""
if preferred is not None and preferred.is_file():
return preferred
candidates = sorted(
artifact_dir.rglob("*.dxf"), key=lambda item: item.stat().st_mtime, reverse=True
)
return candidates[0] if candidates else None
class McpStdioAdapter(BenchmarkAdapter):
"""Black-box adapter base for competitor MCP servers."""
name = "mcp-stdio"
spec: CompetitorSpec # set by subclasses
def __init__(self, *, backend: str = "ezdxf"):
if backend != "ezdxf":
raise ValueError(
f"{type(self).__name__} only benchmarks the headless ezdxf lane; "
f"got backend={backend!r}. COM lanes run locally, not in CI."
)
self.backend_name = backend
self.artifact_dir = Path()
self.runtime: CompetitorRuntime | None = None
self._client: Client | None = None
self.tool_names: set[str] = set()
# -- lifecycle ----------------------------------------------------------
def extra_launch_env(self) -> dict[str, str]:
"""Environment computed at setup time (``self.artifact_dir`` is set)."""
return {}
async def setup(self, artifact_dir: Path) -> None:
self.artifact_dir = Path(artifact_dir)
self.runtime = ensure_competitor(self.spec)
env = {**self.runtime.env, **self.extra_launch_env()}
env.setdefault("PYTHONIOENCODING", "utf-8")
transport = StdioTransport(
command=str(self.runtime.python_exe),
args=list(self.runtime.entry_args),
env=env,
cwd=str(self.runtime.repo_dir),
)
self._client = Client(transport)
await self._client.__aenter__()
self.tool_names = {tool.name for tool in await self._client.list_tools()}
await self._warmup()
def warmup_calls(self) -> tuple[tuple[str, dict[str, Any]], ...]:
"""Calls issued once after connect to absorb the competitor's lazy
backend initialization (which can exceed the per-call timeout)."""
return ()
async def _warmup(self) -> None:
for tool, arguments in self.warmup_calls():
if tool not in self.tool_names:
continue
try:
await self._client.call_tool(
tool, arguments, timeout=WARMUP_TIMEOUT_SECONDS, raise_on_error=False
)
except Exception: # cold-start absorption is best effort
continue
async def cleanup(self) -> None:
if self._client is not None:
await self._client.__aexit__(None, None, None)
self._client = None
def metadata(self) -> dict:
return {
"backend": self.backend_name,
"kind": "mcp-stdio-blackbox",
"repo": self.spec.repo_url,
"pinned_sha": self.spec.pinned_sha,
"tools_discovered": sorted(self.tool_names),
}
# -- call helpers ---------------------------------------------------------
async def call(self, tool: str, arguments: dict[str, Any]) -> Any:
"""Call one tool; raise ``ToolCallFailed`` on an error result.
Competitor servers in this family report failures *inside* the payload
(``CommandResult(ok=False, error=...)`` serialized as text) rather than
via the MCP ``isError`` flag, so the payload is inspected as well.
"""
if tool not in self.tool_names:
raise ToolCallFailed(f"tool {tool!r} is not exposed by {self.name}")
result = await self._client.call_tool(
tool, arguments, timeout=CALL_TIMEOUT_SECONDS, raise_on_error=False
)
if getattr(result, "is_error", False):
raise ToolCallFailed(f"{tool}({arguments}) -> {_result_text(result)}")
data = getattr(result, "data", None)
payload = data if data is not None else _result_text(result)
payload = _normalize_payload(payload)
error = _payload_error(payload)
if error:
raise ToolCallFailed(f"{tool}({arguments}) -> {error}")
return payload
async def call_op(
self,
tool: str,
operation: str,
data: dict[str, Any] | None = None,
*,
shapes: tuple[dict[str, Any], ...] | None = None,
) -> Any:
"""Call a consolidated ``{tool}(operation=..., ...)`` style tool.
Competitors disagree on whether payload fields live under ``data`` or
at the top level, and on field spellings. ``shapes`` supplies explicit
argument dicts to try in order; otherwise both common layouts of
``data`` are attempted. The first non-error result wins.
"""
attempts: list[dict[str, Any]] = []
if shapes:
attempts.extend({"operation": operation, **shape} for shape in shapes)
else:
payload = data or {}
attempts.append({"operation": operation, "data": payload})
if payload:
attempts.append({"operation": operation, **payload})
last_error: Exception | None = None
for arguments in attempts:
try:
return await self.call(tool, arguments)
except ToolCallFailed as exc: # try the next argument shape
last_error = exc
raise ToolCallFailed(str(last_error))
# -- task dispatch --------------------------------------------------------
async def run_task(self, task: TaskSpec) -> TaskResult:
handler = getattr(self, f"_task_{task.task_id}", None)
if handler is None:
return TaskResult(
task.task_id,
"unsupported",
0.0,
f"{self.name} has no documented equivalent for this task",
)
try:
return await handler()
except ToolCallFailed as exc:
return TaskResult(task.task_id, "fail", 0.0, str(exc)[:500])
# -- verification ---------------------------------------------------------
def save_target(self, filename: str) -> Path:
return self.artifact_dir / filename
def verify_dxf(self, preferred: Path | None = None):
located = find_dxf(self.artifact_dir, preferred)
if located is None:
raise ToolCallFailed("no DXF artifact was produced")
return located, read_dxf(located)
def _result_text(result: Any) -> str:
parts: list[str] = []
for item in getattr(result, "content", None) or []:
text = getattr(item, "text", None)
if text:
parts.append(str(text))
return " | ".join(parts)[:1000] if parts else "<no text content>"
def _normalize_payload(payload: Any) -> Any:
"""Unwrap fastmcp's dynamic output wrappers and JSON-in-text payloads.
Legacy servers return plain text; the fastmcp client may deserialize it
into a generated *dataclass* (e.g. ``entityOutput(result='<json>')``) or a
pydantic model. Normalize down to dict / list / str so verification and
handle extraction see real data.
"""
if dataclasses.is_dataclass(payload) and not isinstance(payload, type):
payload = dataclasses.asdict(payload)
elif hasattr(payload, "model_dump"):
payload = payload.model_dump()
elif not isinstance(payload, (dict, list, str)) and hasattr(payload, "result"):
payload = payload.result
if isinstance(payload, dict) and set(payload) == {"result"}:
payload = payload["result"]
if isinstance(payload, str):
try:
parsed = json.loads(payload)
except ValueError:
parsed = None
if isinstance(parsed, (dict, list)):
payload = parsed
return payload
_TEXT_ERROR_MARKERS = ('"ok": false', "'ok': false", '"ok":false', "ok=false")
def _payload_error(payload: Any) -> str | None:
"""Detect failure reported inside a tool payload instead of MCP isError."""
if isinstance(payload, dict):
if payload.get("ok") is False or payload.get("success") is False:
return str(payload.get("error") or payload.get("message") or "ok=false")[:300]
error = payload.get("error")
if isinstance(error, str) and error.strip():
return error[:300]
return None
if isinstance(payload, str):
lowered = payload.lower()
if lowered.startswith("error") or any(m in lowered for m in _TEXT_ERROR_MARKERS):
return payload[:300]
return None
def wrapped_shapes(payload: dict[str, Any], **top_level: Any) -> tuple[dict[str, Any], ...]:
"""Produce the two common consolidated-tool argument layouts for a payload:
nested under ``data`` and flattened at the top level."""
return (
{**top_level, "data": payload},
{**top_level, **payload},
)
_HANDLE_KEYS = ("handle", "entity_id", "entity_handle", "id", "object_id")
_HANDLE_RE = re.compile(
r'"(?:handle|entity_id|entity_handle|object_id)"\s*:\s*"?([0-9A-Fa-f*][\w*-]*)"?'
)
def extract_handle(payload: Any) -> str | None:
"""Best-effort extraction of a created-entity handle from a black-box
tool response (structured dict, list, or raw text)."""
if isinstance(payload, dict):
for key in _HANDLE_KEYS:
value = payload.get(key)
if isinstance(value, (str, int)) and str(value):
return str(value)
for value in payload.values():
found = extract_handle(value)
if found:
return found
return None
if isinstance(payload, (list, tuple)):
for item in payload:
found = extract_handle(item)
if found:
return found
return None
if isinstance(payload, str):
match = _HANDLE_RE.search(payload)
return match.group(1) if match else None
return None
+224
View File
@@ -0,0 +1,224 @@
"""Black-box adapter for puran-water/autocad-mcp (headless ezdxf lane).
The competitor exposes 8 consolidated tools (``drawing``, ``entity``,
``layer``, ``block``, ``annotation``, ``pid``, ``view``, ``system``) whose
operations take an ``operation`` selector plus a payload. Argument spellings
are probed through ``wrapped_shapes`` fallbacks; every geometry claim is
verified by saving a DXF and re-reading it with ezdxf in the harness.
Tasks with no documented equivalent on this server are reported as
``unsupported`` with an explicit reason so the published JSON explains the
coverage gap instead of hiding it.
"""
from __future__ import annotations
import math
from benchmarks.adapters.base import TaskResult
from benchmarks.adapters.mcp_stdio import (
McpStdioAdapter,
ToolCallFailed,
extract_handle,
)
from benchmarks.competitors_env import CompetitorSpec
PINNED_SHA = "95476a33a1c246308326eb4709d6379ef2efdbc1"
class PuranWaterAdapter(McpStdioAdapter):
name = "puran-water-autocad-mcp"
spec = CompetitorSpec(
competitor_id="puran-water-autocad-mcp",
repo_url="https://github.com/puran-water/autocad-mcp.git",
pinned_sha=PINNED_SHA,
entry_candidates=(
("-m", "autocad_mcp"),
("server.py",),
("src/server.py",),
("main.py",),
),
pip_installs=(("-e", "."),),
launch_env={"AUTOCAD_MCP_BACKEND": "ezdxf"},
pythonpath="src",
)
# Exact v3.1 contract (verified against the pinned checkout's server.py):
# entity create_line takes top-level x1..y2; circle/dimension payloads sit
# under ``data``; modify ops take top-level ``entity_id`` + ``data``.
def warmup_calls(self):
# The first backend touch lazily initializes and can exceed the
# per-call timeout; absorb it once during setup.
return (
("system", {"operation": "status"}),
("drawing", {"operation": "create", "data": {}}),
)
async def _new_drawing(self) -> None:
await self.call_op("drawing", "create", {})
async def _save(self, filename: str) -> str:
target = self.save_target(filename)
await self.call_op("drawing", "save", {"path": str(target)})
return str(target)
async def _create_line(self, x1: float, y1: float, x2: float, y2: float):
return await self.call(
"entity",
{"operation": "create_line", "x1": x1, "y1": y1, "x2": x2, "y2": y2},
)
# -- tasks ----------------------------------------------------------------
async def _task_core_geometry(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 3, 4)
await self.call_op("entity", "create_circle", {"cx": 10, "cy": 10, "radius": 2})
preferred = await self._save("puran_core_geometry.dxf")
_, doc = self.verify_dxf(self.save_target("puran_core_geometry.dxf"))
lines = doc.modelspace().query("LINE")
circles = doc.modelspace().query("CIRCLE")
length = None
if lines:
start, end = lines[0].dxf.start, lines[0].dxf.end
length = math.dist((start.x, start.y), (end.x, end.y))
passed = (
len(lines) == 1
and len(circles) == 1
and length is not None
and abs(length - 5.0) < 1e-6
and abs(circles[0].dxf.radius - 2.0) < 1e-6
)
return TaskResult(
"core_geometry",
"pass" if passed else "fail",
100.0 if passed else 0.0,
metrics={"line_length": length, "lines": len(lines), "circles": len(circles)},
artifacts=[preferred],
)
async def _task_modify_query(self) -> TaskResult:
await self._new_drawing()
created = await self._create_line(0, 0, 10, 0)
handle = extract_handle(created)
if not handle:
return TaskResult(
"modify_query",
"fail",
0.0,
"could not extract created-entity handle from the create_line response",
)
await self.call(
"entity",
{"operation": "move", "entity_id": handle, "data": {"dx": 5, "dy": 2}},
)
artifact = await self._save("puran_modify_query.dxf")
_, doc = self.verify_dxf(self.save_target("puran_modify_query.dxf"))
lines = doc.modelspace().query("LINE")
moved = (
bool(lines)
and abs(lines[0].dxf.start.x - 5.0) < 1e-6
and (abs(lines[0].dxf.start.y - 2.0) < 1e-6)
)
return TaskResult(
"modify_query",
"pass" if moved else "fail",
100.0 if moved else 0.0,
artifacts=[artifact],
)
async def _task_layers_linetypes(self) -> TaskResult:
await self._new_drawing()
try:
await self.call_op(
"layer",
"create",
{"name": "GEOMETRY", "color": 3, "linetype": "CENTER"},
)
except ToolCallFailed:
await self.call_op("layer", "create", {"name": "GEOMETRY", "color": 3})
artifact = await self._save("puran_layers.dxf")
_, doc = self.verify_dxf(self.save_target("puran_layers.dxf"))
has_layer = "GEOMETRY" in {layer.dxf.name.upper() for layer in doc.layers}
has_center = "CENTER" in {lt.dxf.name.upper() for lt in doc.linetypes}
if has_layer and has_center:
return TaskResult("layers_linetypes", "pass", 100.0, artifacts=[artifact])
if has_layer:
return TaskResult(
"layers_linetypes",
"partial",
50.0,
"layer created; no linetype-loading operation is exposed",
artifacts=[artifact],
)
return TaskResult("layers_linetypes", "fail", 0.0, artifacts=[artifact])
async def _task_dimensions(self) -> TaskResult:
await self._new_drawing()
await self.call_op(
"annotation",
"create_dimension_linear",
{"x1": 0, "y1": 0, "x2": 50, "y2": 0, "dim_x": 25, "dim_y": 10},
)
artifact = await self._save("puran_dimensions.dxf")
_, doc = self.verify_dxf(self.save_target("puran_dimensions.dxf"))
dims = [e for e in doc.modelspace() if e.dxftype() == "DIMENSION"]
return TaskResult(
"dimensions",
"pass" if dims else "fail",
100.0 if dims else 0.0,
metrics={"dimension_count": len(dims)},
artifacts=[artifact],
)
async def _task_dxf_roundtrip(self) -> TaskResult:
await self._new_drawing()
await self._create_line(0, 0, 10, 10)
artifact = await self._save("puran_roundtrip.dxf")
_, doc = self.verify_dxf(self.save_target("puran_roundtrip.dxf"))
count = len(list(doc.modelspace()))
return TaskResult(
"dxf_roundtrip",
"pass" if count == 1 else "fail",
100.0 if count == 1 else 0.0,
metrics={"entity_count": count},
artifacts=[artifact],
)
# -- documented coverage gaps ----------------------------------------------
async def _task_table_mleader(self) -> TaskResult:
return TaskResult(
"table_mleader",
"unsupported",
0.0,
"no TABLE or MLEADER operation in the 8-tool surface (v3.1)",
)
async def _task_transactions(self) -> TaskResult:
return TaskResult(
"transactions",
"unsupported",
0.0,
"no transaction begin/commit/rollback tool (undo/redo are File-IPC only)",
)
async def _task_preflight(self) -> TaskResult:
return TaskResult("preflight", "unsupported", 0.0, "no requirements-preflight equivalent")
async def _task_quality_refiner(self) -> TaskResult:
return TaskResult(
"quality_refiner", "unsupported", 0.0, "no critique/repair loop equivalent"
)
async def _task_auditable_delivery(self) -> TaskResult:
return TaskResult(
"auditable_delivery",
"unsupported",
0.0,
"no hashed delivery-manifest equivalent (plot_pdf is File-IPC only)",
)
__all__ = ["PuranWaterAdapter", "ToolCallFailed"]
+26 -13
View File
@@ -11,6 +11,7 @@ recorded as a miss instead of taking down the whole run.
The older ref is checked out into a throwaway git worktree, benchmarked, and
removed. The "new" side is always the current repository checkout.
"""
from __future__ import annotations
import json
@@ -27,8 +28,7 @@ PY = sys.executable
def _run(cmd, cwd, repo_on_path):
env = {**os.environ, "PYTHONPATH": repo_on_path}
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True,
text=True, timeout=120)
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
def list_checks():
@@ -43,9 +43,14 @@ def list_checks():
def run_one(repo, suite, name):
try:
r = subprocess.run([PY, suite, name], cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True, text=True, timeout=120)
r = subprocess.run(
[PY, suite, name],
cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
return "timeout"
last = (r.stdout.strip().splitlines() or [""])[-1].strip()
@@ -82,22 +87,30 @@ def main():
op = sum(r["old"] == "pass" for r in rows)
npass = sum(r["new"] == "pass" for r in rows)
summary = {
"baseline_ref": ref, "total": total,
"old_pass": op, "new_pass": npass,
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1),
"baseline_ref": ref,
"total": total,
"old_pass": op,
"new_pass": npass,
"old_pct": round(100 * op / total, 1),
"new_pct": round(100 * npass / total, 1),
"fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows),
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
}
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
print("-" * 66)
for r in rows:
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else (
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
mark = (
" ->FIXED"
if (r["old"] != "pass" and r["new"] == "pass")
else (" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
)
print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
print("-" * 66)
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}")
print(
f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}"
)
if json_out:
with open(json_out, "w") as fh:
json.dump({"summary": summary, "rows": rows}, fh, indent=2)
+16
View File
@@ -5,6 +5,22 @@
"id": "autocad-mcp-pro",
"adapter": "benchmarks.adapters.autocad_mcp_pro:AutoCADMCPProAdapter",
"backends": ["ezdxf", "com"]
},
{
"id": "puran-water-autocad-mcp",
"adapter": "benchmarks.adapters.puran_water:PuranWaterAdapter",
"backends": ["ezdxf"],
"repo": "https://github.com/puran-water/autocad-mcp",
"pinned_sha": "95476a33a1c246308326eb4709d6379ef2efdbc1",
"notes": "Black-box MCP stdio lane; File-IPC (live AutoCAD) features run in the local COM lane only."
},
{
"id": "beiming183-autocad-mcp",
"adapter": "benchmarks.adapters.beiming_autocad:BeimingAutocadAdapter",
"backends": ["ezdxf"],
"repo": "https://github.com/beiming183-cloud/AutoCAD-MCP",
"pinned_sha": "11f7c47e5038796a20451b38b23032e625b5aa26",
"notes": "Black-box MCP stdio lane; deliver/undo/redo are documented File-IPC only and reported unsupported."
}
]
}
+121
View File
@@ -0,0 +1,121 @@
"""Reproducible checkout + venv management for competitor MCP servers.
Every competitor is pinned to an exact commit so a benchmark run is
reproducible: same tasks, same tree, same interpreter isolation. The cache
lives under ``benchmarks/.competitors/<id>/`` (gitignored) as::
repo/ the pinned git checkout
venv/ an isolated interpreter with the competitor installed
Nothing here talks MCP — it only prepares a runnable ``python`` + entry args
for :class:`benchmarks.adapters.mcp_stdio.McpStdioAdapter`.
"""
from __future__ import annotations
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
CACHE_ROOT = Path(__file__).resolve().parent / ".competitors"
@dataclass(frozen=True)
class CompetitorSpec:
"""Everything needed to fetch and launch one competitor server."""
competitor_id: str
repo_url: str
pinned_sha: str
# Candidate stdio entrypoints, resolved in order against the checkout.
# ("-m", "pkg") entries are accepted when repo/pkg or repo/src/pkg exists.
entry_candidates: tuple[tuple[str, ...], ...]
# pip arguments executed inside the venv, relative to the checkout.
pip_installs: tuple[tuple[str, ...], ...] = (("-e", "."),)
# Extra environment applied when launching the server.
launch_env: dict[str, str] = field(default_factory=dict)
# Set PYTHONPATH to this repo-relative directory (e.g. "src") if not None.
pythonpath: str | None = None
@dataclass(frozen=True)
class CompetitorRuntime:
"""A resolved, launchable competitor installation."""
python_exe: Path
repo_dir: Path
entry_args: tuple[str, ...]
env: dict[str, str]
def _run(cmd: list[str], cwd: Path | None = None, timeout: float = 600.0) -> None:
completed = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
if completed.returncode != 0:
raise RuntimeError(
f"command failed ({' '.join(cmd)}):\n{completed.stdout}\n{completed.stderr}"
)
def _venv_python(venv_dir: Path) -> Path:
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _ensure_checkout(spec: CompetitorSpec, repo_dir: Path) -> None:
if not (repo_dir / ".git").exists():
repo_dir.parent.mkdir(parents=True, exist_ok=True)
_run(["git", "clone", "--quiet", spec.repo_url, str(repo_dir)])
head = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=repo_dir, capture_output=True, text=True
).stdout.strip()
if head != spec.pinned_sha:
_run(["git", "fetch", "--quiet", "origin"], cwd=repo_dir)
_run(["git", "checkout", "--quiet", spec.pinned_sha], cwd=repo_dir)
def _ensure_venv(spec: CompetitorSpec, venv_dir: Path, repo_dir: Path) -> Path:
python_exe = _venv_python(venv_dir)
marker = venv_dir / ".installed-sha"
if python_exe.exists() and marker.exists() and marker.read_text().strip() == spec.pinned_sha:
return python_exe
_run([sys.executable, "-m", "venv", str(venv_dir)])
_run([str(python_exe), "-m", "pip", "install", "--quiet", "--upgrade", "pip"])
for install in spec.pip_installs:
_run([str(python_exe), "-m", "pip", "install", "--quiet", *install], cwd=repo_dir)
marker.write_text(spec.pinned_sha)
return python_exe
def _resolve_entry(spec: CompetitorSpec, repo_dir: Path) -> tuple[str, ...]:
for candidate in spec.entry_candidates:
if candidate[0] == "-m":
package = candidate[1].replace(".", "/")
roots = [repo_dir, repo_dir / "src"]
if any(
(root / package).is_dir() or (root / f"{package}.py").is_file() for root in roots
):
return candidate
elif (repo_dir / candidate[0]).is_file():
return tuple([str(repo_dir / candidate[0]), *candidate[1:]])
raise RuntimeError(
f"{spec.competitor_id}: no stdio entrypoint found "
f"(tried {[c[0] for c in spec.entry_candidates]})"
)
def ensure_competitor(spec: CompetitorSpec, cache_root: Path | None = None) -> CompetitorRuntime:
"""Clone, pin, install and resolve one competitor. Idempotent."""
root = (cache_root or CACHE_ROOT) / spec.competitor_id
repo_dir = root / "repo"
_ensure_checkout(spec, repo_dir)
python_exe = _ensure_venv(spec, root / "venv", repo_dir)
entry_args = _resolve_entry(spec, repo_dir)
env = dict(spec.launch_env)
if spec.pythonpath is not None:
env["PYTHONPATH"] = str(repo_dir / spec.pythonpath)
return CompetitorRuntime(
python_exe=python_exe, repo_dir=repo_dir, entry_args=entry_args, env=env
)
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
"""Headless performance suite (v3 lane): wall-time on real drawing workloads.
Complements the correctness/coverage lanes with *scale* evidence: how long the
ezdxf engine takes to create, roundtrip, query and quality-check real geometry.
Workloads drive the backend exactly like the MCP tools do (same async methods),
so the numbers include the server-side call overhead, not just raw ezdxf.
Honesty boundaries:
- Self-measurement only — no competitor timing claims (their servers would pay
an extra stdio serialization cost ours does not pay in-process).
- Wall-clock on the machine recorded in the report; absolute numbers move with
hardware, the workload definitions do not.
Run:
python -m benchmarks.perf_suite # human summary
python -m benchmarks.perf_suite --json # machine-readable report
python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import platform
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from backends.ezdxf_backend import EzdxfBackend
@dataclass
class WorkloadResult:
workload_id: str
description: str
wall_ms: float
metric: str
metric_value: float
detail: dict
async def _timed(coro) -> tuple[float, object]:
started = time.perf_counter()
result = await coro
return (time.perf_counter() - started) * 1000.0, result
async def workload_create_lines_2k(backend: EzdxfBackend) -> WorkloadResult:
"""2,000 individual entity_create_line calls (per-call tool overhead included)."""
await backend.drawing_new()
started = time.perf_counter()
for index in range(2_000):
y = float(index % 100)
await backend.entity_create_line(0.0, y, 100.0, y + 1.0)
wall_ms = (time.perf_counter() - started) * 1000.0
count = (await backend.drawing_info()).entity_count
return WorkloadResult(
"create_lines_2k",
"Create 2,000 lines through individual backend calls",
round(wall_ms, 1),
"entities_per_second",
round(2_000 / (wall_ms / 1000.0), 1),
{"entity_count": count},
)
async def workload_roundtrip_10k(backend: EzdxfBackend, scratch: Path) -> WorkloadResult:
"""Build a 10,000-entity drawing, export DXF, reopen, read info."""
await backend.drawing_new()
build_started = time.perf_counter()
for index in range(10_000):
x = float(index % 500)
y = float(index // 500)
await backend.entity_create_line(x, y, x + 0.8, y + 0.8)
build_ms = (time.perf_counter() - build_started) * 1000.0
target = scratch / "perf_roundtrip_10k.dxf"
export_ms, _ = await _timed(backend.drawing_export_dxf(str(target)))
reopened = EzdxfBackend()
await reopened.connect()
try:
reopen_ms, _ = await _timed(reopened.drawing_open(str(target)))
count = (await reopened.drawing_info()).entity_count
finally:
await reopened.disconnect()
total = build_ms + export_ms + reopen_ms
return WorkloadResult(
"roundtrip_10k",
"Create 10,000 lines, export DXF, reopen and verify",
round(total, 1),
"entities_per_second",
round(10_000 / (total / 1000.0), 1),
{
"build_ms": round(build_ms, 1),
"export_ms": round(export_ms, 1),
"reopen_ms": round(reopen_ms, 1),
"file_bytes": target.stat().st_size,
"entity_count": count,
},
)
async def workload_region_query_10k(backend: EzdxfBackend) -> WorkloadResult:
"""Rectangular region query against the 10,000-entity drawing."""
# backend still holds the 10k drawing from the roundtrip workload builder;
# rebuild deterministically to stay order-independent.
await backend.drawing_new()
for index in range(10_000):
x = float(index % 500)
y = float(index // 500)
await backend.entity_create_line(x, y, x + 0.8, y + 0.8)
wall_ms, found = await _timed(backend.analysis_entities_in_region(0.0, 0.0, 250.0, 10.0))
return WorkloadResult(
"region_query_10k",
"Crossing-selection region query over 10,000 entities",
round(wall_ms, 1),
"entities_matched",
float(len(found)),
{"entity_count": 10_000},
)
async def workload_premium_pass(backend: EzdxfBackend) -> WorkloadResult:
"""Small end-to-end quality loop: layers, geometry, dimensions, critique."""
started = time.perf_counter()
await backend.drawing_new()
await backend.drawing_apply_iso_layers("mech")
plate = [
(0.0, 0.0, 120.0, 0.0),
(120.0, 0.0, 120.0, 80.0),
(120.0, 80.0, 0.0, 80.0),
(0.0, 80.0, 0.0, 0.0),
]
for x1, y1, x2, y2 in plate:
await backend.entity_create_line(x1, y1, x2, y2, layer="GEOMETRY")
await backend.entity_create_circle(60.0, 40.0, 12.0, layer="GEOMETRY")
await backend.dimension_linear(0, 0, 120, 0, 60, -15, layer="DIM")
await backend.dimension_linear(0, 0, 0, 80, -15, 40, 90, layer="DIM")
issues = await backend.drawing_critique(None)
wall_ms = (time.perf_counter() - started) * 1000.0
return WorkloadResult(
"premium_pass",
"Plate with hole: ISO layers, geometry, dimensions and full critique",
round(wall_ms, 1),
"critique_issues",
float(len(issues)),
{"entities": (await backend.drawing_info()).entity_count},
)
async def run_suite(scratch: Path) -> dict:
backend = EzdxfBackend()
await backend.connect()
try:
results = [
await workload_create_lines_2k(backend),
await workload_roundtrip_10k(backend, scratch),
await workload_region_query_10k(backend),
await workload_premium_pass(backend),
]
finally:
await backend.disconnect()
return {
"schema_version": "perf/1.0",
"backend": "ezdxf",
"environment": {
"python": sys.version.split()[0],
"implementation": platform.python_implementation(),
"platform": platform.platform(),
"machine": platform.machine(),
},
"workloads": [asdict(item) for item in results],
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
parser.add_argument("--out", type=Path, default=None, help="Also write the report to a file")
parser.add_argument("--scratch", type=Path, default=Path("benchmarks/results/perf-scratch"))
return parser
def main() -> int:
args = build_parser().parse_args()
args.scratch.mkdir(parents=True, exist_ok=True)
report = asyncio.run(run_suite(args.scratch))
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.out is not None:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(rendered + "\n", encoding="utf-8")
if args.json:
print(rendered)
else:
for item in report["workloads"]:
print(
f"{item['workload_id']:>18} {item['wall_ms']:>10.1f} ms "
f"{item['metric']}={item['metric_value']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+31 -17
View File
@@ -66,24 +66,38 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-source-review-v1"
projects = sorted(data["projects"], key=lambda item: item["score"])
labels = [item["repository"] for item in projects]
total = len(projects)
# rank 1 = highest score (drawn topmost by barh with ascending sort)
labels = [f"{total - index}. {item['repository']}" for index, item in enumerate(projects)]
scores = [item["score"] for item in projects]
grades = [item["evidence_grade"] for item in projects]
colors = ["#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781" for label in labels]
# leaderboard palette by rank (rank 1 first), canvas/LLM-benchmark style
rank_palette = [
"#3fb950", # 1 green
"#58a6ff", # 2 sky
"#6e7ce0", # 3 indigo
"#d29922", # 4 amber
"#c4622d", # 5 rust
"#e3b341", # 6 yellow
"#bf4b8a", # 7 magenta
"#f0876e", # 8 salmon
"#8957e5", # 9 purple
]
colors = [rank_palette[(total - 1 - index) % len(rank_palette)] for index in range(total)]
fig, ax = plt.subplots(figsize=(12, 6.4), constrained_layout=True)
fig.patch.set_facecolor("#f6f8fa")
ax.set_facecolor("#f6f8fa")
fig, ax = plt.subplots(figsize=(12, 0.62 * total + 2.4), constrained_layout=True)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
bars = ax.barh(labels, scores, color=colors, height=0.62)
ax.set_xlim(0, 100)
ax.set_xlabel("Weighted capability score / 100", color="#24292f", labelpad=10)
ax.set_xlabel("Weighted capability score / 100", color="#e6edf3", labelpad=10)
ax.set_title(
"Source-reviewed capability benchmark",
"Public AutoCAD MCP leaderboard — Source-reviewed capability benchmark",
loc="left",
fontsize=18,
fontsize=15,
fontweight="bold",
color="#24292f",
color="#e6edf3",
pad=28,
)
ax.text(
@@ -91,17 +105,17 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
1.02,
f"Fixed rubric · reviewed {data['reviewed_at']} · evidence grade shown per project",
transform=ax.transAxes,
color="#57606a",
color="#8b949e",
fontsize=10,
)
for bar, score, grade in zip(bars, scores, grades, strict=True):
ax.text(
min(score + 1.2, 96),
min(score + 1.2, 88),
bar.get_y() + bar.get_height() / 2,
f"{score} · {grade}",
f"{score} / 100 · {grade}",
va="center",
color="#24292f",
color="#e6edf3",
fontweight="bold",
)
@@ -110,13 +124,13 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
-0.17,
"Shared live-run results are not implied. Scores reflect public-source and recorded test evidence.",
transform=ax.transAxes,
color="#57606a",
color="#8b949e",
fontsize=9,
)
ax.grid(axis="x", color="#d0d7de", linewidth=0.8, alpha=0.8)
ax.grid(axis="x", color="#30363d", linewidth=0.8, alpha=0.9)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#57606a")
ax.tick_params(axis="y", colors="#24292f", length=0, pad=8)
ax.tick_params(axis="x", colors="#8b949e")
ax.tick_params(axis="y", colors="#e6edf3", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
+149
View File
@@ -0,0 +1,149 @@
"""Render the live-run benchmark lane (v2 fixed-task matrix) as a deterministic SVG.
Input: the published per-server reports under ``benchmarks/results/published/``
(produced by ``benchmarks.run_competitors``, artifact paths sanitized to
filenames). Output: a GitHub-readable horizontal bar chart showing the
weighted score and task coverage per server.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-livebench.svg"
def load_reports(input_dir: Path) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(input_dir.glob("*.json")):
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "2.0":
continue # other lanes (e.g. perf/1.0) share the published folder
summary = data.get("summary") or {}
if "score" not in summary or "coverage_percent" not in summary:
raise ValueError(f"{path.name}: summary is missing score/coverage")
reports.append(data)
if not reports:
raise SystemExit(f"no published reports found in {input_dir}")
return reports
def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-livebench-v2"
reports = sorted(reports, key=lambda item: item["summary"]["score"])
labels = [item["adapter"] for item in reports]
scores = [item["summary"]["score"] for item in reports]
coverage = [item["summary"]["coverage_percent"] for item in reports]
passed = [item["summary"]["passed"] for item in reports]
attempted = [item["summary"]["attempted"] for item in reports]
# Which task set each server actually ran. Not decoration: since v1.5.0
# the reference adapter runs the 15-task v3 matrix while the pinned
# competitor reports are v2 runs from v1.4, so the bars do not share a
# denominator and a chart that implies they do is the lie this lane
# exists to avoid.
matrices = [item.get("matrix", "v2") for item in reports]
colors = ["#58a6ff" if label == "autocad-mcp-pro" else "#484f58" for label in labels]
fig, ax = plt.subplots(figsize=(12, 4.8), constrained_layout=True)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
bars = ax.barh(labels, scores, color=colors, height=0.58)
ax.set_xlim(0, 100)
ax.set_xlabel(
"Weighted live-run score / 100, within each server's own task matrix",
color="#e6edf3",
labelpad=10,
)
ax.set_title(
"Live-run benchmark - headless ezdxf lane",
loc="left",
fontsize=18,
fontweight="bold",
color="#e6edf3",
pad=28,
)
ax.text(
0,
1.02,
"Same harness, pinned competitor commits; artifacts verified by re-opening the DXF. "
"Matrices differ - read the per-bar label.",
transform=ax.transAxes,
color="#8b949e",
fontsize=10,
)
for bar, score, cov, ok, total, matrix in zip(
bars, scores, coverage, passed, attempted, matrices, strict=True
):
ax.text(
min(score + 1.2, 76),
bar.get_y() + bar.get_height() / 2,
f"{score:g} ({ok}/{total} pass, {cov:g}% coverage, matrix {matrix})",
va="center",
color="#e6edf3",
fontweight="bold",
)
ax.text(
0,
-0.22,
"Unsupported tasks score 0 within a server's own matrix. The tasks added after v2 "
"were never put to the pinned competitor runs; per-task statuses are "
"in benchmarks/results/published/.",
transform=ax.transAxes,
color="#8b949e",
fontsize=9,
)
ax.grid(axis="x", color="#30363d", linewidth=0.8, alpha=0.8)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#8b949e")
ax.tick_params(axis="y", colors="#e6edf3", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro live benchmark renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_DIR)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_reports(args.input_dir), args.output)
print(f"Rendered live benchmark chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+180
View File
@@ -0,0 +1,180 @@
"""Render the live-run task matrix (tasks x servers) as a deterministic SVG.
Input: the published per-server reports under ``benchmarks/results/published/``
(the same files the score chart uses). Output: a status heatmap — every task
against every server — so coverage gaps are visible per capability instead of
being folded into one number.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from benchmarks.tasks_v7 import TASKS_V7
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-taskmatrix.svg"
STATUS_COLORS = {
"pass": "#238636",
"partial": "#d29922",
"fail": "#da3633",
"timeout": "#a40e26",
"unsupported": "#30363d",
"not_run": "#161b22",
}
STATUS_GLYPHS = {
"pass": "P",
"partial": "~",
"fail": "F",
"timeout": "T",
"unsupported": "-",
"not_run": "?",
}
def load_reports(input_dir: Path) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(input_dir.glob("*.json")):
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "2.0":
continue # perf reports and other lanes live in the same folder
reports.append(data)
if not reports:
raise SystemExit(f"no task-matrix reports found in {input_dir}")
# our server first, then alphabetical
reports.sort(key=lambda item: (item["adapter"] != "autocad-mcp-pro", item["adapter"]))
return reports
def render_chart(reports: list[dict[str, Any]], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch, Rectangle
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1"
task_ids = [task.task_id for task in TASKS_V7]
task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V7]
servers = [report["adapter"] for report in reports]
status_by_server = {
report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]}
for report in reports
}
fig, ax = plt.subplots(
figsize=(3.4 + 2.4 * len(servers), 1.8 + 0.52 * len(task_ids)),
constrained_layout=True,
)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
for row, task_id in enumerate(task_ids):
for col, server in enumerate(servers):
status = status_by_server[server].get(task_id, "not_run")
color = STATUS_COLORS.get(status, "#f6f8fa")
ax.add_patch(
Rectangle(
(col + 0.06, row + 0.08),
0.88,
0.84,
facecolor=color,
edgecolor="#30363d",
linewidth=0.8,
)
)
glyph_color = "#ffffff" if status in ("pass", "fail", "timeout") else "#e6edf3"
ax.text(
col + 0.5,
row + 0.5,
STATUS_GLYPHS.get(status, "?"),
ha="center",
va="center",
fontsize=11,
fontweight="bold",
color=glyph_color,
)
ax.set_xlim(0, len(servers))
ax.set_ylim(len(task_ids), 0)
ax.set_xticks([index + 0.5 for index in range(len(servers))])
ax.set_xticklabels(servers, color="#e6edf3", fontsize=10, fontweight="bold")
ax.xaxis.set_ticks_position("top")
ax.set_yticks([index + 0.5 for index in range(len(task_ids))])
ax.set_yticklabels(task_labels, color="#e6edf3", fontsize=9)
ax.tick_params(length=0)
for spine in ax.spines.values():
spine.set_visible(False)
ax.set_title(
"Task matrix — live headless lane",
loc="left",
fontsize=17,
fontweight="bold",
color="#e6edf3",
pad=34,
)
legend_handles = [
Patch(facecolor=STATUS_COLORS["pass"], label="pass"),
Patch(facecolor=STATUS_COLORS["partial"], label="partial"),
Patch(facecolor=STATUS_COLORS["fail"], label="fail"),
Patch(facecolor=STATUS_COLORS["unsupported"], label="unsupported (no equivalent)"),
# Real rows now, not a theoretical state: the competitor reports were
# pinned and run against the v2 matrix, so every task added since v2
# was never put to them. An invented zero would look identical to a
# measured one, so they stay blank and say why.
Patch(facecolor=STATUS_COLORS["not_run"], edgecolor="#30363d", label="not run"),
]
ax.legend(
handles=legend_handles,
loc="upper left",
bbox_to_anchor=(0, -0.02),
ncol=5,
frameon=False,
fontsize=9,
labelcolor="#e6edf3",
)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro task-matrix renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_DIR)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_reports(args.input_dir), args.output)
print(f"Rendered task-matrix chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+146
View File
@@ -0,0 +1,146 @@
"""Render the headless performance suite as a deterministic SVG.
Input: the report produced by ``benchmarks.perf_suite --out ...``
(default: ``benchmarks/results/published/perf-ezdxf.json``).
Output: a GitHub-readable log-scale wall-time chart per workload.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_INPUT = ROOT / "benchmarks" / "results" / "published" / "perf-ezdxf.json"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "autocad-mcp-perf.svg"
_LABELS = {
"create_lines_2k": "Create 2,000 lines (individual calls)",
"roundtrip_10k": "10,000 lines: build + DXF export + reopen",
"region_query_10k": "Region query over 10,000 entities",
"premium_pass": "Premium pass: layers + part + dims + full critique",
}
def load_report(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != "perf/1.0":
raise ValueError("unsupported perf report schema_version")
if not data.get("workloads"):
raise ValueError("perf report contains no workloads")
return data
def _annotation(item: dict[str, Any]) -> str:
wall_s = item["wall_ms"] / 1000.0
if item["metric"] == "entities_per_second":
return f"{wall_s:.2f} s · {item['metric_value']:,.0f} entities/s"
if item["metric"] == "entities_matched":
return f"{wall_s:.2f} s · {item['metric_value']:,.0f} matched"
if item["metric"] == "critique_issues":
return f"{wall_s:.2f} s · {item['metric_value']:.0f} issues"
return f"{wall_s:.2f} s"
def render_chart(data: dict[str, Any], output: Path) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise SystemExit(
'Matplotlib is required. Install it with: pip install -e ".[pdf]"'
) from exc
matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-perf-v1"
workloads = sorted(data["workloads"], key=lambda item: item["wall_ms"])
labels = [_LABELS.get(item["workload_id"], item["workload_id"]) for item in workloads]
values = [item["wall_ms"] for item in workloads]
fig, ax = plt.subplots(figsize=(12, 4.6), constrained_layout=True)
fig.patch.set_facecolor("#0d1117")
ax.set_facecolor("#0d1117")
bars = ax.barh(labels, values, color="#58a6ff", height=0.58)
ax.set_xscale("log")
ax.set_xlabel("Wall time in ms (log scale) — lower is better", color="#e6edf3", labelpad=10)
ax.set_title(
"Headless performance — ezdxf engine",
loc="left",
fontsize=18,
fontweight="bold",
color="#e6edf3",
pad=28,
)
env = data.get("environment", {})
ax.text(
0,
1.02,
f"Fixed workloads via the same backend methods the MCP tools call · "
f"Python {env.get('python', '?')} · {env.get('platform', '?')}",
transform=ax.transAxes,
color="#8b949e",
fontsize=9,
)
for bar, item in zip(bars, workloads, strict=True):
ax.text(
bar.get_width() * 1.12,
bar.get_y() + bar.get_height() / 2,
_annotation(item),
va="center",
color="#e6edf3",
fontweight="bold",
fontsize=9,
)
ax.set_xlim(right=max(values) * 8)
ax.text(
0,
-0.24,
"Self-measurement only: competitor servers would pay extra stdio serialization cost in-process runs do not. "
"Reproduce: python -m benchmarks.perf_suite",
transform=ax.transAxes,
color="#8b949e",
fontsize=9,
)
ax.grid(axis="x", color="#30363d", linewidth=0.8, alpha=0.8)
ax.set_axisbelow(True)
ax.tick_params(axis="x", colors="#8b949e")
ax.tick_params(axis="y", colors="#e6edf3", length=0, pad=8)
for spine in ax.spines.values():
spine.set_visible(False)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
output,
format="svg",
facecolor=fig.get_facecolor(),
metadata={"Creator": "AutoCAD MCP Pro perf renderer", "Date": None},
)
plt.close(fig)
svg = output.read_text(encoding="utf-8")
normalized = "\n".join(line.rstrip() for line in svg.splitlines()) + "\n"
output.write_text(normalized, encoding="utf-8", newline="\n")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=DEFAULT_INPUT)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
return parser
def main() -> int:
args = build_parser().parse_args()
render_chart(load_report(args.input), args.output)
print(f"Rendered perf chart: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,140 @@
{
"summary": {
"baseline_ref": "v1.3.0",
"total": 21,
"old_pass": 21,
"new_pass": 21,
"old_pct": 100.0,
"new_pct": 100.0,
"fixed": 0,
"regressed": 0
},
"rows": [
{
"check": "core_line_length",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_circle_radius",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_layer_create",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_polyline_closed",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_linear_dim",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_save_dxf_roundtrip",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "dim_aligned_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "dim_angular_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "array_polar_360_distinct",
"category": "Modify",
"old": "pass",
"new": "pass"
},
{
"check": "point_intersection_line_line",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "point_tangent_external",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "arc_has_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "arc_select_by_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "ezdxf_bounding_box",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "mtext_rotation_roundtrip",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "screenshot_png",
"category": "Render",
"old": "pass",
"new": "pass"
},
{
"check": "dim_overlap_critique_fires",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "iso13567_dim_layer",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "construction_left_iso_caught",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "gear_no_self_overlap",
"category": "Engineering",
"old": "pass",
"new": "pass"
},
{
"check": "center_linetype_applied",
"category": "Entities",
"old": "pass",
"new": "pass"
}
]
}
@@ -0,0 +1,170 @@
{
"summary": {
"baseline_ref": "v1.4.0",
"total": 26,
"old_pass": 21,
"new_pass": 26,
"old_pct": 80.8,
"new_pct": 100.0,
"fixed": 5,
"regressed": 0
},
"rows": [
{
"check": "core_line_length",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_circle_radius",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_layer_create",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_polyline_closed",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_linear_dim",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_save_dxf_roundtrip",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "dim_aligned_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "dim_angular_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "array_polar_360_distinct",
"category": "Modify",
"old": "pass",
"new": "pass"
},
{
"check": "point_intersection_line_line",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "point_tangent_external",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "arc_has_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "arc_select_by_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "ezdxf_bounding_box",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "mtext_rotation_roundtrip",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "screenshot_png",
"category": "Render",
"old": "pass",
"new": "pass"
},
{
"check": "dim_overlap_critique_fires",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "iso13567_dim_layer",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "construction_left_iso_caught",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "gear_no_self_overlap",
"category": "Engineering",
"old": "pass",
"new": "pass"
},
{
"check": "center_linetype_applied",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "hatch_area_subtracts_its_island",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "boundary_area_agrees_with_measure",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "two_vertex_circle_has_area",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "diameter_dim_measures_the_diameter",
"category": "Dimensions",
"old": "fail",
"new": "pass"
},
{
"check": "radius_dim_ignores_the_leader_length",
"category": "Dimensions",
"old": "fail",
"new": "pass"
}
]
}
@@ -0,0 +1,170 @@
{
"summary": {
"baseline_ref": "v1.4.0",
"total": 26,
"old_pass": 21,
"new_pass": 26,
"old_pct": 80.8,
"new_pct": 100.0,
"fixed": 5,
"regressed": 0
},
"rows": [
{
"check": "core_line_length",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_circle_radius",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_layer_create",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_polyline_closed",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_linear_dim",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_save_dxf_roundtrip",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "dim_aligned_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "dim_angular_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "array_polar_360_distinct",
"category": "Modify",
"old": "pass",
"new": "pass"
},
{
"check": "point_intersection_line_line",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "point_tangent_external",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "arc_has_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "arc_select_by_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "ezdxf_bounding_box",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "mtext_rotation_roundtrip",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "screenshot_png",
"category": "Render",
"old": "pass",
"new": "pass"
},
{
"check": "dim_overlap_critique_fires",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "iso13567_dim_layer",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "construction_left_iso_caught",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "gear_no_self_overlap",
"category": "Engineering",
"old": "pass",
"new": "pass"
},
{
"check": "center_linetype_applied",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "hatch_area_subtracts_its_island",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "boundary_area_agrees_with_measure",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "two_vertex_circle_has_area",
"category": "Measurement",
"old": "miss",
"new": "pass"
},
{
"check": "diameter_dim_measures_the_diameter",
"category": "Dimensions",
"old": "fail",
"new": "pass"
},
{
"check": "radius_dim_ignores_the_leader_length",
"category": "Dimensions",
"old": "fail",
"new": "pass"
}
]
}
@@ -0,0 +1,170 @@
{
"summary": {
"baseline_ref": "v1.5.0",
"total": 26,
"old_pass": 24,
"new_pass": 26,
"old_pct": 92.3,
"new_pct": 100.0,
"fixed": 2,
"regressed": 0
},
"rows": [
{
"check": "core_line_length",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_circle_radius",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_layer_create",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_polyline_closed",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_linear_dim",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "core_save_dxf_roundtrip",
"category": "Core",
"old": "pass",
"new": "pass"
},
{
"check": "dim_aligned_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "dim_angular_no_error",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "array_polar_360_distinct",
"category": "Modify",
"old": "pass",
"new": "pass"
},
{
"check": "point_intersection_line_line",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "point_tangent_external",
"category": "Geometry",
"old": "pass",
"new": "pass"
},
{
"check": "arc_has_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "arc_select_by_length",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "ezdxf_bounding_box",
"category": "Query",
"old": "pass",
"new": "pass"
},
{
"check": "mtext_rotation_roundtrip",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "screenshot_png",
"category": "Render",
"old": "pass",
"new": "pass"
},
{
"check": "dim_overlap_critique_fires",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "iso13567_dim_layer",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "construction_left_iso_caught",
"category": "Quality gate",
"old": "pass",
"new": "pass"
},
{
"check": "gear_no_self_overlap",
"category": "Engineering",
"old": "pass",
"new": "pass"
},
{
"check": "center_linetype_applied",
"category": "Entities",
"old": "pass",
"new": "pass"
},
{
"check": "hatch_area_subtracts_its_island",
"category": "Measurement",
"old": "pass",
"new": "pass"
},
{
"check": "boundary_area_agrees_with_measure",
"category": "Measurement",
"old": "pass",
"new": "pass"
},
{
"check": "two_vertex_circle_has_area",
"category": "Measurement",
"old": "pass",
"new": "pass"
},
{
"check": "diameter_dim_measures_the_diameter",
"category": "Dimensions",
"old": "fail",
"new": "pass"
},
{
"check": "radius_dim_ignores_the_leader_length",
"category": "Dimensions",
"old": "fail",
"new": "pass"
}
]
}
@@ -0,0 +1,302 @@
{
"summary": {
"baseline_ref": "v1.5.1",
"total": 48,
"old_pass": 26,
"new_pass": 48,
"old_pct": 54.2,
"new_pct": 100.0,
"fixed": 22,
"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": "pass",
"new": "pass"
},
{
"check": "radius_dim_ignores_the_leader_length",
"category": "Dimensions",
"old": "pass",
"new": "pass"
},
{
"check": "pid_block_define_attdef_roundtrip",
"category": "P&ID",
"old": "miss",
"new": "pass"
},
{
"check": "pid_tag_parse_fic",
"category": "P&ID",
"old": "miss",
"new": "pass"
},
{
"check": "pid_graph_edge_count",
"category": "P&ID",
"old": "miss",
"new": "pass"
},
{
"check": "settings_dimstyle_iso25_values",
"category": "Settings",
"old": "miss",
"new": "pass"
},
{
"check": "settings_layer_state_roundtrip",
"category": "Settings",
"old": "miss",
"new": "pass"
},
{
"check": "settings_pdf_mediabox_a3",
"category": "Settings",
"old": "miss",
"new": "pass"
},
{
"check": "mech_part_roundtrip",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "mech_section_hatch_area",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "mech_iso286_on_dimension",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "mech_thread_unrepresented_is_caught",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "std_part_iso4014_m12",
"category": "Mechanical",
"old": "miss",
"new": "pass"
},
{
"check": "sheet_frame_iso5457_a3",
"category": "Sheet",
"old": "miss",
"new": "pass"
},
{
"check": "bom_balloon_link",
"category": "Sheet",
"old": "miss",
"new": "pass"
},
{
"check": "arch_junction_l_t_x",
"category": "Architecture",
"old": "miss",
"new": "pass"
},
{
"check": "arch_room_area_net",
"category": "Architecture",
"old": "miss",
"new": "pass"
},
{
"check": "arch_opening_cuts_wall",
"category": "Architecture",
"old": "miss",
"new": "pass"
},
{
"check": "arch_rooms_detect_foreign",
"category": "Architecture",
"old": "miss",
"new": "pass"
},
{
"check": "scale_check_detects_schematic",
"category": "Understanding",
"old": "miss",
"new": "pass"
},
{
"check": "pipe_takeoff_rmst_exact",
"category": "Understanding",
"old": "miss",
"new": "pass"
},
{
"check": "cable_takeoff_roundup",
"category": "Understanding",
"old": "miss",
"new": "pass"
},
{
"check": "diff_detects_known_edits",
"category": "Understanding",
"old": "miss",
"new": "pass"
},
{
"check": "topology_known_defects",
"category": "Understanding",
"old": "miss",
"new": "pass"
}
]
}
@@ -0,0 +1,816 @@
{
"adapter": "autocad-mcp-pro",
"adapter_metadata": {
"backend": "ezdxf",
"capabilities": {
"backend": "ezdxf",
"features": {
"audit_detail": {
"mode": "native",
"reason": null,
"supported": true
},
"boundary_trace": {
"mode": "computed",
"reason": "straight_edges_split_at_intersections;curved_edges_not_split",
"supported": true
},
"chspace": {
"mode": "computed",
"reason": "top_view_untwisted_viewports_only;dimensions_refused_unless_frozen;acis_proxy_table_refused;viewport_clipping_reported_not_applied",
"supported": true
},
"delivery": {
"mode": "shared",
"reason": null,
"supported": true
},
"documents": {
"mode": "registry",
"reason": null,
"supported": true
},
"drawing_2d": {
"mode": "native",
"reason": null,
"supported": true
},
"dwg": {
"mode": null,
"reason": "no_dwg_writer_requires_live_autocad",
"supported": false
},
"dwg_write": {
"mode": null,
"reason": "requires_oda_file_converter:https://www.opendesign.com/guestfiles/oda_file_converter",
"supported": false
},
"dwgprops": {
"mode": null,
"reason": "summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer",
"supported": false
},
"dwt_write": {
"mode": null,
"reason": "dwt_is_a_dwg_container_requires_live_autocad",
"supported": false
},
"dxf": {
"mode": "native",
"reason": null,
"supported": true
},
"explode_opaque_members": {
"mode": null,
"reason": "ezdxf_cannot_transform:OLE2FRAME,VIEWPORT,ACAD_PROXY_ENTITY_without_proxy_graphic;refused_before_writing",
"supported": false
},
"handle_overlay": {
"mode": "rendered",
"reason": null,
"supported": true
},
"hatch_edge_paths": {
"mode": "native",
"reason": null,
"supported": true
},
"interactive_prompt": {
"mode": null,
"reason": "no_operator_to_prompt_headlessly",
"supported": false
},
"layer_states": {
"mode": "xrecord",
"reason": "portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
"supported": true
},
"lisp": {
"mode": null,
"reason": "live_com_only",
"supported": false
},
"live_application": {
"mode": null,
"reason": "headless_engine_has_no_application_to_launch_or_attach",
"supported": false
},
"measure_area_acis": {
"mode": null,
"reason": "acis_evaluation_requires_live_autocad",
"supported": false
},
"mleader": {
"mode": "composite",
"reason": null,
"supported": true
},
"mtext_background_color": {
"mode": "native",
"reason": null,
"supported": true
},
"named_views": {
"mode": "vport_active",
"reason": "restore_sets_the_view_the_file_opens_on;no_live_display",
"supported": true
},
"ocs_normalized": {
"mode": "wcs",
"reason": "reported_xy_is_wcs;z_component_not_reported;text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported",
"supported": true
},
"ocs_tilted_plane": {
"mode": null,
"reason": "2d_xy_cannot_address_a_tilted_plane",
"supported": false
},
"paper_space": {
"mode": "native",
"reason": null,
"supported": true
},
"pdf": {
"mode": "rendered",
"reason": null,
"supported": true
},
"png": {
"mode": "rendered",
"reason": null,
"supported": true
},
"preferences": {
"mode": null,
"reason": "preferences_live_in_the_running_application_not_in_a_file",
"supported": false
},
"preflight": {
"mode": "shared",
"reason": null,
"supported": true
},
"refiner": {
"mode": "shared",
"reason": null,
"supported": true
},
"registry_sysvar": {
"mode": null,
"reason": "registry_saved_variables_have_no_home_in_a_file",
"supported": false
},
"revcloud": {
"mode": "native",
"reason": "ezdxf_revcloud_helper",
"supported": true
},
"solid_3d": {
"mode": null,
"reason": "acis_generation_requires_live_autocad",
"supported": false
},
"table": {
"mode": "composite",
"reason": null,
"supported": true
},
"transactions": {
"mode": "snapshot",
"reason": null,
"supported": true
},
"ucs": {
"mode": "stored_not_interpreted",
"reason": "tool_coordinates_stay_wcs",
"supported": true
},
"undo_history": {
"mode": null,
"reason": "set_EZDXF_UNDO_DEPTH_to_enable",
"supported": false
},
"viewport_render": {
"mode": "rendered",
"reason": "viewport_borders_not_drawn",
"supported": true
},
"wipeout": {
"mode": "native",
"reason": null,
"supported": true
},
"xlsx_write": {
"mode": "openpyxl",
"reason": null,
"supported": true
},
"xref_live": {
"mode": null,
"reason": "reload_and_bind_need_a_live_seat;list_detach_path_are_headless",
"supported": false
}
}
}
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"generated_at": "2026-09-24T20:58:06.608758+00:00",
"git_sha": "554daff8d08c7d891974e55db046e7c70c15e185",
"matrix": "v7",
"results": [
{
"artifacts": [],
"duration_ms": 2.71,
"message": "",
"metrics": {
"line_length": 5.0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [],
"duration_ms": 1.92,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [],
"duration_ms": 3.38,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [],
"duration_ms": 24.95,
"message": "",
"metrics": {
"entity_type": "DIMENSION"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 3.64,
"message": "",
"metrics": {
"representations": [
"composite",
"composite"
]
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 8.37,
"message": "",
"metrics": {
"entity_count": 0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.85,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 17.45,
"message": "",
"metrics": {
"final_score": 100.0,
"initial_score": 92.0,
"remaining_issues": [],
"rounds": [
{
"actions": [
{
"detail": {
"deleted_handle": "3E",
"kept_handle": "3D"
},
"fingerprint": "c1b87eac3cfdf11312c9b18ccf4c062e672791f7a9a6dfa3c45b6db7f9e5835e",
"focus": "duplicate_entities",
"handles": [
"3D",
"3E"
],
"status": "applied"
}
],
"error_count_after": 0,
"error_count_before": 0,
"number": 1,
"score_after": 100.0,
"score_before": 92.0,
"transaction": "committed"
}
],
"status": "threshold_met"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "roundtrip.dxf",
"sha256": "28a18050aea00646f9ff7a5fdf8fe3b8783cd4cb77d71108b3834c746e7259c5",
"size_bytes": 18446
}
],
"duration_ms": 7.21,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [
{
"filename": "manifest.json",
"sha256": "7178055a030b533e83c4a914466c7474350e7728657cd624c9ebc01c753d9e13",
"size_bytes": 7929
}
],
"duration_ms": 274.87,
"message": "",
"metrics": {
"score": {
"errors": 0,
"grade": "A",
"info": 0,
"invalidity_ratio": 0.0,
"score": 100.0,
"warnings": 0
}
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
},
{
"artifacts": [],
"duration_ms": 699.92,
"message": "",
"metrics": {
"ranks": {
"BPOLY": 1,
"CHSPACE": 1,
"FILLET": 1,
"OVERKILL": 1,
"QSELECT": 1,
"WBLOCK": 1
},
"top_n": 3
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "tool_discovery"
},
{
"artifacts": [],
"duration_ms": 86.79,
"message": "",
"metrics": {
"advertised_tools": 2,
"catalog_tokens": 83921,
"catalog_tools": 242,
"ceiling": 2000,
"discovery_tokens": 356,
"reduction_x": 235.7,
"tokenizer": "ratio/v1 (estimate)"
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "token_budget"
},
{
"artifacts": [],
"duration_ms": 7.47,
"message": "",
"metrics": {
"area_ignoring_islands": 400.0,
"expected_filled": 300.0,
"filled_area": 300.0,
"hatch_style": "outer",
"path_count": 2
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "hatch_islands"
},
{
"artifacts": [],
"duration_ms": 26.1,
"message": "",
"metrics": {
"bounding_box": 3,
"by_layer": 1,
"crossing": 2,
"polygon": 1,
"window": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "selection_filter"
},
{
"artifacts": [],
"duration_ms": 5.83,
"message": "",
"metrics": {
"boundary_agrees": true,
"fraction_lost_by_reading_points_back": 0.282,
"measured_area": 139.269908,
"true_area": 139.269908,
"vertex_shoelace_area": 100.0
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "measure_from_handle"
},
{
"artifacts": [],
"duration_ms": 58.19,
"message": "",
"metrics": {
"authored_line_numbers_kept": true,
"confidence_min": 1.0,
"critique_issues": 0,
"dangling": 0,
"edges": 4,
"instrument_index_rows": 1,
"line_list_rows": 4,
"nodes": 5,
"unnumbered_lines_sequenced": true
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "pid_roundtrip"
},
{
"artifacts": [
{
"filename": "untitled-Layout1-ansi_b.pdf",
"sha256": "af3c19d4d8b14fbc116ab807bb6feee9fc900fc114e57cd6feb658a6c0dabffb",
"size_bytes": 1287
},
{
"filename": "untitled-Layout1-iso_a3.pdf",
"sha256": "8d1d1ed505903b6f692ed6e91e9f52577c2c78db3bc2942b949ea4a3d73c9b01",
"size_bytes": 1287
}
],
"duration_ms": 65.46,
"message": "",
"metrics": {
"mediabox_mm": [
420.0,
297.0
],
"mediabox_mm_before": [
432.0,
279.0
],
"orientation": "landscape",
"paper": "ISO_A3",
"pdf_bytes": 1287,
"plot_style_known": true,
"size_mm_changed": [
[
432.0,
279.0
],
[
420.0,
297.0
]
],
"size_mm_listed": [
420.0,
297.0
]
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "page_setup_truth"
},
{
"artifacts": [
{
"filename": "mech_assembly.dxf",
"sha256": "407dcbed3eb17be7f9d72dda2c1951e8f97fa3fb6090713440404a3f1497d1b2",
"size_bytes": 101158
}
],
"duration_ms": 487.19,
"message": "",
"metrics": {
"critique_focuses": [],
"critique_issues": 0,
"fasteners": 4,
"grade": "A",
"invalidity_ratio": 0.0,
"parts_list_rows": 2,
"score": 100.0,
"validator_findings": []
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "mech_assembly"
},
{
"artifacts": [
{
"filename": "arch_roundtrip.dxf",
"sha256": "ebec378ef28670e091e651b4130a6eac5a0d2fb480d60f9335b37de28ea713c7",
"size_bytes": 82829
}
],
"duration_ms": 982.07,
"message": "",
"metrics": {
"confidence_min": 1.0,
"critique_focuses": [],
"critique_issues": 0,
"door_tags": [
"D1",
"D2"
],
"faces_detected": 2,
"grade": "A",
"invalidity_ratio": 0.0,
"omitted": [],
"rooms": {
"01": {
"agrees": true,
"detected_mm2": 27743750.0,
"hand_mm2": 27743750.0,
"label_mm2": 27743750.0
},
"02": {
"agrees": true,
"detected_mm2": 21993750.0,
"hand_mm2": 21993750.0,
"label_mm2": 21993750.0
}
},
"score": 100.0,
"validator_findings": [],
"window_tags": [
"W1",
"W2"
]
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "arch_roundtrip"
},
{
"artifacts": [
{
"filename": "pid.dxf",
"sha256": "94dbdc026878927560a6c81e2283a7d2a26fbe6a398475d415c6383728c46672",
"size_bytes": 33795
},
{
"filename": "layout.dxf",
"sha256": "37963e237f27f9f495b30d7c0b3bf7f4eff54ff4fec9665f34ab50ddc3f3c7a7",
"size_bytes": 40503
}
],
"duration_ms": 614.2,
"message": "",
"metrics": {
"cable_mismatch": [],
"length_source": "layout",
"loads_checked": 4,
"pipe_mismatch": [],
"runs_checked": 9,
"scale_verdict": "schematic",
"within_10pct": 0.21818181818181817
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "takeoff_roundtrip"
},
{
"artifacts": [
{
"filename": "layout.dxf",
"sha256": "ff728965a5698cf9ddf9d9a01cfa244102d183065a149488d4d29dbcee404e85",
"size_bytes": 40503
},
{
"filename": "pid.dxf",
"sha256": "de4f1ab4102d220efa7167b192b8fdaf9d57cbf300241e945c16967770401cd8",
"size_bytes": 33795
}
],
"duration_ms": 311.97,
"message": "",
"metrics": {
"clusters": 2,
"copies_separated": true,
"misread_layers": {},
"outlier_found": true,
"units": {
"agree": false,
"confidence": 0.76,
"declared": {
"code": 1,
"label": "Inches",
"name": "in"
},
"evidence": [
{
"as": {
"cm": 2.5,
"ft": 76.2,
"in": 6.35,
"m": 250.0,
"mm": 0.25
},
"band": [
0.0018,
4.0
],
"band_unit": "m",
"count": 26,
"informative": true,
"kind": "text_height",
"plausible": [
"mm",
"cm"
],
"value": 250.0,
"weight": 0.5
},
{
"as": {
"cm": 2.0,
"ft": 60.96,
"in": 5.08,
"m": 200.0,
"mm": 0.2
},
"band": [
0.05,
1.0
],
"band_unit": "m",
"count": 24,
"informative": true,
"kind": "wall_thickness",
"plausible": [
"mm"
],
"value": 200.0,
"weight": 1.0
},
{
"as": {
"cm": 21480.0,
"ft": 19955572.992000002,
"in": 138580.368,
"m": 214800000.0,
"mm": 214.79999999999998
},
"band": [
1.0,
5000.0
],
"band_unit": "m2",
"count": 4,
"informative": true,
"kind": "room_area",
"plausible": [
"mm"
],
"value": 214800000.0,
"weight": 1.0
},
{
"as": {
"cm": 1364.0,
"ft": 41574.72,
"in": 3464.56,
"m": 136400.0,
"mm": 136.4
},
"band": [
0.01,
2000.0
],
"band_unit": "m",
"count": 80,
"informative": true,
"kind": "overall_size",
"plausible": [
"mm",
"cm"
],
"value": 136400.0,
"weight": 0.25
}
],
"inferred": "mm",
"mm_per_unit": 1.0,
"scores": {
"cm": 0.75,
"ft": 0,
"in": 0.5,
"m": 0,
"mm": 2.75
},
"warning": "INSUNITS declares Inches (1) but the geometry reads as mm: text_height 250 = 0.25 m as mm, 6.35 m as in; wall_thickness 200 = 0.2 m as mm, 5.08 m as in; room_area 2.148e+08 = 214.8 m2 as mm, 1.386e+05 m2 as in; overall_size 136400 = 136.4 m as mm, 3465 m as in. Lengths are measured in mm; the header is not changed."
},
"units_flagged": true
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "understand_foreign"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 21,
"coverage_percent": 100.0,
"passed": 21,
"score": 100.0,
"supported": 21,
"unsupported": 0
},
"timeout_seconds": 30.0
}
@@ -0,0 +1,191 @@
{
"adapter": "beiming183-autocad-mcp",
"adapter_metadata": {
"backend": "ezdxf",
"kind": "mcp-stdio-blackbox",
"pinned_sha": "11f7c47e5038796a20451b38b23032e625b5aa26",
"repo": "https://github.com/beiming183-cloud/AutoCAD-MCP.git",
"tools_discovered": [
"annotation",
"block",
"drawing",
"entity",
"job",
"layer",
"pid",
"product",
"solid",
"system",
"transaction",
"view"
]
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:38:36.969519+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [
{
"filename": "beiming_core_geometry.dxf",
"sha256": "d972b29da994b1ea8f3887eef84f2ed892a486409385b56cd444b1fe9794e337",
"size_bytes": 18587
}
],
"duration_ms": 96.24,
"message": "",
"metrics": {
"circles": 1,
"line_length": 5.0,
"lines": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [
{
"filename": "beiming_modify_query.dxf",
"sha256": "298534d8512ce62833551ab6e54c0c2a0baf18bdf7a96bd956cdf06280fb4fb8",
"size_bytes": 18473
}
],
"duration_ms": 108.09,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [
{
"filename": "beiming_layers.dxf",
"sha256": "b401e145e97aca7011d03097190b3d0ece0a89b47d38022a81c6c4c7e250ed3d",
"size_bytes": 19887
}
],
"duration_ms": 69.86,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [
{
"filename": "beiming_dimensions.dxf",
"sha256": "8996d2ba42da66f46e79d1655405a4bf2ba72301861eeea58509cba44c408fb4",
"size_bytes": 20972
}
],
"duration_ms": 75.55,
"message": "",
"metrics": {
"dimension_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 0.12,
"message": "no TABLE or MLEADER operation in the annotation tool (v3.10)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 18.99,
"message": "transaction begin is rejected as unsupported on the headless backend",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.04,
"message": "no requirements-preflight equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 0.02,
"message": "audits detect issues but there is no automated repair loop",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "beiming_roundtrip.dxf",
"sha256": "79d7bdc1e66e506dd2ecf66fbf030839242c77870021ccbfe3c819ad394695ce",
"size_bytes": 18474
}
],
"duration_ms": 69.95,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [],
"duration_ms": 0.06,
"message": "drawing.deliver is documented as File-IPC only (not headless)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 50.0,
"passed": 5,
"score": 50.0,
"supported": 5,
"unsupported": 5
},
"timeout_seconds": 30.0
}
@@ -0,0 +1,85 @@
{
"backend": "ezdxf",
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"schema_version": "perf/1.0",
"workloads": [
{
"description": "Create 2,000 lines through individual backend calls",
"detail": {
"entity_count": 2000
},
"metric": "entities_per_second",
"wall_ms": 306.1,
"workload_id": "create_lines_2k",
"runs_ms": [
287.6,
373.6,
298.2,
425.1,
306.1
],
"metric_value": 6533.8
},
{
"description": "Create 10,000 lines, export DXF, reopen and verify",
"detail": {
"build_ms": 2149.4,
"entity_count": 10000,
"export_ms": 516.6,
"file_bytes": 1369662,
"reopen_ms": 535.2
},
"metric": "entities_per_second",
"wall_ms": 2553.5,
"workload_id": "roundtrip_10k",
"runs_ms": [
3201.3,
2550.0,
3118.3,
2553.5,
2289.4
],
"metric_value": 3916.2
},
{
"description": "Crossing-selection region query over 10,000 entities",
"detail": {
"entity_count": 10000
},
"metric": "entities_matched",
"metric_value": 2500.0,
"wall_ms": 712.2,
"workload_id": "region_query_10k",
"runs_ms": [
712.2,
806.7,
295.1,
836.4,
300.6
]
},
{
"description": "Plate with hole: ISO layers, geometry, dimensions and full critique",
"detail": {
"entities": 7
},
"metric": "critique_issues",
"metric_value": 0.0,
"wall_ms": 65.3,
"workload_id": "premium_pass",
"runs_ms": [
76.3,
65.3,
36.9,
83.8,
41.3
]
}
],
"aggregation": "median of 5 default-configuration runs, one machine, one interpreter"
}
@@ -0,0 +1,142 @@
{
"schema_version": "perf-ab/1.0",
"method": "benchmarks/perf_suite.py of 1.6.0 run against each tree's own code with one CPython 3.11.15 and one ezdxf, five rounds, the three configurations alternating within a round; medians of wall_ms. v1.4.0 had no perf suite; the 1.6.0 script drives the same backend methods.",
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-10-10.0.26200-SP0",
"python": "3.11.15"
},
"medians_ms": {
"v1.4.0": {
"create_lines_2k": 244.5,
"roundtrip_10k": 1847.9,
"region_query_10k": 665.2,
"premium_pass": 150.6
},
"v1.6.0": {
"create_lines_2k": 306.1,
"roundtrip_10k": 2553.5,
"region_query_10k": 712.2,
"premium_pass": 65.3
},
"v1.6.0 EZDXF_CALL_TIMEOUT=0": {
"create_lines_2k": 284.9,
"roundtrip_10k": 2216.4,
"region_query_10k": 783.9,
"premium_pass": 69.9
}
},
"ratios_to_v1_4_0": {
"v1.6.0": {
"create_lines_2k": 1.25,
"roundtrip_10k": 1.38,
"region_query_10k": 1.07,
"premium_pass": 0.43
},
"v1.6.0 EZDXF_CALL_TIMEOUT=0": {
"create_lines_2k": 1.17,
"roundtrip_10k": 1.2,
"region_query_10k": 1.18,
"premium_pass": 0.46
}
},
"runs": {
"v1.4.0": [
{
"create_lines_2k": 244.5,
"roundtrip_10k": 1936.8,
"region_query_10k": 670.3,
"premium_pass": 160.3
},
{
"create_lines_2k": 221.9,
"roundtrip_10k": 1592.5,
"region_query_10k": 638.2,
"premium_pass": 150.6
},
{
"create_lines_2k": 236.7,
"roundtrip_10k": 1706.6,
"region_query_10k": 927.2,
"premium_pass": 140.2
},
{
"create_lines_2k": 261.0,
"roundtrip_10k": 1979.2,
"region_query_10k": 665.2,
"premium_pass": 191.4
},
{
"create_lines_2k": 308.4,
"roundtrip_10k": 1847.9,
"region_query_10k": 296.5,
"premium_pass": 70.3
}
],
"v1.6.0": [
{
"create_lines_2k": 287.6,
"roundtrip_10k": 3201.3,
"region_query_10k": 712.2,
"premium_pass": 76.3
},
{
"create_lines_2k": 373.6,
"roundtrip_10k": 2550.0,
"region_query_10k": 806.7,
"premium_pass": 65.3
},
{
"create_lines_2k": 298.2,
"roundtrip_10k": 3118.3,
"region_query_10k": 295.1,
"premium_pass": 36.9
},
{
"create_lines_2k": 425.1,
"roundtrip_10k": 2553.5,
"region_query_10k": 836.4,
"premium_pass": 83.8
},
{
"create_lines_2k": 306.1,
"roundtrip_10k": 2289.4,
"region_query_10k": 300.6,
"premium_pass": 41.3
}
],
"v1.6.0 EZDXF_CALL_TIMEOUT=0": [
{
"create_lines_2k": 285.3,
"roundtrip_10k": 1817.2,
"region_query_10k": 926.5,
"premium_pass": 61.4
},
{
"create_lines_2k": 256.1,
"roundtrip_10k": 2520.9,
"region_query_10k": 750.3,
"premium_pass": 80.2
},
{
"create_lines_2k": 260.2,
"roundtrip_10k": 1714.1,
"region_query_10k": 783.9,
"premium_pass": 76.7
},
{
"create_lines_2k": 351.7,
"roundtrip_10k": 3057.1,
"region_query_10k": 940.8,
"premium_pass": 69.9
},
{
"create_lines_2k": 284.9,
"roundtrip_10k": 2216.4,
"region_query_10k": 311.2,
"premium_pass": 41.7
}
]
}
}
@@ -0,0 +1,187 @@
{
"adapter": "puran-water-autocad-mcp",
"adapter_metadata": {
"backend": "ezdxf",
"kind": "mcp-stdio-blackbox",
"pinned_sha": "95476a33a1c246308326eb4709d6379ef2efdbc1",
"repo": "https://github.com/puran-water/autocad-mcp.git",
"tools_discovered": [
"annotation",
"block",
"drawing",
"entity",
"layer",
"pid",
"system",
"view"
]
},
"environment": {
"implementation": "CPython",
"machine": "AMD64",
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.14.0"
},
"generated_at": "2026-07-23T11:32:07.977232+00:00",
"git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4",
"results": [
{
"artifacts": [
{
"filename": "puran_core_geometry.dxf",
"sha256": "bc8185404c8344134859293a0e28ebf821cd5968553cdd96b7029e46e1ca0c99",
"size_bytes": 18587
}
],
"duration_ms": 205.92,
"message": "",
"metrics": {
"circles": 1,
"line_length": 5.0,
"lines": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "core_geometry"
},
{
"artifacts": [
{
"filename": "puran_modify_query.dxf",
"sha256": "2113a59d0a9f624b6134e89f992d153bc8e085495e956f4887b8ba7b54f3c005",
"size_bytes": 18473
}
],
"duration_ms": 156.79,
"message": "",
"metrics": {},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "modify_query"
},
{
"artifacts": [
{
"filename": "puran_layers.dxf",
"sha256": "7144467e22e2e8c8d18f1fc4f177f078f9ce450bb73f4e9ca8f74c16d67e1b53",
"size_bytes": 18498
}
],
"duration_ms": 189.98,
"message": "layer created; no linetype-loading operation is exposed",
"metrics": {},
"score": 50.0,
"status": "partial",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "layers_linetypes"
},
{
"artifacts": [
{
"filename": "puran_dimensions.dxf",
"sha256": "dde808c5e504c5e6b7837f5f5c14ef096711812a6239dd284906efed5c9e3614",
"size_bytes": 20972
}
],
"duration_ms": 215.28,
"message": "",
"metrics": {
"dimension_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dimensions"
},
{
"artifacts": [],
"duration_ms": 0.12,
"message": "no TABLE or MLEADER operation in the 8-tool surface (v3.1)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "table_mleader"
},
{
"artifacts": [],
"duration_ms": 0.02,
"message": "no transaction begin/commit/rollback tool (undo/redo are File-IPC only)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "transactions"
},
{
"artifacts": [],
"duration_ms": 0.01,
"message": "no requirements-preflight equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "preflight"
},
{
"artifacts": [],
"duration_ms": 0.03,
"message": "no critique/repair loop equivalent",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "quality_refiner"
},
{
"artifacts": [
{
"filename": "puran_roundtrip.dxf",
"sha256": "11335953641be32f3f47c23fbc13f79876111c6f84f8874af8f5e04d5e398cf6",
"size_bytes": 18474
}
],
"duration_ms": 143.4,
"message": "",
"metrics": {
"entity_count": 1
},
"score": 100.0,
"status": "pass",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "dxf_roundtrip"
},
{
"artifacts": [],
"duration_ms": 0.06,
"message": "no hashed delivery-manifest equivalent (plot_pdf is File-IPC only)",
"metrics": {},
"score": 0.0,
"status": "unsupported",
"stderr_summary": "",
"stdout_summary": "",
"task_id": "auditable_delivery"
}
],
"schema_version": "2.0",
"summary": {
"attempted": 10,
"coverage_percent": 50.0,
"passed": 4,
"score": 45.0,
"supported": 5,
"unsupported": 5
},
"timeout_seconds": 30.0
}
+89 -13
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import asyncio
import hashlib
import importlib
import json
import platform
import subprocess
@@ -13,8 +14,22 @@ import time
from datetime import UTC, datetime
from pathlib import Path
from backends.capability import UnsupportedCapabilityError
from benchmarks.adapters.base import BenchmarkAdapter, TaskResult
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec, task_by_id
from benchmarks.tasks_v7 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id
def _load_registry() -> dict[str, dict]:
"""Read competitors.yaml (JSON content) into an id -> entry registry."""
path = Path(__file__).with_name("competitors.yaml")
data = json.loads(path.read_text(encoding="utf-8"))
return {entry["id"]: entry for entry in data.get("competitors", [])}
def _make_adapter(entry: dict, backend: str) -> BenchmarkAdapter:
module_name, _, attr = entry["adapter"].partition(":")
adapter_cls = getattr(importlib.import_module(module_name), attr)
return adapter_cls(backend=backend)
def _git_sha() -> str | None:
@@ -64,6 +79,7 @@ async def run_tasks(
artifact_dir: str | Path,
*,
timeout: float = 30.0,
matrix: str = DEFAULT_MATRIX,
) -> dict:
destination = Path(artifact_dir).resolve()
destination.mkdir(parents=True, exist_ok=True)
@@ -76,6 +92,12 @@ async def run_tasks(
result = await asyncio.wait_for(adapter.run_task(task), timeout=timeout)
except TimeoutError:
result = TaskResult(task.task_id, "timeout", 0.0, f"Exceeded {timeout}s")
except UnsupportedCapabilityError as exc:
# "Your server got this wrong" and "your engine cannot reach
# this" are different findings about a competitor. The enum has
# carried `unsupported` since v2 for exactly this, but a
# refusal is still an exception and used to land as `fail`.
result = TaskResult(task.task_id, "unsupported", 0.0, f"[{exc.capability}] {exc}")
except Exception as exc:
result = TaskResult(task.task_id, "fail", 0.0, str(exc), stderr_summary=str(exc))
result.duration_ms = round((time.perf_counter() - started) * 1000, 2)
@@ -91,12 +113,14 @@ async def run_tasks(
total_weight = sum(weight_by_id.values())
supported_weight = sum(weight_by_id[item.task_id] for item in supported)
weighted_score = sum(
weight_by_id[item.task_id] * item.score
for item in results
if item.status != "unsupported"
weight_by_id[item.task_id] * item.score for item in results if item.status != "unsupported"
)
return {
# The *report* schema is unchanged — same keys, same enum — so the
# chart renderers still read v1.4's published files. `matrix` says
# which task set ran, which is the thing that actually moved.
"schema_version": "2.0",
"matrix": matrix,
"adapter": adapter.name,
"adapter_metadata": adapter.metadata(),
"git_sha": _git_sha(),
@@ -122,33 +146,85 @@ async def run_tasks(
}
def _parser() -> argparse.ArgumentParser:
def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="List the fixed benchmark tasks")
parser.add_argument("--server", default="autocad-mcp-pro", choices=["autocad-mcp-pro"])
parser.add_argument(
"--matrix",
default=DEFAULT_MATRIX,
choices=sorted(MATRICES),
help="Task set to run. v2 stays addressable so a v1.4 report can be reproduced.",
)
parser.add_argument(
"--server",
default="autocad-mcp-pro",
choices=sorted(registry),
help="Adapter id from benchmarks/competitors.yaml",
)
parser.add_argument("--backend", default="ezdxf", choices=["ezdxf", "com"])
parser.add_argument("--task", action="append", help="Run only this task id (repeatable)")
parser.add_argument("--artifact-dir", default="benchmarks/results/latest")
parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
parser.add_argument(
"--publish",
type=Path,
help=(
"Also write the report here with artifact paths reduced to filenames. "
"How results/published/*.json are produced — it was a manual edit before."
),
)
return parser
def _sanitize_for_publication(report: dict) -> dict:
"""Drop absolute paths from artifacts, keep the hash that identifies them.
A published report should not carry the run machine's directory layout;
the sha256 is what makes the artifact checkable, and that stays.
"""
published = json.loads(json.dumps(report))
for result in published["results"]:
for artifact in result["artifacts"]:
artifact.pop("path", None)
return published
def main() -> None:
args = _parser().parse_args()
registry = _load_registry()
args = _parser(registry).parse_args()
if args.list:
for task in TASKS_V2:
for task in MATRICES[args.matrix]:
print(f"{task.task_id}\t{task.category}\t{task.description}")
return
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2)
adapter = AutoCADMCPProAdapter(backend=args.backend)
entry = registry[args.server]
supported_backends = entry.get("backends", ["ezdxf"])
if args.backend not in supported_backends:
raise SystemExit(
f"{args.server} supports backends {supported_backends}, not {args.backend!r}"
)
tasks = (
[task_by_id(task_id, args.matrix) for task_id in args.task]
if args.task
else list(MATRICES[args.matrix])
)
adapter = _make_adapter(entry, args.backend)
report = asyncio.run(
run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout)
run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout, matrix=args.matrix)
)
rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.publish:
args.publish.parent.mkdir(parents=True, exist_ok=True)
args.publish.write_text(
json.dumps(
_sanitize_for_publication(report), ensure_ascii=False, indent=2, sort_keys=True
)
+ "\n",
encoding="utf-8",
newline="\n",
)
print(f"Published: {args.publish}", file=sys.stderr)
print(rendered)
+99 -11
View File
@@ -1,25 +1,88 @@
{
"schema_version": "1.0",
"reviewed_at": "2026-07-17",
"reviewed_at": "2026-09-24",
"title": "AutoCAD MCP source-reviewed capability benchmark",
"evidence_boundary": "source_review_not_shared_live_run",
"methodology": "Public source, documented behavior, and locally recorded test evidence scored with a fixed 100-point rubric. Repository stars and raw tool counts do not score points.",
"methodology": "Public source, documented behavior, and locally recorded test evidence scored with a fixed 100-point rubric. Repository stars and raw tool counts do not score points. The U-C4N row was re-scored for 1.6.0 on 2026-09-24 with per-category evidence; competitor rows are their July snapshots and were not re-reviewed.",
"rubric": [
{"category": "Functional CAD coverage", "weight": 25},
{"category": "Correctness and delivery", "weight": 20},
{"category": "Backends and platforms", "weight": 15},
{"category": "Engineering production", "weight": 15},
{"category": "Tests and maintenance", "weight": 15},
{"category": "Security and operations", "weight": 10}
{
"category": "Functional CAD coverage",
"weight": 25
},
{
"category": "Correctness and delivery",
"weight": 20
},
{
"category": "Backends and platforms",
"weight": 15
},
{
"category": "Engineering production",
"weight": 15
},
{
"category": "Tests and maintenance",
"weight": 15
},
{
"category": "Security and operations",
"weight": 10
}
],
"projects": [
{
"repository": "U-C4N/Autocad-MCP",
"url": "https://github.com/U-C4N/Autocad-MCP",
"reviewed_version": "1.3.0 working tree",
"reviewed_version": "1.6.0 (release)",
"score": 93,
"evidence_grade": "A",
"note": "Dual COM/ezdxf backends, standards-aware quality gates, deterministic geometry, closed-loop refinement, and auditable delivery."
"note": "Scored per category, each with its evidence (roadmap criterion 8). 1.4.0 received a single overall 95; this review is stricter, and every point withheld is named. New in 1.5-1.6: P&ID, environment, mechanical, sheet, architecture, understanding and takeoffs; A/B 48/48 with 0 regressions; live COM smokes on every track.",
"category_scores": [
{
"category": "Functional CAD coverage",
"weight": 25,
"score": 24,
"evidence": "247 tools: drafting, ISO 129/286/1101 annotation, paper space, styles and page setup, environment, P&ID (ISO 10628-2 / ISA-5.1, read back with confidence), a mechanical part model with standard parts, ISO 5457/7200/7573/6433 sheets, an architectural plan engine, drawing understanding, diff, topology and plant takeoffs. Not covered: electrical schematics, image-to-CAD tracing, Plant 3D, obstacle-avoiding routing, headless REGION/3DSOLID."
},
{
"category": "Correctness and delivery",
"weight": 20,
"score": 19,
"evidence": "A/B gate against v1.5.1: 48/48 checks, 0 regressions (ab-v1.5.1-vs-v1.6.0.json). Matrix v7: 21 tasks whose gates can fail (zero critique issues and a finalize score of at least 90, PDF /MediaBox, rooms within 0.1 %, takeoff lengths exact). drawing_deliver writes a manifest and checks reopen parity. A field test on a real P&ID pair drove nine fixes. Minus: a headless DIMENSION does not re-render, and a takeoff on a real P&ID still needs the engineer's scope decisions."
},
{
"category": "Backends and platforms",
"weight": 15,
"score": 14,
"evidence": "COM and ezdxf behind one typed contract, with capability-map parity enforced by a test. Every 1.6 track's COM path ran live on AutoCAD 2026. CI runs Linux 3.11/3.12 and Windows. Minus: BricsCAD, ZWCAD and GstarCAD are unverified."
},
{
"category": "Engineering production",
"weight": 15,
"score": 14,
"evidence": "Transcribed standards tables (ISO 4014/4017/4032/7089/4762, ISO 15, ISO 286, ISO 5457, ISO 7200, ISO 7573) and one part model and view engine. 18 domain critique focuses (P&ID 6, mechanical 6, architecture 3, topology 3) feed drawing_finalize's 0-100 score. Minus: four DIN/ISO feature tables ship empty until verified rows exist."
},
{
"category": "Tests and maintenance",
"weight": 15,
"score": 14,
"evidence": "About 4,990 tests on a locked dependency graph. The release gate re-runs three OS/Python lanes; a document-number gate and the per-release A/B suite also run. The perf lane is published (perf-v1.4.0-vs-v1.6.0.json). Minus: entity creation is 1.25x (2,000 lines) and 1.38x (10,000-line roundtrip) slower than v1.4.0, so roadmap criterion 7 is not met."
},
{
"category": "Security and operations",
"weight": 10,
"score": 8,
"evidence": "Per-tool path validation, a loopback-only HTTP bind unless a bearer token is set, the audit middleware, and a field-test script that refuses to write inside the repository. Minus: ALLOWED_PATHS is empty, and so unscoped, by default; command and LISP filtering is a denylist, which is a guardrail rather than a security boundary."
}
],
"history": [
{
"version": "1.4.0",
"score": 95,
"reviewed_at": "2026-07-23"
}
]
},
{
"repository": "varavista/autocad-mcp",
@@ -60,6 +123,31 @@
"score": 68,
"evidence_grade": "B",
"note": ".NET main-thread integration with a broad live AutoCAD operation surface."
},
{
"repository": "LokmenoWer/best-cad-mcp",
"url": "https://github.com/LokmenoWer/best-cad-mcp",
"reviewed_version": "v1.5.0 (2026-07-23 snapshot)",
"score": 74,
"evidence_grade": "B",
"note": "Very broad live-AutoCAD operation surface with CAD-IR understanding, visual grounding, and guarded CADPlan dry-run/rollback; COM-only (no headless lane), no standards-based generators or toleranced annotation."
},
{
"repository": "daobataotie/CAD-MCP",
"url": "https://github.com/daobataotie/CAD-MCP",
"reviewed_version": "2026-07-23 snapshot",
"score": 35,
"evidence_grade": "B",
"note": "Multi-CAD reach (AutoCAD/GstarCAD/ZWCAD) over Windows COM with a small basic drawing surface; no verification, transactions, tests, or delivery evidence documented."
},
{
"repository": "zh19980811/Easy-MCP-AutoCad",
"url": "https://github.com/zh19980811/Easy-MCP-AutoCad",
"reviewed_version": "2026-07-23 snapshot",
"score": 30,
"evidence_grade": "C",
"note": "Basic COM drawing tool set with SQLite persistence; reviewed from the documented tool surface only, execution path not exercised locally."
}
]
],
"competitors_reviewed_at": "2026-07-23"
}
+84
View File
@@ -0,0 +1,84 @@
"""The v3 task matrix: the v2 ten, plus five the v2 ten could not see.
v2 scored this server 10/10, and that number was structurally uninformative:
every task in it exercised something the server was already built around. A
matrix that cannot fail is a matrix that measures nothing, so the five added
here were picked to cover capability the v2 set never touches — discovery,
idle token cost, hatch islands, selection semantics, and measuring an entity
that is already in the drawing.
Two of the five are renamed from the release plan, and the renames are the
point rather than bookkeeping:
``tool_discovery_bilingual`` -> ``tool_discovery``
The plan assumed a Turkish query normalizer. One was written and then
removed: this repository is English throughout, and the Turkish was an
inference from the author's chat language, not a product requirement. A
task named "bilingual" would advertise a capability that does not exist,
which is the class of claim this release spent four milestones deleting.
``measurement_massprops`` -> ``measure_from_handle``
No mass properties ship. The plan's eight-tool measurement family (F2) was
cut to one after measurement showed the repo already carried eight
``analysis_*`` tools; centroids and moments of inertia were not among the
survivors. What did ship is measuring a *handle* instead of a caller's
remembered coordinates, so that is what the task is called.
The v2 ids and weights are untouched. The published competitor reports were
run against the v2 matrix at v1.4 and are not re-scored here — those servers
have never been asked these five questions, and inventing a result for them
would be worse than the gap. The task-matrix chart renders them ``not_run``.
"""
from __future__ import annotations
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec
#: Added in v1.5.0. Every one of these can fail on this server.
NEW_TASKS_V3: tuple[TaskSpec, ...] = (
TaskSpec(
"tool_discovery",
"discovery",
"Resolve AutoCAD command names to tools through the search layer",
1.0,
),
TaskSpec(
"token_budget",
"efficiency",
"Keep the idle catalog under a fixed token ceiling",
1.0,
),
TaskSpec(
"hatch_islands",
"hatch",
"Hatch an area with an island and measure what it fills",
1.0,
),
TaskSpec(
"selection_filter",
"selection",
"Separate window from crossing, and a polygon from its bounding box",
1.0,
),
TaskSpec(
"measure_from_handle",
"measurement",
"Measure a curved boundary from its handle, bulges included",
1.0,
),
)
TASKS_V3: tuple[TaskSpec, ...] = TASKS_V2 + NEW_TASKS_V3
#: Runner-selectable task sets. v2 stays addressable so a v2 report can be
#: reproduced exactly rather than only described.
MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3}
DEFAULT_MATRIX = "v3"
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
for task in MATRICES[matrix]:
if task.task_id == task_id:
return task
raise KeyError(f"Unknown benchmark task: {task_id}")
+52
View File
@@ -0,0 +1,52 @@
"""The v4 task matrix: v3 plus what 1.6 added.
``pid_roundtrip`` (track A) draws a five-item P&ID through the same code the
tools call and reads it back: the edge count, the absence of dangling ends
and the instrument index are each derived independently of the drawing code.
``page_setup_truth`` (track E) applies an ISO A3 landscape page setup to a
layout, plots it through ``batch_plot`` and reads the sheet size back from
the PDF's own ``/MediaBox`` — the setup is verified by the file, not by the
setter's return value. A fresh Layout1 is already A3 landscape, so the task
moves the sheet to ANSI B (432 × 279) and reads that PDF first; a setter that
writes nothing fails there, not at the A3 read-back.
The published competitor reports were never asked either question; the chart
shows ``not_run`` for them, not zero.
"""
from __future__ import annotations
from benchmarks.tasks_v2 import TASKS_V2, TaskSpec
from benchmarks.tasks_v3 import TASKS_V3
#: Added in v1.6.0. Each can fail on its own.
NEW_TASKS_V4: tuple[TaskSpec, ...] = (
TaskSpec(
"pid_roundtrip",
"pid",
"Draw a P&ID from a spec and read back a matching graph, index and line list",
1.0,
),
TaskSpec(
"page_setup_truth",
"pagesetup",
"Apply an ISO A3 page setup, plot it, and read the sheet size back from the PDF",
1.0,
),
)
TASKS_V4: tuple[TaskSpec, ...] = TASKS_V3 + NEW_TASKS_V4
#: Runner-selectable task sets. v2 and v3 stay addressable so an earlier
#: report can be reproduced exactly rather than only described.
MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3, "v4": TASKS_V4}
DEFAULT_MATRIX = "v4"
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
for task in MATRICES[matrix]:
if task.task_id == task_id:
return task
raise KeyError(f"{task_id} is not in matrix {matrix}")
+50
View File
@@ -0,0 +1,50 @@
"""The v5 task matrix: v4 plus what tracks B and G added.
``mech_assembly`` (tracks B + G) is roadmap criterion 3: a flange-coupling
sheet built from the tools — two parts with a section, a bolt circle of ISO
4014 bolts and ISO 4032 nuts, ISO 129 dimensions, an ISO 5457 A3 frame, an
ISO 7200 title block and an ISO 7573 parts list with balloons. The gate is two
numbers, not an impression: ``drawing_critique(focus=None)`` returns zero
issues and the finalize score is at least 90. Either one slipping fails the
task outright.
The published competitor reports were never asked this question; the chart
shows ``not_run`` for them, not zero.
"""
from __future__ import annotations
from benchmarks.tasks_v2 import TASKS_V2
from benchmarks.tasks_v3 import TASKS_V3
from benchmarks.tasks_v4 import TASKS_V4, TaskSpec
#: Added in v1.6.0 by tracks B + G.
NEW_TASKS_V5: tuple[TaskSpec, ...] = (
TaskSpec(
"mech_assembly",
"mech",
"Draw a flange-coupling sheet from the part model and the sheet standard, "
"with a clean critique and a finalize score of at least 90",
1.0,
),
)
TASKS_V5: tuple[TaskSpec, ...] = TASKS_V4 + NEW_TASKS_V5
#: Runner-selectable task sets. v2, v3 and v4 stay addressable so an earlier
#: report can be reproduced exactly rather than only described.
MATRICES: dict[str, tuple[TaskSpec, ...]] = {
"v2": TASKS_V2,
"v3": TASKS_V3,
"v4": TASKS_V4,
"v5": TASKS_V5,
}
DEFAULT_MATRIX = "v5"
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
for task in MATRICES[matrix]:
if task.task_id == task_id:
return task
raise KeyError(f"{task_id} is not in matrix {matrix}")
+54
View File
@@ -0,0 +1,54 @@
"""The v6 task matrix: v5 plus what track F added.
``arch_roundtrip`` (track F) is the roadmap evidence for the plan model: a
two-room plan - an entrance door, an interior door, two windows and a stair -
drawn by ``arch_plan_from_spec``, then read back by ``arch_rooms_detect``,
which never sees the spec. Each room's detected area has to agree with the area
its label carries within 0.1 %, and both with the net floor computed by hand;
the door and window schedules have to list the drawn tags; and the sheet is
gated on the same two numbers as ``mech_assembly``: ``drawing_critique
(focus=None)`` returns zero issues and the finalize score is at least 90.
The published competitor reports were never asked this question; the chart
shows ``not_run`` for them, not zero.
"""
from __future__ import annotations
from benchmarks.tasks_v2 import TASKS_V2
from benchmarks.tasks_v3 import TASKS_V3
from benchmarks.tasks_v4 import TASKS_V4
from benchmarks.tasks_v5 import TASKS_V5, TaskSpec
#: Added in v1.6.0 by track F.
NEW_TASKS_V6: tuple[TaskSpec, ...] = (
TaskSpec(
"arch_roundtrip",
"arch",
"Draw a two-room plan from one spec, read its rooms back from the drawing "
"within 0.1 %, schedule its tags, with a clean critique and a finalize score "
"of at least 90",
1.0,
),
)
TASKS_V6: tuple[TaskSpec, ...] = TASKS_V5 + NEW_TASKS_V6
#: Runner-selectable task sets. v2 to v5 stay addressable so an earlier report
#: can be reproduced exactly rather than only described.
MATRICES: dict[str, tuple[TaskSpec, ...]] = {
"v2": TASKS_V2,
"v3": TASKS_V3,
"v4": TASKS_V4,
"v5": TASKS_V5,
"v6": TASKS_V6,
}
DEFAULT_MATRIX = "v6"
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
for task in MATRICES[matrix]:
if task.task_id == task_id:
return task
raise KeyError(f"{task_id} is not in matrix {matrix}")
+71
View File
@@ -0,0 +1,71 @@
"""The v7 task matrix: v6 plus what track H added.
Track H teaches the server to read drawings somebody else made, so its two
tasks are gated on a drawing pair whose answers are known in advance - the
synthetic plant pair of ``tests/fixtures/plant_pair.py``, which reproduces
every field finding of the design spec with invented names:
* ``takeoff_roundtrip`` - the scale check calls the P&ID ``schematic``; every
routable pipe run's layout length equals the rectilinear minimum spanning
tree of its tags exactly; its size, service and status match the generator's
truth; the cable rows equal the Manhattan lengths from each load to its panel
with the allowance rounded up to the metre.
* ``understand_foreign`` - one ``describe`` of the layout flags inches declared
over millimetre geometry, finds the stray entity by handle and separates the
two plan copies; one ``describe`` of the P&ID classifies its English and
Dutch service layers.
The published competitor reports were never asked these questions; the chart
shows ``not_run`` for them, not zero.
"""
from __future__ import annotations
from benchmarks.tasks_v2 import TASKS_V2
from benchmarks.tasks_v3 import TASKS_V3
from benchmarks.tasks_v4 import TASKS_V4
from benchmarks.tasks_v5 import TASKS_V5
from benchmarks.tasks_v6 import TASKS_V6, TaskSpec
#: Added in v1.6.0 by track H.
NEW_TASKS_V7: tuple[TaskSpec, ...] = (
TaskSpec(
"takeoff_roundtrip",
"plant",
"Take pipe and cable quantities off a schematic P&ID and its layout: the P&ID "
"called schematic, every run's layout length equal to the rectilinear MST of "
"its tags, sizes and services as drawn, cable metres rounded up after the "
"allowance",
1.0,
),
TaskSpec(
"understand_foreign",
"understand",
"Read a foreign layout in one call: inches declared over millimetres flagged, "
"the stray entity found, both plan copies separated, the service layers of its "
"P&ID classified",
1.0,
),
)
TASKS_V7: tuple[TaskSpec, ...] = TASKS_V6 + NEW_TASKS_V7
#: Runner-selectable task sets. v2 to v6 stay addressable so an earlier report
#: can be reproduced exactly rather than only described.
MATRICES: dict[str, tuple[TaskSpec, ...]] = {
"v2": TASKS_V2,
"v3": TASKS_V3,
"v4": TASKS_V4,
"v5": TASKS_V5,
"v6": TASKS_V6,
"v7": TASKS_V7,
}
DEFAULT_MATRIX = "v7"
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
for task in MATRICES[matrix]:
if task.task_id == task_id:
return task
raise KeyError(f"{task_id} is not in matrix {matrix}")
File diff suppressed because it is too large Load Diff
+89 -4
View File
@@ -5,12 +5,51 @@ from __future__ import annotations
import os
from pathlib import Path
#: ProgID the live backend attaches to when ``CAD_PROGID`` is unset. The only
#: CAD this project is developed and tested against.
DEFAULT_CAD_PROGID = "AutoCAD.Application"
#: Default ``MAX_DXF_BYTES``: 512 MB.
DEFAULT_MAX_DXF_BYTES = 512 * 1024 * 1024
class Settings:
"""Server configuration loaded from environment variables."""
@property
def backend(self) -> str:
"""Which engine to run: ``ezdxf``, ``com`` or ``auto``.
Read live rather than captured in ``__init__``, and deliberately
read-only. ``_make_backend`` used to read ``os.environ`` itself while
this attribute had no production reader at all, so
``monkeypatch.setattr(settings, "backend", "ezdxf")`` looked like a
guard and was a no-op — on a machine with AutoCAD open that meant a test
attaching to the operator's live drawing. Late binding is what keeps
``monkeypatch.setenv`` working for the call sites that pin the headless
backend correctly; the missing setter is what makes the broken spelling
fail loudly instead of silently.
"""
return os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
@property
def cad_progid(self) -> str:
"""COM ProgID the live backend attaches to.
Read live and deliberately read-only for the same reasons as
``backend``: late binding keeps ``monkeypatch.setenv`` working, and the
missing setter makes a wrong spelling fail loudly rather than look like
a guard that does nothing.
Stripped but **not** lowercased — the value is echoed back in error
messages and ``system_status``, where ``gstarcad.application`` reads
like a typo we introduced. Never validated against what is actually
registered on the machine: an unrecognised ProgID is not "invalid", it
is unverifiable by us, and only AutoCAD is tested.
"""
return os.environ.get("CAD_PROGID", "").strip() or DEFAULT_CAD_PROGID
def __init__(self):
self.backend: str = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip()
self.log_level: str = os.environ.get("LOG_LEVEL", "INFO").upper().strip()
raw_paths = os.environ.get("ALLOWED_PATHS", "").strip()
@@ -26,9 +65,12 @@ class Settings:
os.environ.get("DANGEROUS_COMMANDS_ENABLED", "false").lower().strip() == "true"
)
# Reject opening DXF files larger than this many bytes (default 50 MB).
# Set to 0 to disable the check.
self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(50 * 1024 * 1024)))
# Reject opening DXF files larger than this many bytes (default 512 MB).
# Set to 0 to disable the check. It was 50 MB until v1.6's track H: a
# real plant layout read on 2026-09-24 was a 188 MB DXF, and the
# understanding tools exist for exactly such drawings. The refusal
# names the file's size and this variable.
self.max_dxf_bytes: int = int(os.environ.get("MAX_DXF_BYTES", str(DEFAULT_MAX_DXF_BYTES)))
# Hard cap on list/select limit params to keep tool responses bounded.
self.max_list_limit: int = int(os.environ.get("MAX_LIST_LIMIT", "5000"))
@@ -37,11 +79,54 @@ class Settings:
# otherwise block the single-thread STA executor forever.
self.com_call_timeout: float = float(os.environ.get("COM_CALL_TIMEOUT", "60"))
# Per-call timeout for the headless ezdxf backend. Every ezdxf method
# runs under one document lock, so a single hung call (pathological DXF,
# a render that never returns) would otherwise hold that lock forever
# and deadlock every later tool call. Higher than com_call_timeout
# because matplotlib PDF/PNG rendering of a large drawing is legitimately
# slow. Set to 0 to disable the check.
self.ezdxf_call_timeout: float = float(os.environ.get("EZDXF_CALL_TIMEOUT", "120"))
# How many undo steps the headless backend keeps. 0 = off, which is the
# default because it is not free: ezdxf has no journal, so a step is a
# full DXF snapshot. Measured — 10 entities 3.1 ms / 19 KB, 1000
# entities 28 ms / 146 KB, 5000 entities 130 ms / 658 KB — and the cost
# is paid on every mutating call, so switching it on for a large drawing
# is a real trade the user should make deliberately rather than inherit.
self.ezdxf_undo_depth: int = max(0, int(os.environ.get("EZDXF_UNDO_DEPTH", "0")))
# HTTP transport: refuse non-loopback bind unless explicitly opted in.
self.allow_remote_http: bool = (
os.environ.get("ALLOW_REMOTE_HTTP", "false").lower().strip() == "true"
)
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
# Exposed tool profile: "full" (default, everything) or "lean" (a
# curated ~47-tool drafting/inspection core for clients with tight tool
# caps). Invalid values fall back to "full" with a warning at startup;
# so does the "core" profile removed in v1.5.0 (see
# server.REMOVED_TOOL_PROFILES).
self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip()
# Tool packs: "all" (default) advertises every vertical pack; a comma
# list ("core,pid,settings") advertises only those. core cannot be disabled.
self.tool_packs: str = os.environ.get("TOOL_PACKS", "all").lower().strip()
# Tool discovery: "off" (default) advertises the full catalog; "search"
# replaces it with a search tool plus a call_tool proxy (see
# discovery/transform.py and server._apply_discovery_mode).
#
# Default "off" deliberately. "search" is the intended v1.x default,
# but flipping it changes what every connected client sees on
# list_tools — a breaking wire-level change that needs its own
# migration note and README pass, neither of which ships in v1.5.0.
# Invalid values fall back to "off" with a warning at startup.
self.discovery_mode: str = os.environ.get("DISCOVERY_MODE", "off").lower().strip()
# Opt-in 3D solids (COM backend only). Disabled by default because the
# headless backend cannot produce ACIS solids and live 3D modifies real
# documents; enable deliberately.
self.enable_3d: bool = os.environ.get("ENABLE_3D", "false").lower().strip() == "true"
settings = Settings()
+7
View File
@@ -0,0 +1,7 @@
"""Tool-discovery layer: searchable tool metadata and the search transform.
Kept deliberately free of ``fastmcp`` and ``ezdxf`` imports at module scope so
the discovery data stays cheap to import and testable in isolation.
"""
__all__: list[str] = []
+2741
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
"""Compact one-line cards for tool-search results.
Passed to a search transform as ``search_result_serializer``. FastMCP's default
(``serialize_tools_for_output_json``) re-emits each hit's full ``list_tools``
entry, JSON Schema and all — for this server that is roughly 1.5 kB per hit,
and a five-hit answer costs more than the crowded catalog the search was meant
to replace.
One line per hit::
entity_fillet (write) | Round a corner between two entities. | req: handle_a, handle_b, radius | acad: FILLET, F
``req:`` lists *required* parameters only. Optional parameters and types are
deliberately absent — the honest cost of this format is that a caller must
either already know the optional arguments or read the error from a failed
call. That trade is worth it at five hits per search; it would not be at one.
Scope note: the compact payload is a real saving but it is **not** what makes
this discovery layer better than stock. Stock BM25 with this same serializer
gets the same size win. The differentiators are the alias corpus (which lets a
query find anything at all) and the risk filter — not bytes.
"""
from __future__ import annotations
from collections.abc import Sequence
from fastmcp.tools.base import Tool
from discovery.aliases import aliases_for
__all__ = ["MAX_SUMMARY_CHARS", "serialize_search_results"]
# Long enough for every first docstring line in the live catalog (measured max
# 96 characters), short enough that a rambling one cannot dominate the payload.
MAX_SUMMARY_CHARS = 110
# Beyond three, AutoCAD command names stop disambiguating and start padding.
MAX_ACAD_COMMANDS = 3
EMPTY_RESULT = "No tools matched the query."
def _risk(tool: Tool) -> str:
"""Same derivation as ``discovery.transform.tool_risk``, inlined.
Imported the other way round would make ``discovery.serialize`` depend on
the transform, and the transform already depends on this module for its
default serializer.
"""
annotations = tool.annotations
if annotations is not None:
if annotations.destructiveHint:
return "destructive"
if annotations.readOnlyHint:
return "read"
return "write"
def _truncate(text: str) -> str:
if len(text) <= MAX_SUMMARY_CHARS:
return text
clipped = text[:MAX_SUMMARY_CHARS].rsplit(" ", 1)[0].rstrip(" ,;:.")
return f"{clipped}..."
def _summary(tool: Tool) -> str:
"""One line describing the tool.
Prefers the hand-written ``@cad_tool`` summary in the MCP ``meta`` channel;
falls back to the first line of the docstring, which for this server is
already written as a standalone sentence.
"""
cad = (tool.meta or {}).get("cad")
if isinstance(cad, dict) and cad.get("summary"):
return _truncate(str(cad["summary"]).strip())
for line in (tool.description or "").strip().splitlines():
if line.strip():
return _truncate(line.strip())
return "(no description)"
def _required(tool: Tool) -> str:
schema = tool.parameters or {}
required = schema.get("required")
if not isinstance(required, list) or not required:
return "(none)"
return ", ".join(str(name) for name in required)
def _acad(tool: Tool) -> str:
record = aliases_for(tool.name)
if record is None or not record.acad:
return ""
return ", ".join(record.acad[:MAX_ACAD_COMMANDS])
def _card(tool: Tool) -> str:
parts = [
f"{tool.name} ({_risk(tool)})",
_summary(tool),
f"req: {_required(tool)}",
]
acad = _acad(tool)
if acad:
parts.append(f"acad: {acad}")
return " | ".join(parts)
def serialize_search_results(tools: Sequence[Tool]) -> str:
"""Render search hits as one compact card per line."""
if not tools:
return EMPTY_RESULT
return "\n".join(_card(tool) for tool in tools)
+402
View File
@@ -0,0 +1,402 @@
"""Tool-search transform that speaks AutoCAD.
Subclasses FastMCP's :class:`BM25SearchTransform` rather than replacing it, so
the ``call_tool`` proxy, the auth-filtered catalog accessor
(``_get_visible_tools``) and the re-entrancy bypass all come for free. Two
things are added on top, and only two:
**1. Vocabulary.** Stock BM25 indexes tool name + description + parameter names
+ parameter descriptions. Measured against this server's live catalog, that
leaves ``BPOLY``, ``PEDIT``, ``WBLOCK``, ``OVERKILL``, ``LAYTRANS``,
``MATCHPROP`` and ``QSELECT`` at df=0 — a search for any of them returns
nothing at all. :func:`discovery.aliases.alias_text` supplies the missing
AutoCAD command names and shop-floor synonyms; this module folds them into the
indexed document. (``FILLET`` is *not* in that list: it is already indexed,
because the tool is literally named ``entity_fillet``.)
**2. Two additive boosts**, so an exact alias match outranks an incidental
description word. See :data:`ACAD_MATCH_BOOST` / :data:`SYNONYM_MATCH_BOOST`.
BM25 itself is not reimplemented: the index object the base class already
built is reused, only with different documents.
"""
from __future__ import annotations
import hashlib
import re
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Annotated, Any, Literal
from fastmcp.server.context import Context
from fastmcp.server.transforms.search import BM25SearchTransform
from fastmcp.server.transforms.search.base import SearchResultSerializer
from fastmcp.server.transforms.search.base import (
_extract_searchable_text as _stock_searchable_text,
)
from fastmcp.tools.base import Tool
from pydantic import Field
from discovery.aliases import alias_text, aliases_for
from discovery.serialize import serialize_search_results
__all__ = [
"ACAD_MATCH_BOOST",
"MAX_SEARCH_LIMIT",
"RISK_CEILINGS",
"SYNONYM_MATCH_BOOST",
"CadSearchTransform",
"SearchOutcome",
"tool_risk",
]
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
#
# ``_BM25Index.query()`` hands back a *ranking*, not scores, so the two signals
# are fused on a rank scale instead of a raw-score scale: BM25's nth hit is
# worth ``1 / (1 + n)``. That makes the boosts below trivially explainable in
# the only units that matter here — positions.
#
# ACAD_MATCH_BOOST the query names an AutoCAD command this tool owns,
# *written as a command* — see _names_a_command below.
# Worth exactly as much as being BM25's top hit, so a
# command match pulls a tool past any number of tools
# that merely share a description word.
# SYNONYM_MATCH_BOOST the query contains one of the tool's synonym phrases
# verbatim ("bill of materials", "round the corner").
# Worth BM25's second position: strong, but it loses to
# an outright command match.
#
# That is the whole scorer. Both boosts are flat and apply at most once, so a
# tool with a long synonym list cannot out-shout a tool with a short one.
ACAD_MATCH_BOOST = 1.0
SYNONYM_MATCH_BOOST = 0.5
_WORD_RE = re.compile(r"[a-z0-9]+")
_RAW_WORD_RE = re.compile(r"[A-Za-z0-9]+")
# Dropped from both sides before phrase matching, so the synonym "delete a
# layer" still matches "delete the layer called GEOMETRY". Articles only —
# a general stop-word list would start matching phrases that mean other things.
_ARTICLES = frozenset({"a", "an", "the"})
def _words(text: str) -> list[str]:
"""Lowercase word list. Unlike BM25's tokenizer, single characters survive.
``_tokenize`` drops ``len <= 1`` tokens, which is right for an inverted
index and wrong for phrase matching: dropping the "a" out of "round the
corner" would silently change which phrases can match.
"""
return _WORD_RE.findall(text.lower())
def _phrase_words(text: str) -> list[str]:
return [word for word in _words(text) if word not in _ARTICLES]
def _names_a_command(query: str, command: str) -> bool:
"""True when ``query`` uses ``command`` as a command, not as English.
Several AutoCAD command names are also ordinary nouns — LAYER, TABLE, LINE,
POINT, TEXT, BOX, ARRAY. Boosting on a bare lowercase "layer" put
``layer_create`` / ``layer_modify`` above ``layer_delete`` for "delete the
layer called GEOMETRY", which is exactly backwards. A drafter reaching for
the command types it in capitals, or types nothing else:
"BPOLY", "trace it with BPOLY" -> command
"bpoly" -> command (nothing else in the query)
"delete the layer ..." -> English; BM25 handles it
"""
words = _words(query)
if len(words) == 1 and words[0] == command.lower():
return True
return command in _RAW_WORD_RE.findall(query)
def _contains_phrase(haystack: Sequence[str], needle: Sequence[str]) -> bool:
"""True when ``needle`` appears as a contiguous run of words in ``haystack``.
Word-level rather than substring, so the synonym "line" does not match
"polyline" or "linear".
"""
n = len(needle)
if not n or n > len(haystack):
return False
first = needle[0]
for i, word in enumerate(haystack):
if word == first and list(haystack[i : i + n]) == list(needle):
return True
return False
# ---------------------------------------------------------------------------
# Risk
# ---------------------------------------------------------------------------
#
# Derived from the MCP annotations each tool already carries, so there is no
# second risk table to drift out of step with the registrations. The ceiling a
# caller may ask for, most permissive first:
#
# any everything (default — a search must not quietly narrow itself)
# write hide tools that delete or overwrite (entity_delete_many, layer_delete,
# transaction_rollback, drawing_finalize, ...)
# read only read-only query tools — the right ceiling for "how many ...?"
RISK_CEILINGS = ("any", "write", "read")
MAX_SEARCH_LIMIT = 25
def tool_risk(tool: Tool) -> str:
"""``"read"`` | ``"write"`` | ``"destructive"`` for one tool.
``destructiveHint`` wins over ``readOnlyHint``: no tool in this catalog
sets both, and if one ever did, the safe reading is the destructive one.
Tools that annotate neither are treated as ``"write"`` — the honest default
for an unannotated tool on a server whose job is mutating drawings.
"""
annotations = tool.annotations
if annotations is not None:
if annotations.destructiveHint:
return "destructive"
if annotations.readOnlyHint:
return "read"
return "write"
def _risk_allowed(ceiling: str, level: str) -> bool:
if ceiling == "any":
return True
if ceiling == "write":
return level != "destructive"
return level == "read"
@dataclass(frozen=True, slots=True)
class SearchOutcome:
"""One search: the tools shown, plus what the risk ceiling took away.
``hidden`` holds only the tools the caller would otherwise have *seen* —
blocked hits inside the unfiltered top-``limit`` — not every blocked tool
in the catalog. That keeps the notice a fact about this answer.
"""
hits: tuple[Tool, ...] = ()
hidden: tuple[Tool, ...] = ()
risk: str = "any"
def notice(self) -> str:
"""One line naming what the risk ceiling removed, or ``""``.
A filter that silently halves a mixed intent ("list the layers then
delete GEOMETRY") is worse than no filter: the caller cannot tell the
difference between "no such tool" and "not shown to you".
"""
if not self.hidden:
return ""
names = ", ".join(tool.name for tool in self.hidden)
return (
f"Risk filter: {len(self.hidden)} higher-risk match(es) hidden by "
f'risk="{self.risk}" ({names}). Re-run with risk="any" to include them.'
)
class CadSearchTransform(BM25SearchTransform):
"""BM25 tool search with the AutoCAD alias corpus folded into the index.
Args:
max_results: Default number of tools returned per search.
bm25_b: BM25 length-normalisation, left at the 0.75 textbook default —
the value every measured number in this module was obtained with.
Lowering it is defensible in principle (these "documents" are
metadata records of deliberately unequal verbosity, and a tool with
six synonyms is not a broader tool than one with two), but it was
not what shipped, so re-measure the golden set before changing it.
"""
def __init__(
self,
*,
max_results: int = 5,
bm25_b: float = 0.75,
always_visible: list[str] | None = None,
search_tool_name: str = "search_tools",
call_tool_name: str = "call_tool",
search_result_serializer: SearchResultSerializer | None = None,
) -> None:
super().__init__(
max_results=max_results,
always_visible=always_visible,
search_tool_name=search_tool_name,
call_tool_name=call_tool_name,
search_result_serializer=search_result_serializer or serialize_search_results,
)
self._bm25_k1 = self._index.k1
self._bm25_b = bm25_b
# ------------------------------------------------------------------
# Indexed document
# ------------------------------------------------------------------
def _extract_searchable_text(self, tool: Tool) -> str:
"""Stock searchable text plus this tool's alias vocabulary.
The narrowest available hook: upstream's ``_extract_searchable_text``
is a module-level function, not a method, so it cannot be overridden in
place — it is called and extended instead. Tools with no alias record
(anything registered outside this server) keep the stock text
unchanged.
"""
extra = alias_text(tool.name)
stock = _stock_searchable_text(tool)
return f"{stock} {extra}" if extra else stock
# ------------------------------------------------------------------
# Index lifecycle
# ------------------------------------------------------------------
def _ensure_index(self, tools: Sequence[Tool]) -> None:
"""Rebuild the index when the catalog's indexed text changes.
The hash is order-sensitive on purpose: results are mapped back to
tools positionally, so a reordered catalog with identical text is still
a different index.
No lock: this and its only caller (:meth:`rank`) are synchronous and
await nothing, so two concurrent searches on one event loop cannot
interleave a rebuild with a query.
"""
documents = [self._extract_searchable_text(t) for t in tools]
digest = hashlib.sha256("\x00".join(documents).encode()).hexdigest()
if digest == self._last_hash:
return
# Built via type() rather than importing the private _BM25Index name:
# the base class already owns an instance, and reusing its class keeps
# this from depending on a private module path.
index = type(self._index)(self._bm25_k1, self._bm25_b)
index.build(documents)
self._index = index
self._indexed_tools = tuple(tools)
self._last_hash = digest
# ------------------------------------------------------------------
# Ranking
# ------------------------------------------------------------------
def _alias_boost(self, tool: Tool, query: str, phrase_words: Sequence[str]) -> float:
record = aliases_for(tool.name)
if record is None:
return 0.0
boost = 0.0
if any(_names_a_command(query, command) for command in record.acad):
boost += ACAD_MATCH_BOOST
if any(_contains_phrase(phrase_words, _phrase_words(phrase)) for phrase in record.synonyms):
boost += SYNONYM_MATCH_BOOST
return boost
def rank(
self,
tools: Sequence[Tool],
query: str,
*,
limit: int | None = None,
risk: str = "any",
) -> SearchOutcome:
"""Rank ``tools`` against ``query``. Pure — no Context, no I/O."""
if risk not in RISK_CEILINGS:
raise ValueError(f"risk must be one of {RISK_CEILINGS}, got {risk!r}")
cutoff = self._max_results if limit is None else max(1, min(limit, MAX_SEARCH_LIMIT))
tools = list(tools)
if not tools:
return SearchOutcome(risk=risk)
self._ensure_index(tools)
ranked = self._index.query(query, len(tools))
bm25_points = {position: 1.0 / (1.0 + rank) for rank, position in enumerate(ranked)}
phrase_words = _phrase_words(query)
scored: list[tuple[float, str, Tool]] = []
for position, tool in enumerate(tools):
score = bm25_points.get(position, 0.0) + self._alias_boost(tool, query, phrase_words)
if score > 0.0:
scored.append((score, tool.name, tool))
scored.sort(key=lambda row: (-row[0], row[1]))
matches = [tool for _, _, tool in scored]
# Filter before truncating, so a blocked hit frees its slot for the
# next safe tool instead of just shortening the answer.
allowed = [tool for tool in matches if _risk_allowed(risk, tool_risk(tool))]
hidden = tuple(
tool for tool in matches[:cutoff] if not _risk_allowed(risk, tool_risk(tool))
)
return SearchOutcome(hits=tuple(allowed[:cutoff]), hidden=hidden, risk=risk)
async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]:
return list(self.rank(tools, query).hits)
# ------------------------------------------------------------------
# The synthetic search tool
# ------------------------------------------------------------------
async def render(self, outcome: SearchOutcome) -> Any:
"""Serialize an outcome, keeping the risk notice on its own line.
With the default (string) serializer the notice is appended as a
trailing line. A serializer that returns structured data instead gets
the notice as a sibling key, because there is no line to append to.
"""
rendered = await self._render_results(outcome.hits)
notice = outcome.notice()
if not notice:
return rendered
if isinstance(rendered, str):
return f"{rendered}\n\n{notice}"
return {"results": rendered, "notice": notice}
def _make_search_tool(self) -> Tool:
transform = self
default_limit = self._max_results
async def search_tools(
query: Annotated[
str,
Field(
description=(
"What you want to do, in plain English or as an AutoCAD "
"command name (FILLET, BPOLY, QSELECT, DLI)."
)
),
],
limit: Annotated[
int,
Field(description=(f"How many tools to return. Clamped to 1..{MAX_SEARCH_LIMIT}.")),
] = default_limit,
risk: Annotated[
Literal["any", "write", "read"],
Field(
description=(
"Highest risk level to return. 'any' (default) is everything; "
"'write' hides tools that delete or overwrite; 'read' returns "
"only read-only query tools — use it for questions like 'how "
"many ...?'. Anything filtered out is reported in the result."
)
),
] = "any",
ctx: Context = None, # type: ignore[assignment]
) -> Any:
"""Find the AutoCAD tools that do a job.
Accepts AutoCAD command names as well as English. Returns one
compact line per tool: name, risk level, what it does, its required
arguments and the AutoCAD command it corresponds to. Call a result
with call_tool.
"""
visible = await transform._get_visible_tools(ctx)
outcome = transform.rank(visible, query, limit=limit, risk=risk)
return await transform.render(outcome)
return Tool.from_function(fn=search_tools, name=self._search_tool_name)
+51
View File
@@ -0,0 +1,51 @@
# Release & Distribution Runbook (v1.4.0+)
The code side of a release is fully automated; three account-bound steps need
a human once, plus the directory submissions. Track them here.
## One-time setup (owner action)
- [ ] **PyPI trusted publisher** — on <https://pypi.org/manage/account/publishing/>
add a *pending publisher* for project `autocad-mcp-pro`:
repository `U-C4N/Autocad-MCP`, workflow `release.yml`, environment `pypi`.
(No API token is stored anywhere; `release.yml` uses OIDC.)
- [ ] **GitHub environment** — create the `pypi` environment in the repo
settings (Settings → Environments) so the publish job can bind to it.
- [ ] **MCP registry login** — install `mcp-publisher`, then
`mcp-publisher login github` (authenticates the `io.github.u-c4n` namespace).
## Every release
1. Bump `pyproject.toml` + `server.json` versions, add the CHANGELOG section,
update the README snapshot line — `tests/test_release_consistency.py`
enforces all of it.
2. Push `main`, wait for CI to go green (lint, linux, windows, package,
docker, registry-validate).
3. Tag and push: `git tag v1.4.0 && git push origin v1.4.0`.
`release.yml` runs the test gate, verifies tag == package version, builds,
publishes to PyPI via trusted publishing and cuts the GitHub Release.
4. Verify: `pip install autocad-mcp-pro==<version>` in a clean venv;
`uvx autocad-mcp-pro --help`.
5. Publish the registry manifest: `mcp-publisher publish` (validates
`server.json`, checks the `mcp-name` marker in the PyPI README).
## Directory listings (submit once, then keep fresh)
- [ ] Glama — <https://glama.ai/mcp/servers> (auto-indexes GitHub; claim the
server page and verify metadata).
- [ ] mcpservers.org — submit via their GitHub repo PR flow.
- [ ] awesome-mcp-servers — PR adding the server under CAD/engineering.
- [ ] Smithery — optional; requires a `smithery.yaml` (evaluate demand first).
## Benchmark refresh (per release)
```bash
python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf
python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf
python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf
# sanitize + copy reports into benchmarks/results/published/, then:
python -m benchmarks.render_live_chart
```
Bump the competitor pins in `benchmarks/competitors.yaml` deliberately (new
SHA = re-read their tool contracts before trusting the mappings).
+292
View File
@@ -0,0 +1,292 @@
# Track A (v1.6 P&ID) — review backlog
**Source:** the plan-compliance and adversarial review rounds of Track A (2026-09-15/16),
waves 1–4. Blocking findings were fixed inside Track A (`fix(task-N)` commits) and are
not listed. Every *minor* finding is here, de-duplicated, with one disposition:
- **fixed-in-task-N** — Track E group H (`docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`, Tasks 1–7)
- **fixed-in-track-A** — resolved on main by a later Track A commit (evidence cited)
- **accepted-limitation** — kept as is, with the reason
- **deferred-1.7** — a real item for the 1.7 roadmap
Reading key: `W1-T1-03` = wave 1, Track A Task 1, finding 3. Files are repository-relative.
## Wave 1
#### Track A Task 1 — `block_insert(attributes=…)` (ezdxf)
| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T1-01 | backends/ezdxf_backend.py | The undefined-block gate (`6f4bda5`) was added without being listed as a deviation. | accepted-limitation (reporting gap; the gate is kept and shared by Task 1) |
| W1-T1-02 | plan | Branch forked before the docs commits; no code diverged. | accepted-limitation (process; merged clean) |
| W1-T1-03 | backends/ezdxf_backend.py | `attributes` was coerced after `add_blockref`, so a non-mapping failed after the write and skipped `_mark_dirty`. | fixed-in-task-3 |
| W1-T1-04 | backends/ezdxf_backend.py | `str(v)` wrote Python reprs (`None`, `True`, a dict) into ATTRIBs on both engines. | fixed-in-task-3 |
| W1-T1-05 | backends/ezdxf_backend.py | Tag matching is exact-case; `{"tag": …}` silently drops against ATTDEF `TAG`. | deferred-1.7 (normalise keys to upper case; the server's own ATTDEF tags are upper-case by `ATTDEF_TAG_RE`) |
| W1-T1-06 | backends/ezdxf_backend.py | `*Model_Space` / `*Paper_Space` pass the existence check and insert a reference cycle. | fixed-in-task-1 |
| W1-T1-07 | backends/ezdxf_backend.py | `entity_create_block_ref` still accepts an undefined block; `doc.audit()` deletes the INSERT later. | fixed-in-task-1 |
| W1-T1-08 | tests/test_block_insert_attributes.py | `attributes=None` yields no ATTRIBs headlessly while COM's `InsertBlock` instantiates ATTDEF defaults; refusal types diverged (ValueError vs RuntimeError). | refusal type: fixed-in-task-1 (COM raises the same ValueError); default ATTRIBs: deferred-1.7 (engine divergence on ATTDEF defaults) |
| W1-T1-09 | backends/ezdxf_backend.py | `block_explode` drops the ATTRIB text. | fixed-in-task-2 |
#### Track A Task 2 — `backends/block_specs.py`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T2-01 | security.py | `illegal_symbol_name_chars` added outside the task's file list, unreported. | accepted-limitation (reporting gap; harmless, tested) |
| W1-T2-02 | backends/block_specs.py | `fullmatch`, layer-character gate and `_one_line` are stricter than the plan stated. | accepted-limitation (all serve the validate-before-write rule; pinned by tests) |
| W1-T2-03 | plan / spec | Spec §4.7 per-entity `color`/`linetype`/`lineweight` overrides were dropped by the plan; unknown keys are silently ignored. | deferred-1.7 (carry the three keys through or refuse unknown keys by index) |
| W1-T2-04 | backends/block_specs.py | `_scalar` let `10**400` escape as `OverflowError` instead of the named `TypeError`. | fixed-in-task-4 |
| W1-T2-05 | backends/block_specs.py | A non-list `entities` (dict, str, generator) is refused as "empty". | deferred-1.7 |
| W1-T2-06 | backends/block_specs.py | `_layer` gates characters but not the 255-char length rule. | deferred-1.7 |
| W1-T2-07 | backends/block_specs.py | Refusal messages echo the caller's value unbounded (`got {value!r}`; measured 2 MB messages). | deferred-1.7 (truncate with reprlib) |
| W1-T2-08 | backends/block_specs.py | Same as W1-T2-03 (unknown keys such as `rotation` silently dropped). | deferred-1.7 (with W1-T2-03) |
#### Track A Task 6 — ISA-5.1 tag grammar
| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T6-01 | engineering/pid/tags.py | Three grammar changes (X user-defined, no output after V, O/C only after ZS) unreported. | accepted-limitation (the fixture is the contract; documented in `describe_tables()['rules']`) |
| W1-T6-02 | engineering/pid/tags.py | Two of those go past the spec §8 wording. | accepted-limitation (spec text to be reconciled by the 1.7 docs pass) |
| W1-T6-03 | spec | Spec example meanings (`Flow rate`/`Indicate`/`Control`) differ from the tables (`Flow`/`Indicator`/`Controller`). | accepted-limitation (keys match; consumers pin the tables) |
| W1-T6-04 | engineering/pid/tags.py | The "unexpected letter" error names the first trailing letter, not the offender (`LAHX-1` → reports H). | deferred-1.7 |
| W1-T6-05 | engineering/pid/tags.py | `equipment_prefixes={}` falls back to the default map (`or` instead of `is None`); non-string values pass through. | deferred-1.7 (also W4-T19-05) |
| W1-T6-06 | tests/data/isa51_tags.json | No fixture row for K (rate of change / control station), M trailing, O readout, B/N/X user-defined readouts. | deferred-1.7 (≈5 rows) |
| W1-T6-07 | engineering/pid/tags.py | O/C trailing narrowed to Z-first tags beyond the spec text. | accepted-limitation (documented resolution of a spec/fixture conflict) |
#### Track A Task 12 — `engineering/pid/lines.py`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T12-01 | engineering/pid/lines.py | `axis_direction`, the `string.Formatter` parser and finiteness checks were added unreported. | accepted-limitation (reporting gap; pinned by tests) |
| W1-T12-02 | engineering/pid/__init__.py | Created outside the task's file list (branch predated Task 5). | accepted-limitation (byte-identical to Task 5's) |
| W1-T12-03 | engineering/pid/lines.py | `direct` and waypoint modes accept a negative / NaN `stub`. | deferred-1.7 (hoist the stub check above the mode branch) |
| W1-T12-04 | engineering/pid/lines.py | A malformed waypoint (`{"x":…}`, a flat list) escapes as `KeyError` / `TypeError` instead of `ValueError` naming `waypoints[i]`. | deferred-1.7 (also W3-T13-11) |
| W1-T12-05 | engineering/pid/lines.py | `count_crossings` undercounts a crossing that lands exactly on the other polyline's vertex. | deferred-1.7 (reported metric; orthogonal auto routes cannot produce it) |
| W1-T12-06 | engineering/pid/lines.py | A format spec containing `-` (`{seq:-05d}`) is refused with a misleading message. | accepted-limitation (a refusal, not a wrong number; hyphen inside a field spec is unsupported) |
| W1-T12-07 | engineering/pid/lines.py | `label_placement([(0,0)])` raises an unnamed error; a closed-loop waypoint route is accepted; `{{seq}}` is copied through. | accepted-limitation (unreachable through the tools; cosmetic) |
## Wave 2
#### Track A Task 3 — `block_define`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T3-01 | README.md | Snapshot bumped while lines 23/30/106 still said 154. | fixed-in-track-A (Task 21 README rewrite: "166 tools in 20 groups") |
| W2-T3-02 | tests/test_tool_registry.py | Frozen snapshot edited outside the file list. | accepted-limitation (required by the gate; the precedent every later task follows) |
| W2-T3-03 | backends/ezdxf_backend.py | `base_x`/`base_y` only `float()`-cast; NaN written into the BLOCK record. | fixed-in-task-4 |
| W2-T3-04 | backends/ezdxf_backend.py | A primitive layer is written without a layer-table entry; COM would fail mid-loop. | fixed-in-task-4 |
| W2-T3-05 | README.md | Same as W2-T3-01. | fixed-in-track-A |
| W2-T3-06 | backends/ezdxf_backend.py | ATTDEF linetype stays BYLAYER on ezdxf vs ByBlock on COM; neither sets lineweight ByBlock. | deferred-1.7 (cosmetic; layer-0 members resolve to the INSERT's layer) |
| W2-T3-07 | backends/ezdxf_backend.py | `attdefs or []` turns `{}`/`''` into "no ATTDEFs"; a non-str `name` raises AttributeError. | deferred-1.7 |
| W2-T3-08 | backends/ezdxf_backend.py | `block_insert` without `attributes` carries none of the ATTDEF defaults (COM does). | deferred-1.7 (with W1-T1-08) |
#### Track A Task 4 — XDATA
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T4-01 | discovery/aliases.py | `entity_get_xdata` ships `XDLIST` only (XDATA would break the single-route gate). | accepted-limitation (still discoverable; reporting gap) |
| W2-T4-02 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W2-T4-03 | backends/ezdxf_backend.py | APPID names are case-sensitive headlessly; `APP_A` and `app_a` become two groups and double-count the budget. | deferred-1.7 |
| W2-T4-04 | backends/com_backend.py | A no-op removal registers the APPID on COM but not on ezdxf. | deferred-1.7 |
| W2-T4-05 | backends/xdata_specs.py | 1004 binary chunks decode to the repr of a bytes object. | deferred-1.7 (hex string) |
| W2-T4-06 | backends/xdata_specs.py | The reserved-app rule blocks reading `ACAD` by name while the unfiltered read returns it; other AutoCAD-owned apps are writable. | deferred-1.7 |
| W2-T4-07 | README.md | Snapshot paragraph contradicted itself; collected-tests drift. | fixed-in-track-A (Task 21) |
#### Track A Task 5 — `engineering/pid/xdata.py`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T5-01 | engineering/pid/__init__.py | Listed as "create" but already existed. | accepted-limitation (nothing missing) |
| W2-T5-02 | tests/test_pid_xdata.py | Sync tests under a module-level `pytest.mark.asyncio` emit PytestWarnings. | deferred-1.7 (hygiene sweep across the pid test modules) |
| W2-T5-03 | spec | Spec payload (`actuator`, 3-element ports, no `seq`) vs plan (`variant`, 5-element ports, `seq`). | accepted-limitation (plan governs; spec text to reconcile) |
| W2-T5-04 | engineering/pid/xdata.py | `encode_payload` uses `allow_nan=True`; a NaN would be serialised as a non-RFC literal. | deferred-1.7 (`allow_nan=False`; no current caller can deliver one after Task 5) |
| W2-T5-05 | engineering/pid/xdata.py | `symbol_payload(params={})` is replaced by the spec's params (truthiness). | deferred-1.7 |
| W2-T5-06 | engineering/pid/xdata.py | Duplicate port names collapse silently (last wins). | deferred-1.7 (refuse in `make_spec`) |
| W2-T5-07 | engineering/pid/xdata.py | A non-dict payload is written and can never be read back. | deferred-1.7 |
| W2-T5-08 | tests/test_pid_xdata.py | No multi-chunk payload round trip through a backend. | deferred-1.7 (test gap; the path was verified by hand) |
#### Track A Task 7 — symbol framework
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T7-01 | engineering/pid/symbols.py | Per-type outlines (no circle for computer/plc) deviate from the plan snippet, unreported. | accepted-limitation (the spec's ISA table governs; tests added) |
| W2-T7-02 | tests/test_pid_symbols.py | Unused import dropped, `math` added. | accepted-limitation (no functional effect) |
| W2-T7-03 | engineering/pid/symbols.py | Rear-location dashed lines stop up to 1 mm short on the right. | accepted-limitation (cosmetic; no port, bbox or gate depends on it) |
| W2-T7-04 | engineering/pid/symbols.py | A single radial `signal` port cannot describe the hexagon/square outlines (0.67 mm gap on vertical lines into `computer`). | accepted-limitation (spec §4.4 model; a per-axis reach is a 1.7 item) |
| W2-T7-05 | engineering/pid/symbols.py | `all_specs()` swallows builder ValueErrors and nothing pins the catalogue size. | deferred-1.7 (assert `len(all_specs())`) |
| W2-T7-06 | engineering/pid/symbols.py | `register()` overwrites silently; `list_symbols()` returns variants by reference. | deferred-1.7 |
| W2-T7-07 | engineering/pid/geometry.py | `bbox_of` samples arcs at 1° and can under-report by 3.8e-5·r. | accepted-limitation (sub-lineweight) |
#### Track A Task 8 — valves
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T8-01 | plan | The plan file was edited to match the shipped geometry, unreported. | accepted-limitation (the original geometry floated the dome; evidence in the commit) |
| W2-T8-02 | engineering/pid/symbols_valves.py | `check`/`relief` advertise actuator variants they refuse. | deferred-1.7 (variants `["none"]` for those bodies) |
| W2-T8-03 | engineering/pid/symbols_valves.py | The diaphragm *body* dome floats 1 mm above the bow-tie. | deferred-1.7 (cosmetic) |
| W2-T8-04 | engineering/pid/symbols_valves.py | The `signal` port and the TAG/DESC text share x = 0 above the actuator; a signal line crosses the tag. | accepted-limitation (spec §4.7 placement; a tag/line overlap critique is a 1.7 item, with W2-T9-05) |
#### Track A Task 9 — equipment
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T9-01 | engineering/pid/symbols_equipment.py | `pd_pump` ports are `in`/`out`, spec says `suction`/`discharge`. | accepted-limitation (plan governs; spec text to reconcile; `pid_from_spec` names ports explicitly) |
| W2-T9-02 | engineering/pid/symbols_equipment.py | `spectacle_blind` cites ISO 10628-2 instead of PIP PIC001; no spec names its figure/table. | deferred-1.7 (provenance strings) |
| W2-T9-03 | engineering/pid/symbols_equipment.py | Same as W2-T9-01. | accepted-limitation |
| W2-T9-04 | engineering/pid/symbols_equipment.py | `build_equipment` ignores `shape` for non-reducers and its signature is narrower than `**options`. | accepted-limitation (`resolve()` validates options; builders are internal) |
| W2-T9-05 | engineering/pid/symbols_equipment.py | TAG/DESC sit on the outward ray of every +Y port. | accepted-limitation (with W2-T8-04) |
| W2-T9-06 | engineering/pid/symbols_equipment.py | `air_cooler` fan cross pokes 0.046 mm outside its circle. | deferred-1.7 (cosmetic, ±1.75) |
| W2-T9-07 | engineering/pid/symbols_equipment.py | Seven origins are not the bbox centre (spec §4.2). | accepted-limitation (deliberate mounting points; spec text to reconcile) |
#### Track A Task 10 — vessels
| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T10-01 | engineering/pid/geometry.py | `scan_hits` added outside the file list, unreported. | accepted-limitation (additive, tested) |
| W2-T10-02 | engineering/pid/symbols_vessels.py | Stricter size rules and outline-based stubs, unreported. | accepted-limitation (evidence in the commit; plan tests unchanged) |
| W2-T10-03 | spec | `PID_VESSEL_VERTICAL_` vs `PID_VESSEL_VERTICAL_VESSEL_`. | accepted-limitation (plan's general rule governs) |
| W2-T10-04 | engineering/pid/symbols_vessels.py | Non-finite sizes/fractions pass `validate_vessel_params` (NaN spec, `RuntimeError` for inf). | deferred-1.7 (`math.isfinite` next to `> 0`) |
| W2-T10-05 | engineering/pid/symbols_vessels.py | Column tray lines poke through dished heads (up to 1.8 mm). | deferred-1.7 (clip to the outline with `scan_hits`) |
| W2-T10-06 | engineering/pid/symbols_vessels.py | Nozzle fractions near 0/1 on curved/sloped sides give degenerate long stubs from a corner. | deferred-1.7 |
| W2-T10-07 | engineering/pid/symbols_vessels.py | `trays` is unbounded (1 000 000 trays build in 6.3 s / 272 MB). | deferred-1.7 (cap by spacing ≥ 1 mm) |
| W2-T10-08 | engineering/pid/symbols_vessels.py | Two nozzles at the same side+fraction duplicate the stub; `-0.0` hashes to a different block name. | deferred-1.7 |
## Waves 3–4
#### Track A Task 11 — `pid_symbol_list` / `pid_symbol_insert`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T11-01 | engineering/layers.py | `PROCESS-LINE-TEXT` inserted at position 9; spec said "16th entry". | accepted-limitation (count matches; ordinal wording only) |
#### Track A Task 13 — `pid_line_draw`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T13-01 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W3-T13-02 | discovery/aliases.py | `PLINE` added to `SHARED_ACAD_COMMANDS`. | accepted-limitation (required by the single-route gate; documented in the allowlist comment) |
| W3-T13-03 | backends/com_backend.py | `e924b5d` touched three files outside the list, unreported (COM `AcDbPolyline` naming, mirrored ports). | accepted-limitation (both were defects; tests added) |
| W3-T13-04 | server.py | `>=` for `≥` in a docstring. | accepted-limitation (cosmetic) |
| W3-T13-05 | engineering/pid/drawlines.py | Line sequence is per current space, not per document (spec §6.3). | deferred-1.7 (document-wide scan of existing lines) |
| W3-T13-06 | engineering/pid/drawlines.py | Every signal line got a fabricated number and label and consumed the process sequence. | fixed-in-task-6 |
| W3-T13-07 | engineering/pid/drawlines.py | A bubble's exit aimed at the far end even with waypoints, so the first segment cut through the bubble. | fixed-in-task-6 |
| W3-T13-08 | engineering/pid/drawlines.py | A foreign/hand-edited payload with a non-dict `from`/`to` raised `TypeError` out of `draw_line`. | fixed-in-task-7 |
| W3-T13-09 | engineering/pid/drawlines.py | (a) crossings tested against chords, not arcs; (b) COM heavy 2DPOLYLINE `Coordinates` read with stride 2. | (a) fixed-in-task-7; (b) deferred-1.7 (heavy polylines the tool never creates) |
| W3-T13-10 | README.md | Snapshot line edited without the collected-tests figure. | fixed-in-track-A (Task 21) |
| W3-T13-11 | server.py | An unknown endpoint handle and a flat waypoint list surface as "Internal error" rather than a named refusal. | deferred-1.7 (with W1-T12-04) |
#### Track A Task 14 — `pid_graph`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T14-01 | engineering/pid/graph.py | Block-name keywords match whole tokens, not substrings (spec §9.2); `GATEVALVE` no longer classifies. | accepted-limitation (the plan's own test demands it; documented in code) |
| W3-T14-02 | backends/ezdxf_backend.py | A public `bulges` key was added to every LWPOLYLINE `entity_get`, unreported. | accepted-limitation (additive; spec §9.4 "geometry from the drawing"; now consumed by Task 7) |
| W3-T14-03 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W3-T14-04 | engineering/pid/graph.py | Edge dict carries `length_approximate` and (with geometry) `bulges` beyond the spec. | accepted-limitation (additive) |
| W3-T14-05 | tests/test_pid_graph.py | Three sync tests under the asyncio mark warn. | deferred-1.7 (hygiene, with W2-T5-02) |
| W3-T14-06 | engineering/pid/graph.py | `_Edge.segments` omits a closed polyline's closing edge while `length` includes it. | deferred-1.7 |
| W3-T14-07 | engineering/pid/graph.py | The dangling `nearest` hint never names a radial (bubble) port. | deferred-1.7 |
| W3-T14-08 | engineering/pid/graph.py | Text-tag fallback searched a circle, not "above the box". | fixed-in-track-A (`d17a261`, `6ad3734`: tags above the box, `LINE_NUMBER_RE` excluded) |
| W3-T14-09 | engineering/pid/graph.py | `scope="all"` restores `current` but not the paper-space layout binding. | deferred-1.7 |
| W3-T14-10 | engineering/pid/graph.py | A −Z-extruded catalogue INSERT reported mirror-image ports at confidence 1.0. | fixed-in-track-A (`6ad3734`: both engines report `mirrored`; `transform_port(mirrored=)`) |
| W3-T14-11 | engineering/pid/graph.py | A `PID_*` INSERT whose XDATA was stripped falls to the 0.6 heuristic with no ports (spec says catalogue 1.0). | deferred-1.7 |
| W3-T14-12 | README.md | Release-consistency gate red on the collected-tests figure. | fixed-in-track-A (Task 21) |
| W3-T14-13 | engineering/pid/symbols.py | `6ad3734` touched five files outside the list; `transform_port` grew `mirrored`/`strict`. | accepted-limitation (justified; interface kept by keyword defaults) |
| W3-T14-14 | engineering/pid/graph.py | `_text_near` accepts a baseline half a height inside the box and excludes line-number texts — beyond the spec wording. | accepted-limitation (documented, tested) |
| W3-T14-15 | engineering/pid/graph.py | Node dicts gained `y_scale`, `mirrored`, `notes`, `space`, `description`; junctions `space`. | accepted-limitation (additive) |
| W3-T14-16 | docs (plan/spec) | `ruff format --check` was red on the plan and spec Markdown. | fixed-in-track-A (Task 21 formatted them) |
| W3-T14-17 | spec | §9.1 model not extended for the added node/junction keys. | deferred-1.7 (docs) |
| W3-T14-18 | engineering/pid/symbols.py | `strict=False` keeps a diagonal port's local angle under non-uniform scale. | accepted-limitation (unreachable: all 243 catalogue ports are axis-aligned) |
| W3-T14-19 | backends/ezdxf_backend.py | `mirrored` is `True` for a tilted INSERT with a negative-z normal (COM twin too). | deferred-1.7 (`flat and z < 0`) |
#### Track A Task 15 — deliverables
| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T15-01 | tests/test_pid_deliverables.py | Four plan deviations (line-list rows, `_far_ends`, CSV refusal, `deliverable()` ValueError) unreported. | accepted-limitation (each stronger than the plan; the plan's own numbers were wrong) |
| W3-T15-02 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W3-T15-03 | tests/_probe_task15.py | A stray probe file appeared during review. | fixed-in-track-A (not in the tree) |
| W3-T15-04 | engineering/pid/deliverables.py | `natural_key` crashes on `str.isdigit()` runs that are not `\d` (①, ²). | deferred-1.7 (`isdecimal`) |
| W3-T15-05 | engineering/pid/deliverables.py | `_far_ends` resolves junctions at the ends of the instrument's line only, not on it. | deferred-1.7 |
| W3-T15-06 | engineering/pid/deliverables.py | `_tag_of` writes the internal junction id into a tag column; untagged node ends are `None` vs handle in the index. | deferred-1.7 |
| W3-T15-07 | engineering/pid/deliverables.py | `write_csv` edge cases: 0-byte file for zero rows, `csv_path=''` treated as None, directory path escapes as PermissionError, dead `mkdir`, late validation. | deferred-1.7 |
| W3-T15-08 | engineering/pid/deliverables.py | CSV formula injection: cells starting with `=+-@` are written verbatim. | deferred-1.7 (first item of the 1.7 deliverables pass) |
| W3-T15-09 | server.py | The three deliverable tools expose only `tolerance`/`scope`, not `label_search`/`include_foreign`. | deferred-1.7 |
| W3-T15-10 | engineering/pid/deliverables.py | `signal_lines` counts every edge on a bubble regardless of class. | deferred-1.7 |
#### Track A Task 16 — critique focuses
| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T16-01 | tests/test_pid_critique.py | The refine test calls `refine_drawing` (no backend `drawing_refine` exists), unreported. | accepted-limitation (the plan's call could not run; stricter test) |
| W3-T16-02 | engineering/pid/critique.py | `has_pid_content` guard instead of `not graph['nodes']`, unreported. | accepted-limitation (spec intent; pinned by tests) |
| W3-T16-03 | engineering/pid/critique.py | `_incompatible` skips inferred ports; `_dangling` reports unknown_block ends — beyond the plan. | accepted-limitation (spec §9.4 / §11.1; tested) |
| W3-T16-04 | tests/test_pid_critique.py | Two sync tests warn under the asyncio mark. | deferred-1.7 (hygiene) |
| W3-T16-05 | engineering/pid/critique.py | `_untagged` tests the composed tag, not FUNC/LOOP; an empty LOOP files as `pid_illegal_tag`. | deferred-1.7 |
| W3-T16-06 | engineering/pid/critique.py | A mechanical block whose name holds a keyword token (`OIL_FILTER`) counts as P&ID content and can fail finalize. | accepted-limitation (spec §9.2 mandates the heuristic; README "Known limitations" states it) |
| W3-T16-07 | engineering/pid/critique.py | `_duplicate_tags` scores a text-derived (guessed) tag as a gate-failing error. | deferred-1.7 (warning for `tag_source: text`) |
#### Track A Task 17 — `pid_from_spec`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T17-01 | tests/test_pid_from_spec.py | Four reported deviations verified legitimate (save_path, score dict, async test, snapshot). | accepted-limitation |
| W4-T17-02 | engineering/pid/spec.py | The module is a refactor of the plan's listing; interfaces preserved. | accepted-limitation |
| W4-T17-03 | README.md | `pid-showcase.png` rendered but not referenced. | fixed-in-track-A (README line 20 embeds it) |
| W4-T17-04 | tests/test_release_consistency.py | Collected-tests drift. | fixed-in-track-A (Task 21) |
| W4-T17-05 | engineering/pid/spec.py | `_num` accepts NaN/inf; an INSERT lands at `[nan, 0]` and `build_graph` crashes. | fixed-in-task-5 |
| W4-T17-06 | engineering/pid/spec.py | `run_spec` catches `Exception` only; a cancellation leaves an open transaction. | fixed-in-task-5 |
| W4-T17-07 | engineering/pid/spec.py | `dry_run` skips `place_symbol`'s option checks (`scale=-1` approved; wrong item blamed). | deferred-1.7 (`validate_spec` owns scale/fail/actuator rules) |
| W4-T17-08 | engineering/pid/spec.py | `dry_run` crossings count the spec's own lines only, unlabelled. | deferred-1.7 (`crossings_scope` field) |
| W4-T17-09 | engineering/pid/spec.py | Nested transactions: ezdxf nests, COM refuses; `drawing_plan` is set before the refusal. | deferred-1.7 |
| W4-T17-10 | engineering/pid/spec.py | The `sheet` block is not validated (unknown layer set / sheet size / keys accepted). | deferred-1.7 |
| W4-T17-11 | engineering/pid/spec.py | String-typed optional keys unvalidated (`arrow: "no"` truthy; `number: 123` bare TypeError; `id` with a dot). | deferred-1.7 |
| W4-T17-12 | README.md | README showcase half incomplete; gate red. | fixed-in-track-A (Task 21) |
#### Track A Task 19 — tag parse, resources, prompt
| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T19-01 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W4-T19-02 | scripts/render_pid_catalog.py | Renders through its own matplotlib path instead of `view_screenshot`, unreported. | accepted-limitation (byte-reproducible output; interface kept) |
| W4-T19-03 | README.md | Lines 23/106 still said 154 / 6 resources. | fixed-in-track-A (Task 21) |
| W4-T19-04 | server.py | The prompt's OFF-PAGE example emits a tag warning every time it is followed (`TO P&ID-002` parsed as ISA-5.1). | deferred-1.7 (skip `parse_tag` for connectors at insert) |
| W4-T19-05 | engineering/pid/tags.py | `equipment_prefixes={}` discarded; non-string values pass. | deferred-1.7 (with W1-T6-05) |
| W4-T19-06 | README.md | Release gate red on the collected-tests figure. | fixed-in-track-A (Task 21) |
#### Track A Task 18 — `TOOL_PACKS`
| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T18-01 | tests/test_tool_packs.py | A sync test warns under the asyncio mark. | deferred-1.7 (hygiene) |
| W4-T18-02 | README.md / config.py / .env.example | Token table and prose said 47 while lean has 50. | README: fixed-in-track-A (Task 21); `config.py:99` / `.env.example:52` "~47": deferred-1.7 (docs) |
| W4-T18-03 | server.py | `TOOL_PACKS=all,mech3000` drops the unknown entry without a warning or `ignored`. | deferred-1.7 |
| W4-T18-04 | server.py | `_enabled_packs()` runs twice per apply; unknown entries warn twice and duplicate in `ignored`. | deferred-1.7 |
| W4-T18-05 | tests/test_tool_packs.py | The pack test compares against a hand-copied set, never the registry. | deferred-1.7 (assert against `_registered_tools()` tags) |
| W4-T18-06 | server.py | Under `TOOL_PACKS=core` the P&ID prompt and resources are still advertised. | accepted-limitation (spec §12 scopes packs to tools) |
#### Track A Task 20 — benchmark v4
| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T20-01 | scripts/check_doc_numbers.py | The gate short-circuited on the 29-vs-26 A/B drift. | fixed-in-track-A (Task 21 regenerated the A/B report) |
| W4-T20-02 | CLAUDE.md | An extra clause beyond the dictated sentence. | accepted-limitation (accurate) |
| W4-T20-03 | benchmarks/README.md | "16/16 on the v4 matrix" has no published artifact behind it. | deferred-1.7 (publish a v4 reference report at the 1.6 release) |
| W4-T20-04 | tests/test_benchmark_v4.py | No test executes `_task_pid_roundtrip`. | deferred-1.7 (Task 6 of the hardening plan runs it by hand) |
| W4-T20-05 | benchmarks/README.md | A/B rows said 29 checks over a 26-check report. | fixed-in-track-A (Task 21) |
#### Track A Task 21 — docs and smoke
| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T21-01 | scripts/smoke_pid_com.py | Not re-run live during review (no AutoCAD running). | accepted-limitation (the implementer's live run is recorded in the CHANGELOG) |
| W4-T21-02 | README.md | "Known limitations of the P&ID track" was not a plan item. | accepted-limitation (claims verified against the code) |
| W4-T21-03 | docs (plan) | The plan file was ruff-formatted outside the file list. | accepted-limitation (mechanical) |
| W4-T21-04 | benchmarks/README.md | Tables moved for the new gate labels. | accepted-limitation (required by `check_doc_numbers.py`) |
| W4-T21-05 | CHANGELOG.md | "12.5 % short on a semicircular jump" is the v1.5 square figure; the tested geometry is 10.25 %. | deferred to the final docs task of Track E (CHANGELOG `Unreleased` edit) |
| W4-T21-06 | README.md | The snapshot is labelled v1.5 while quoting the 1.6-dev surface. | accepted-limitation (pinned to pyproject's minor until the 1.6 bump) |
| W4-T21-07 | scripts/smoke_pid_com.py | Exit code checks `dangling` only; critique/confidence/xdata are printed, not asserted. | deferred-1.7 |
| W4-T21-08 | CHANGELOG.md | `LAYER_SET_INTENT_MISMATCH` is claimed for `drawing_plan`, which only appends a warning string. | deferred to the final docs task of Track E |
## Totals
165 rows. Fixed in this track (Tasks 1–7): 15 · fixed inside Track A: 17 · accepted with a reason: 62 · deferred (1.7 or the Track E docs task): 71.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 90 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -448,10 +448,7 @@ def render_chart(data: dict[str, Any], output: Path) -> None:
labels = [item["repository"] for item in projects]
scores = [item["score"] for item in projects]
grades = [item["evidence_grade"] for item in projects]
colors = [
"#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781"
for label in labels
]
colors = ["#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781" for label in labels]
fig, ax = plt.subplots(figsize=(12, 6.4), constrained_layout=True)
fig.patch.set_facecolor("#f6f8fa")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More