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
This commit is contained in:
co-authored by
Claude Opus 5
parent
415de73137
commit
b12fa854c1
+95
-43
@@ -3388,6 +3388,77 @@ class ComBackend(AutoCADBackend):
|
||||
out.pop("TextAlignmentPoint", None)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _capture_attrib(cls, attr) -> dict:
|
||||
"""Everything a TEXT needs to stand in for ``attr`` (an ATTRIB or ATTDEF)."""
|
||||
try:
|
||||
invisible = bool(attr.Invisible)
|
||||
except Exception:
|
||||
invisible = False
|
||||
return {
|
||||
"text": str(attr.TextString),
|
||||
"insertion": tuple(attr.InsertionPoint),
|
||||
"height": float(attr.Height),
|
||||
"rotation": float(attr.Rotation),
|
||||
"layer": str(attr.Layer),
|
||||
"invisible": invisible,
|
||||
"members": cls._capture_attrib_text_members(attr),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _add_text_from_capture(cls, owner, item: dict):
|
||||
"""``owner.AddText`` carrying a captured ATTRIB/ATTDEF; returns the TEXT.
|
||||
|
||||
Every point or vector goes through ``_apoint`` (``VT_ARRAY|VT_R8``):
|
||||
ActiveX refuses a plain Python tuple (marshalled ``VT_ARRAY|VT_VARIANT``)
|
||||
with ``E_INVALIDARG``, and ``Normal`` is written after the explode and
|
||||
the ATTDEF deletes, so a tuple there tore the drawing.
|
||||
"""
|
||||
ins = item["insertion"]
|
||||
point = _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0)
|
||||
text = owner.AddText(item["text"], point, item["height"])
|
||||
members = item["members"]
|
||||
for name in cls._ATTRIB_FRAME_MEMBERS:
|
||||
if name not in members:
|
||||
continue
|
||||
value = members[name]
|
||||
if name == "Normal":
|
||||
value = _apoint(value[0], value[1], value[2] if len(value) > 2 else 0.0)
|
||||
setattr(text, name, value)
|
||||
text.Rotation = item["rotation"]
|
||||
if "Normal" in members:
|
||||
# The frame moved the OCS origin; the anchor was WCS.
|
||||
text.InsertionPoint = point
|
||||
if "Alignment" in members:
|
||||
text.Alignment = members["Alignment"]
|
||||
if "TextAlignmentPoint" in members:
|
||||
tap = members["TextAlignmentPoint"]
|
||||
text.TextAlignmentPoint = _apoint(tap[0], tap[1], tap[2] if len(tap) > 2 else 0.0)
|
||||
text.Layer = item["layer"]
|
||||
if "Color" in members:
|
||||
text.Color = members["Color"]
|
||||
if item["invisible"]:
|
||||
text.Visible = False
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _is_constant_attdef(obj, constant_tags: set) -> bool:
|
||||
"""Whether an exploded ``AcDbAttributeDefinition`` is a constant attribute.
|
||||
|
||||
``Constant`` is the documented member; when the object does not expose
|
||||
it the tag is matched against ``GetConstantAttributes()`` read before
|
||||
the explode, so a constant attribute is never mistaken for a value-less
|
||||
placeholder and deleted.
|
||||
"""
|
||||
try:
|
||||
return bool(obj.Constant)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return str(obj.TagString) in constant_tags
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def block_explode(self, handle) -> dict:
|
||||
"""Explode an INSERT through ActiveX; ATTRIB values survive as TEXT.
|
||||
|
||||
@@ -3403,17 +3474,27 @@ class ComBackend(AutoCADBackend):
|
||||
one (an array of the new objects) and is exercised live by the
|
||||
settings smoke.
|
||||
|
||||
A *constant* attribute has no ATTRIB: ``GetAttributes()`` excludes it
|
||||
(``GetConstantAttributes()`` lists it) and ``Explode()`` hands it back
|
||||
as an ``AcDbAttributeDefinition`` already at its WCS placement showing
|
||||
its value. Deleting every ATTDEF therefore destroyed the constant text
|
||||
with ``ok: True``; BURST converts it to TEXT, so a constant ATTDEF from
|
||||
the explode is captured the same way an ATTRIB is, then replaced by a
|
||||
TEXT, and only the value-less placeholders are just deleted.
|
||||
|
||||
An ATTRIB is an OCS entity and ``AddText`` builds a +Z TEXT, so the
|
||||
frame is carried the way the headless engine carries ``extrusion`` /
|
||||
``thickness``: ``Normal`` is written *before* ``Rotation`` (the
|
||||
group-50 angle only means the same thing inside the same frame -- a
|
||||
mirrored reference's ATTRIB sits on ``(0, 0, -1)`` and used to read
|
||||
theta instead of 180deg-theta, with un-mirrored glyphs) and the WCS
|
||||
headless-authored mirrored reference's ATTRIB sits on ``(0, 0, -1)``;
|
||||
AutoCAD's own MIRROR keeps +Z with ``XScaleFactor -1``) and the WCS
|
||||
``InsertionPoint`` is re-asserted *after* it, because a frame change
|
||||
moves the OCS origin under a point that was handed over in WCS. The
|
||||
members ezdxf's ``_ATTRIB_TO_TEXT`` names have their ActiveX twins in
|
||||
``_ATTRIB_TEXT_MEMBERS``; each is optional and skipped when the object
|
||||
does not expose it, never written as a guess.
|
||||
does not expose it, never written as a guess. ``Normal`` is written as
|
||||
a ``VT_ARRAY|VT_R8`` VARIANT like every other point in this file; a
|
||||
plain tuple is ``E_INVALIDARG`` live (verified on AutoCAD 2026).
|
||||
|
||||
The TEXTs go into the reference's *owner* block
|
||||
(``ObjectIdToObject(OwnerID)``), which is where ``Explode()`` puts the
|
||||
@@ -3430,60 +3511,31 @@ class ComBackend(AutoCADBackend):
|
||||
if ent.ObjectName != "AcDbBlockReference":
|
||||
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||
owner = _owner_layout_block(doc, ent, handle)
|
||||
captured = []
|
||||
try:
|
||||
attrs = ent.GetAttributes()
|
||||
except Exception as exc:
|
||||
log.debug("GetAttributes failed before explode: %s", exc)
|
||||
attrs = ()
|
||||
for attr in attrs:
|
||||
try:
|
||||
invisible = bool(attr.Invisible)
|
||||
except Exception:
|
||||
invisible = False
|
||||
captured.append(
|
||||
{
|
||||
"text": str(attr.TextString),
|
||||
"insertion": tuple(attr.InsertionPoint),
|
||||
"height": float(attr.Height),
|
||||
"rotation": float(attr.Rotation),
|
||||
"layer": str(attr.Layer),
|
||||
"invisible": invisible,
|
||||
"members": self._capture_attrib_text_members(attr),
|
||||
}
|
||||
)
|
||||
captured = [self._capture_attrib(attr) for attr in attrs]
|
||||
constant_tags: set = set()
|
||||
try:
|
||||
for attr in ent.GetConstantAttributes() or ():
|
||||
constant_tags.add(str(attr.TagString))
|
||||
except Exception as exc:
|
||||
log.debug("GetConstantAttributes failed before explode: %s", exc)
|
||||
exploded = ent.Explode()
|
||||
inserted = []
|
||||
for obj in exploded or ():
|
||||
if obj.ObjectName == "AcDbAttributeDefinition":
|
||||
if self._is_constant_attdef(obj, constant_tags):
|
||||
# A constant attribute's only text; already WCS here.
|
||||
captured.append(self._capture_attrib(obj))
|
||||
obj.Delete() # the tag placeholder EXPLODE leaves; the value is below
|
||||
continue
|
||||
inserted.append(str(obj.Handle))
|
||||
attribute_texts = []
|
||||
for item in captured:
|
||||
ins = item["insertion"]
|
||||
point = _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0)
|
||||
text = owner.AddText(item["text"], point, item["height"])
|
||||
members = item["members"]
|
||||
for name in self._ATTRIB_FRAME_MEMBERS:
|
||||
if name in members:
|
||||
setattr(text, name, members[name])
|
||||
text.Rotation = item["rotation"]
|
||||
if "Normal" in members:
|
||||
# The frame moved the OCS origin; the anchor was WCS.
|
||||
text.InsertionPoint = point
|
||||
if "Alignment" in members:
|
||||
text.Alignment = members["Alignment"]
|
||||
if "TextAlignmentPoint" in members:
|
||||
tap = members["TextAlignmentPoint"]
|
||||
text.TextAlignmentPoint = _apoint(
|
||||
tap[0], tap[1], tap[2] if len(tap) > 2 else 0.0
|
||||
)
|
||||
text.Layer = item["layer"]
|
||||
if "Color" in members:
|
||||
text.Color = members["Color"]
|
||||
if item["invisible"]:
|
||||
text.Visible = False
|
||||
text = self._add_text_from_capture(owner, item)
|
||||
attribute_texts.append(str(text.Handle))
|
||||
ent.Delete()
|
||||
_regen()
|
||||
|
||||
@@ -4570,6 +4570,11 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"thickness",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _text_attribs_of(cls, attrib) -> dict:
|
||||
"""The ``_ATTRIB_TO_TEXT`` members ``attrib`` (ATTRIB or ATTDEF) carries."""
|
||||
return {key: attrib.dxf.get(key) for key in cls._ATTRIB_TO_TEXT if attrib.dxf.hasattr(key)}
|
||||
|
||||
async def block_explode(self, handle) -> dict:
|
||||
"""Explode an INSERT into its members; ATTRIB values survive as TEXT.
|
||||
|
||||
@@ -4581,6 +4586,16 @@ class EzdxfBackend(AutoCADBackend):
|
||||
invisible TEXT rather than appearing. The new handles are reported as
|
||||
``attribute_texts``, separate from the geometry's ``inserted_handles``.
|
||||
|
||||
A *constant* attribute (ATTDEF flag 2) carries its value in the
|
||||
definition and, in an AutoCAD-authored file, has no ATTRIB on the
|
||||
reference — ``virtual_entities()`` skips ATTDEF, so its visible text
|
||||
used to vanish with a clean audit. BURST converts it to TEXT, so every
|
||||
constant ATTDEF whose tag has no attached ATTRIB is copied as a TEXT
|
||||
and transformed by the reference's matrix — the same path
|
||||
``add_auto_attribs`` takes, so it lands where the ATTRIB the headless
|
||||
``block_insert`` attaches would have (that ATTRIB, when present, is
|
||||
converted above and the definition is not converted twice).
|
||||
|
||||
The members and the TEXTs go into the INSERT's *owner* layout, not the
|
||||
current one: ``_get_entity`` resolves a handle in any layout, so a title
|
||||
block on a sheet exploded while Model was current used to have its
|
||||
@@ -4601,16 +4616,25 @@ class EzdxfBackend(AutoCADBackend):
|
||||
msp.add_entity(sub_copy)
|
||||
inserted.append(sub_copy.dxf.handle)
|
||||
attribute_texts = []
|
||||
attached_tags = set()
|
||||
for attrib in ent.attribs:
|
||||
dxfattribs = {
|
||||
key: attrib.dxf.get(key)
|
||||
for key in self._ATTRIB_TO_TEXT
|
||||
if attrib.dxf.hasattr(key)
|
||||
}
|
||||
text = msp.add_text(attrib.dxf.text, dxfattribs=dxfattribs)
|
||||
attached_tags.add(attrib.dxf.tag)
|
||||
text = msp.add_text(attrib.dxf.text, dxfattribs=self._text_attribs_of(attrib))
|
||||
if attrib.is_invisible:
|
||||
text.dxf.invisible = 1
|
||||
attribute_texts.append(text.dxf.handle)
|
||||
block = ent.block()
|
||||
attdefs = block.attdefs() if block is not None else ()
|
||||
for attdef in attdefs:
|
||||
if not attdef.is_const or attdef.dxf.get("tag") in attached_tags:
|
||||
continue
|
||||
if not attdef.dxf.hasattr("insert"):
|
||||
continue # a structure error; nowhere to place it
|
||||
text = msp.add_text(attdef.dxf.text, dxfattribs=self._text_attribs_of(attdef))
|
||||
text.transform(ent.matrix44())
|
||||
if attdef.is_invisible:
|
||||
text.dxf.invisible = 1
|
||||
attribute_texts.append(text.dxf.handle)
|
||||
msp.delete_entity(ent)
|
||||
self._mark_dirty()
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user