Перенос нашей дельты (10 изменённых + 2 новых файла) с v1.5.1 на тег v1.6.0
трёхсторонним слиянием с ветвью ours-1.5 (v1.5.1 + /opt/data/autocad-mcp-build/patch).
Файлы 1.6.0 не перезаписывались копированием: 30 конфликтов разрешены по смыслу.
Что перенесено (наше, чего в 1.6.0 не было):
- инструменты: titleblock_apply_gost_form3, file_put, file_get,
drawing_open_base64, drawing_save_base64, entity_create_attdef,
block_get_attdefs, textstyle_ensure, text_apply_style, text_style_report,
dimstyle_ensure, layout_set_paper (+ таблица алиасов и регистрация в server.py);
- параметр `layout` (адресация в пространство листа) — 36 инструментов, включая
геометрию (line/polyline/circle/arc/...), block_insert/block_create_from_entities,
dimension_* и аннотацию; в COM-бэкенде этому служит _activate_layout();
- `inline_font` (ГОСТ-шрифт ISOCPEUR прямо в содержимое MTEXT/таблиц) — 5 инструментов;
- ГОСТ-стиль по умолчанию (_ensure_default_text_style, DEFAULT_TEXT_STYLE=GOST_A)
при drawing_new/drawing_open;
- ATTDEF → ATTRIB: block_insert создаёт именованную вставку (не анонимный *U-блок),
block_set_attributes достраивает отсутствующие ATTRIB из определений блока;
- отчётность block_insert/block_set_attributes: filled / unknown_tags / created_tags
и штамп стиля на ATTRIB (mark_style, attr_style);
- PDF: page_size («layout» — страница равна формату листа 1:1, «extents» — по
содержимому), настоящий текстовый слой (pdf.fonttype=42 + отрисовка текста
поверх геометрии, включая текст через видовые экраны) и предупреждения о
нечитаемо мелких надписях;
- engineering/gost_titleblock.py (основная надпись по ГОСТ Р 21.1101-2013, форма 3)
и _fonts/isocpeur.ttf.
Что взято у 1.6.0 (наши дубли заменены, чтобы не спорить с апстримом):
- drawing_export_pdf: рамка листа считается его _paper_frame() (масштаб, plot area,
поворот, поля) вместо нашей _layout_paper_mm(); наш текстовый слой и page_size
наложены сверху;
- размерные стили: _current_dimstyle_name() вместо нашего _current_dimstyle()
(наш хелпер оставлен — им пользуется отчёт о стилях);
- таблицы (ezdxf и COM): объединённая строка-заголовок, «span», масштабируемый
отступ ячеек и SetCellTextHeight() из 1.6.0 + наш inline_font/style;
- block_insert: структура вставки и обработка многострочных ATTRIB из 1.6.0
+ наша отчётность и штамп стиля;
- экспорт DWG/xref/image_attach/профили, группы arch/pid/mech/sheet/understand —
сохранены целиком.
Три структурные правки после слияния (git сшил код механически, найдены pyflakes
и аудитом дерева):
- com_backend: наша подпись drawing_export_pdf попала в тело xref_attach и
дублировала метод; вставка убрана, паритетные text_layer/page_size перенесены
в настоящую COM-функцию;
- _space_name в ezdxf-бэкенде был занят дважды (нашин и 1.6.0): наш переименован
в _reply_space_name, иначе он затенял хелпер апстрима;
- entity_create_text: ключ "style" в dxfattribs стоял дважды (наш оставлен).
Прод :30015 не затронут.
- The exterior dimension chains follow the plot scale. DIMSCALE is the
plan's scale while they are drawn and is put back afterwards; a 1:50
chain's 2.5 text had been 2.5 drawing units, 0.05 mm on the sheet.
- A headless TABLE merges its title row, as AutoCAD's TABLE does; a long
title no longer wraps inside the first column over the header row.
- A headless TABLE's cell margin scales with the lettering. A flat 1.0 was
0.02 mm on a 1:50 schedule; tables at the default 2.5 text are unchanged.
- drawing_new writes the Standard text style in arial.ttf, as AutoCAD
2026's acadiso.dwt ships it: txt has no superscript two, so 'm²' read
'm?' live and rendered as a box headlessly. The five bundled templates
are rebuilt and build_templates --check reproduces them.
Each fix has a test that fails on the old code.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
The evidence for tracks B and G, in the shape tracks A and E used.
- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
addressable so an earlier report reproduces exactly rather than being only
described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
built from the part model and the sheet standard, through the code the
tools call (apply_titleblock, draw_part/add_view/dimension_part,
insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
two numbers that cannot be negotiated: drawing_critique(focus=None) returns
zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
the v5 matrix). The adapter returns the focuses that fired, so a
regression names itself, and a test drops one balloon to prove the gate
fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
(25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
the drawing, a linked balloon, an xref attached / listed / detached, a
31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
the first call after Documents.Add is refused) and tracks its documents by
the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
code:
* ezdxf dimension_diameter put the text beyond the SECOND chord point;
ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
front-view diameter stood on its own chain dimension. ezdxf now follows
ActiveX.
* draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
every target layer before the first entity.
* bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
(ObjectName AcDbBlockReference) - the live parts list was empty.
* COM _entity_info reported no dimension text_position: ModelSpace.Item
narrows to IAcadEntity, which has no TextPosition (measured
AttributeError); read through _com_unnarrow (TextPosition measured on
AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
diameter collided with its own bore.
* pid graph raised KeyError 'start' mid-critique on a LINE whose point read
AutoCAD refused while busy; such a line is left out.
* the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
the task count from the v5 matrix, the token table is remeasured with a
TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
collected-tests figure is retaken (3990).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
Three blocking findings on the refs group, all of them the same shape: a
number or a refusal the caller was entitled to read as measured.
1. ezdxf xref_manage: the victim scan and the `inserts` count both walked
`doc.modelspace()` only, then dropped the definition with
`delete_block(safe=False)` -- the flag that suppresses the in-use check.
An xref shown on a paper-space sheet was reported as 1 insert where the
drawing held 2, and detaching it left that sheet INSERT with no BLOCK
behind it: `ezdxf.recover` reports `FIX 103 UNDEFINED_BLOCK: Deleted
entity INSERT(#34) without required BLOCK definition` and the repo's own
drawing_audit silently deletes the sheet geometry. The scan now walks
`doc.blocks`, which is every entity space (model space, each paper-space
tab, every block definition) and is exactly what ezdxf's own safe check
searches with `doc.query('INSERT[name==...]i')` -- hence the
case-insensitive name match. `delete_block` is now `safe=True`, as the
plan's Interfaces block pins it, so a future miss raises instead of
writing a corrupt file.
2. COM xref_manage(action='list') reported `kind: "attach"` for every row,
a literal constant. Re-measured on this seat's registered type library
(C:\Program Files\Common Files\Autodesk Shared\acax25*.tlb, "AutoCAD
2025 Type Library", installed with AutoCAD 2026): IAcadBlock's xref
members are exactly IsXRef, Path, Name, Reload, Unload, Bind, Detach,
XRefDatabase -- no overlay indicator exists on the interface, so an
overlay was reported to the client as an attachment. The row now says
`kind: None`, the same honesty the adjacent `inserts` field already had,
and the contract plus the tool docstring say so.
3. COM xref_attach had no duplicate-block-name guard, although
contracts/refs.py and the tool docstring both promise the refusal and
ezdxf implements it. Two different part.dwg files, or a re-attach after a
repath, silently re-pointed or half-wrote the definition while the
payload claimed the new path. Added `_block_names(doc)` (Blocks.Count /
Blocks.Item(index) / block.Name -- the members xref_manage already walks)
and the refusal fires before AttachExternalReference is dispatched, with
the same message the headless engine gives. Lower-cased, because AutoCAD
symbol-table names are case-insensitive.
Tests: five new ones, each failing against the unfixed backends -- an xref
shown on Layout1 is counted and detached with the drawing, the detached file
audits clean through ezdxf.recover, delete_block's in-use check is pinned,
the live listing reports kind None (against a fake that models only the
measured members, so it cannot enshrine an invented one), and the live attach
refuses a held name (xref and plain block, either case) without dispatching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.
The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:
IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
RotationAngle)
IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
XRefDatabase (get)
IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
ac2013_dwg=60 ac2018_dwg=64 (=acNative)
The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.
Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.
Three capability keys, declared in both maps:
dwg_write -- measured absent here (odafc.is_installed() is False), so the
ezdxf entry is a runtime re-evaluation like viewport_render's
matplotlib check rather than a hardcoded False: installing the
ODA File Converter starts the call working with no edit to the
map. The refusal names the install route and drawing_export_dxf.
xref_live -- reload and bind need a live seat's xref manager and are refused
headlessly; list, detach and path are headless and are not
behind the key.
xlsx_write -- openpyxl is optional and absent here; CSV is always available,
so the refusal never blocks the deliverable and says so.
Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
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
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
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
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 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
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
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
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
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
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
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
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
`_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