Commit Graph
217 Commits
Author SHA1 Message Date
Umut CANandClaude Opus 5 32821c6410 fix(task-5): pid_from_spec shields its rollback so a real client cancellation rolls the sheet back on both engines
The MCP SDK cancels a request by cancelling an anyio scope, and anyio
cancellation is level-triggered: task.cancel() is re-issued on every loop
iteration while the task is inside the cancelled scope. run_spec's
`except BaseException: await backend.transaction_rollback()` was therefore
cancelled at its first await, before the rollback body ran. Measured through
the real transport (fastmcp.Client + notifications/cancelled): five symbols
left in an open transaction on ezdxf; on the COM shape EndUndoMark / _UNDO B
never sent while the backend's `finally` already cleared _transaction_active,
so the next transaction_begin nested a StartUndoMark. A native Task.cancel()
(what the plan's Step-4 note reasoned about) is delivered once and clears,
which is why raising CancelledError from a mock passed trivially.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 13:06:52 +03:00