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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
_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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
`_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
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
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
_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
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
`_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
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
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
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
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
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
`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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
`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
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
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
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
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
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
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
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
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
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
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
`_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
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
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
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
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
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
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
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
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
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
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
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