Files
Umut CANandClaude Opus 5 301610620c fix(task-14): detach an xref out of every space, and stop the live engine inventing facts
Three blocking findings on the refs group, all of them the same shape: a
number or a refusal the caller was entitled to read as measured.

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

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

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

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

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

104 lines
4.4 KiB
Python

"""External references, raster images and DWG output (v1.6 tracks B+G, group G).
These are the only four operations of the sheet track that genuinely differ per
engine, so they are the only four that become contract members. Everything else
in `engineering/sheet/` composes the generic members and is written once.
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
#: An xref is attached (its own xrefs come with it) or overlaid (they do not).
XREF_KINDS = ("attach", "overlay")
#: `list`, `detach` and `path` are headless; `reload` and `bind` need a live
#: seat and are refused with the `xref_live` capability on the ezdxf engine.
XREF_ACTIONS = ("list", "reload", "bind", "detach", "path")
#: DWG formats both engines name the same way. COM maps them to AcSaveAsType
#: (measured on AutoCAD 2026: 12/24/36/48/60/64); ezdxf maps them through
#: `ezdxf.addons.odafc.VERSION_MAP`, which covers exactly these six and more.
DWG_VERSIONS = ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
class RefsContract(ABC):
@abstractmethod
async def xref_attach(
self,
path: str,
at,
scale: float,
rotation: float,
kind: str,
) -> dict:
"""Attach an external reference and insert it once.
The block name is the referenced file's stem, which is what both
engines do. Returns ``{ok, name, path, kind, handle, at, scale,
rotation, backend}``.
Refuses, before anything is written: a `kind` outside
:data:`XREF_KINDS`, a file that does not exist, a non-finite
coordinate/scale/rotation, a non-positive scale, and a block name the
drawing already holds (re-attaching would either silently replace the
definition or raise mid-write).
"""
...
@abstractmethod
async def xref_manage(self, name: str, action: str, new_path: str | None) -> dict:
"""One of :data:`XREF_ACTIONS` on one external reference.
``list`` ignores `name` and returns
``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``kind`` and
``inserts`` are ``None`` on a live seat -- ActiveX's ``IAcadBlock``
carries no overlay indicator and no per-xref insert count (measured on
the registered type library), and a constant would read as measured.
``path`` rewrites the saved path to `new_path` and returns the new one.
``detach`` removes every insert of the xref -- in *every* entity space,
model space and each paper-space layout, because an insert left behind
with no block definition is a corrupt drawing -- then the definition,
and reports ``inserts_removed``. ``inserts`` counts the same set.
``reload`` and ``bind`` need a live seat's xref manager: the headless engine raises
``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending,
which is why the key exists; ``list``/``detach``/``path`` are *not*
behind it.
Refuses an action outside the vocabulary, an unknown xref name, and a
``path`` action with no `new_path`.
"""
...
@abstractmethod
async def image_attach(self, path: str, at, scale: float, rotation: float) -> dict:
"""Attach a raster image as an underlay.
`scale` is drawing units per pixel, so the placed size is the image's
pixel size times `scale` and the aspect ratio is the file's. Returns
``{ok, path, handle, at, scale, rotation, pixels: [w, h],
size_mm: [w, h], backend}``.
Refuses a file that does not exist, a file no image reader can open
(naming it), a non-positive scale and a non-finite number -- all before
anything is written.
"""
...
@abstractmethod
async def drawing_export_dwg(self, path: str, version: str) -> dict:
"""Write the current drawing as a real DWG.
Returns ``{ok, path, version, bytes, backend}``. Refuses a `version`
outside :data:`DWG_VERSIONS` by name. Headlessly this needs the ODA
File Converter and otherwise raises
``UnsupportedCapabilityError("dwg_write", ...)`` naming the install
route; on a live seat it is ``Document.SaveAs`` with the matching
``AcSaveAsType``, which -- as AutoCAD's own SaveAs does -- leaves the
session bound to the file it just wrote.
"""
...