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
+94
-42
@@ -3388,6 +3388,77 @@ class ComBackend(AutoCADBackend):
|
|||||||
out.pop("TextAlignmentPoint", None)
|
out.pop("TextAlignmentPoint", None)
|
||||||
return out
|
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:
|
async def block_explode(self, handle) -> dict:
|
||||||
"""Explode an INSERT through ActiveX; ATTRIB values survive as TEXT.
|
"""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
|
one (an array of the new objects) and is exercised live by the
|
||||||
settings smoke.
|
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
|
An ATTRIB is an OCS entity and ``AddText`` builds a +Z TEXT, so the
|
||||||
frame is carried the way the headless engine carries ``extrusion`` /
|
frame is carried the way the headless engine carries ``extrusion`` /
|
||||||
``thickness``: ``Normal`` is written *before* ``Rotation`` (the
|
``thickness``: ``Normal`` is written *before* ``Rotation`` (the
|
||||||
group-50 angle only means the same thing inside the same frame -- a
|
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
|
headless-authored mirrored reference's ATTRIB sits on ``(0, 0, -1)``;
|
||||||
theta instead of 180deg-theta, with un-mirrored glyphs) and the WCS
|
AutoCAD's own MIRROR keeps +Z with ``XScaleFactor -1``) and the WCS
|
||||||
``InsertionPoint`` is re-asserted *after* it, because a frame change
|
``InsertionPoint`` is re-asserted *after* it, because a frame change
|
||||||
moves the OCS origin under a point that was handed over in WCS. The
|
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
|
members ezdxf's ``_ATTRIB_TO_TEXT`` names have their ActiveX twins in
|
||||||
``_ATTRIB_TEXT_MEMBERS``; each is optional and skipped when the object
|
``_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
|
The TEXTs go into the reference's *owner* block
|
||||||
(``ObjectIdToObject(OwnerID)``), which is where ``Explode()`` puts the
|
(``ObjectIdToObject(OwnerID)``), which is where ``Explode()`` puts the
|
||||||
@@ -3430,60 +3511,31 @@ class ComBackend(AutoCADBackend):
|
|||||||
if ent.ObjectName != "AcDbBlockReference":
|
if ent.ObjectName != "AcDbBlockReference":
|
||||||
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||||
owner = _owner_layout_block(doc, ent, handle)
|
owner = _owner_layout_block(doc, ent, handle)
|
||||||
captured = []
|
|
||||||
try:
|
try:
|
||||||
attrs = ent.GetAttributes()
|
attrs = ent.GetAttributes()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("GetAttributes failed before explode: %s", exc)
|
log.debug("GetAttributes failed before explode: %s", exc)
|
||||||
attrs = ()
|
attrs = ()
|
||||||
for attr in attrs:
|
captured = [self._capture_attrib(attr) for attr in attrs]
|
||||||
|
constant_tags: set = set()
|
||||||
try:
|
try:
|
||||||
invisible = bool(attr.Invisible)
|
for attr in ent.GetConstantAttributes() or ():
|
||||||
except Exception:
|
constant_tags.add(str(attr.TagString))
|
||||||
invisible = False
|
except Exception as exc:
|
||||||
captured.append(
|
log.debug("GetConstantAttributes failed before explode: %s", exc)
|
||||||
{
|
|
||||||
"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),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
exploded = ent.Explode()
|
exploded = ent.Explode()
|
||||||
inserted = []
|
inserted = []
|
||||||
for obj in exploded or ():
|
for obj in exploded or ():
|
||||||
if obj.ObjectName == "AcDbAttributeDefinition":
|
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
|
obj.Delete() # the tag placeholder EXPLODE leaves; the value is below
|
||||||
continue
|
continue
|
||||||
inserted.append(str(obj.Handle))
|
inserted.append(str(obj.Handle))
|
||||||
attribute_texts = []
|
attribute_texts = []
|
||||||
for item in captured:
|
for item in captured:
|
||||||
ins = item["insertion"]
|
text = self._add_text_from_capture(owner, item)
|
||||||
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
|
|
||||||
attribute_texts.append(str(text.Handle))
|
attribute_texts.append(str(text.Handle))
|
||||||
ent.Delete()
|
ent.Delete()
|
||||||
_regen()
|
_regen()
|
||||||
|
|||||||
@@ -4570,6 +4570,11 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"thickness",
|
"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:
|
async def block_explode(self, handle) -> dict:
|
||||||
"""Explode an INSERT into its members; ATTRIB values survive as TEXT.
|
"""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
|
invisible TEXT rather than appearing. The new handles are reported as
|
||||||
``attribute_texts``, separate from the geometry's ``inserted_handles``.
|
``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
|
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
|
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
|
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)
|
msp.add_entity(sub_copy)
|
||||||
inserted.append(sub_copy.dxf.handle)
|
inserted.append(sub_copy.dxf.handle)
|
||||||
attribute_texts = []
|
attribute_texts = []
|
||||||
|
attached_tags = set()
|
||||||
for attrib in ent.attribs:
|
for attrib in ent.attribs:
|
||||||
dxfattribs = {
|
attached_tags.add(attrib.dxf.tag)
|
||||||
key: attrib.dxf.get(key)
|
text = msp.add_text(attrib.dxf.text, dxfattribs=self._text_attribs_of(attrib))
|
||||||
for key in self._ATTRIB_TO_TEXT
|
|
||||||
if attrib.dxf.hasattr(key)
|
|
||||||
}
|
|
||||||
text = msp.add_text(attrib.dxf.text, dxfattribs=dxfattribs)
|
|
||||||
if attrib.is_invisible:
|
if attrib.is_invisible:
|
||||||
text.dxf.invisible = 1
|
text.dxf.invisible = 1
|
||||||
attribute_texts.append(text.dxf.handle)
|
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)
|
msp.delete_entity(ent)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3031,8 +3031,9 @@ async def block_explode(
|
|||||||
|
|
||||||
Every attached ATTRIB becomes a TEXT with the same value, placement,
|
Every attached ATTRIB becomes a TEXT with the same value, placement,
|
||||||
height, rotation and layer (an invisible attribute becomes an invisible
|
height, rotation and layer (an invisible attribute becomes an invisible
|
||||||
TEXT); AutoCAD's plain EXPLODE would keep only the tag-name placeholders
|
TEXT), and a constant attribute (which has no ATTRIB) becomes a TEXT of
|
||||||
and drop the values. Everything lands in the layout that owns the
|
its value too; AutoCAD's plain EXPLODE would keep only the tag-name
|
||||||
|
placeholders and drop the values. Everything lands in the layout that owns the
|
||||||
reference (model space or its paper-space sheet), whichever tab is
|
reference (model space or its paper-space sheet), whichever tab is
|
||||||
current. Returns `inserted_handles` (the geometry), `attribute_texts`
|
current. Returns `inserted_handles` (the geometry), `attribute_texts`
|
||||||
(one TEXT handle per attribute), `exploded_handle` and `backend`.
|
(one TEXT handle per attribute), `exploded_handle` and `backend`.
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ def _define_tagged_block(backend, name: str = "TB") -> None:
|
|||||||
# ── fake ActiveX surface ──────────────────────────────────────────────────────
|
# ── fake ActiveX surface ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
#: Members AutoCAD's ActiveX takes as a ``VT_ARRAY|VT_R8`` VARIANT. A plain
|
||||||
|
#: Python tuple marshals as ``VT_ARRAY|VT_VARIANT`` and is refused with
|
||||||
|
#: ``E_INVALIDARG`` (verified live on AutoCAD 2026: ``text.Normal = (0, 0, -1)``
|
||||||
|
#: raises, ``text.Normal = _apoint(0, 0, -1)`` succeeds); the fake refuses it
|
||||||
|
#: the same way so a test cannot pin the wrong call shape.
|
||||||
|
_POINT_MEMBERS = frozenset({"Normal", "InsertionPoint", "TextAlignmentPoint"})
|
||||||
|
|
||||||
|
|
||||||
class _FakeObject:
|
class _FakeObject:
|
||||||
"""An ActiveX object with only the members it is given; property writes are recorded."""
|
"""An ActiveX object with only the members it is given; property writes are recorded."""
|
||||||
|
|
||||||
@@ -39,6 +47,10 @@ class _FakeObject:
|
|||||||
object.__setattr__(self, name, value)
|
object.__setattr__(self, name, value)
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name, value):
|
||||||
|
if name in _POINT_MEMBERS and not hasattr(value, "varianttype"):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"E_INVALIDARG: {name} takes a VT_ARRAY|VT_R8 VARIANT, not {type(value).__name__}"
|
||||||
|
)
|
||||||
self.writes.append((name, value))
|
self.writes.append((name, value))
|
||||||
object.__setattr__(self, name, value)
|
object.__setattr__(self, name, value)
|
||||||
|
|
||||||
@@ -454,6 +466,13 @@ async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com)
|
|||||||
assert space.calls == [("AddText", ("P-101", (-25.0, 13.0, 0.0), 2.5))]
|
assert space.calls == [("AddText", ("P-101", (-25.0, 13.0, 0.0), 2.5))]
|
||||||
text = space.texts[0]
|
text = space.texts[0]
|
||||||
# Point writes go through ``_apoint`` (a VARIANT); compare their payload.
|
# Point writes go through ``_apoint`` (a VARIANT); compare their payload.
|
||||||
|
import pythoncom
|
||||||
|
|
||||||
|
for name, value in text.writes:
|
||||||
|
if name in _POINT_MEMBERS:
|
||||||
|
assert value.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8, (
|
||||||
|
f"{name} must be a double-array VARIANT, not a tuple (E_INVALIDARG live)"
|
||||||
|
)
|
||||||
writes = [
|
writes = [
|
||||||
(name, tuple(value.value) if hasattr(value, "value") else value)
|
(name, tuple(value.value) if hasattr(value, "value") else value)
|
||||||
for name, value in text.writes
|
for name, value in text.writes
|
||||||
@@ -524,6 +543,164 @@ async def test_com_explode_skips_frame_members_the_attrib_does_not_expose(com):
|
|||||||
assert names == ["Rotation", "Alignment", "Layer"]
|
assert names == ["Rotation", "Alignment", "Layer"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_explode_turns_a_constant_attdef_into_text_instead_of_deleting_it(com):
|
||||||
|
"""``GetAttributes()`` excludes constant attributes (``GetConstantAttributes()``
|
||||||
|
lists them) and ``Explode()`` returns each as an ``AcDbAttributeDefinition``
|
||||||
|
already at its WCS placement showing its value. Deleting every ATTDEF
|
||||||
|
destroyed that text with ``ok: True``; BURST converts it to TEXT."""
|
||||||
|
backend, document, space = com
|
||||||
|
tag = _FakeObject(
|
||||||
|
"AcDbAttribute",
|
||||||
|
"A1",
|
||||||
|
TagString="TAG",
|
||||||
|
TextString="P-101",
|
||||||
|
InsertionPoint=(7.0, 15.2, 0.0),
|
||||||
|
Height=5.0,
|
||||||
|
Rotation=0.0,
|
||||||
|
Layer="PID-TAG",
|
||||||
|
Invisible=False,
|
||||||
|
)
|
||||||
|
placeholder = _FakeObject("AcDbAttributeDefinition", "D1", TagString="TAG", Constant=False)
|
||||||
|
constant = _FakeObject(
|
||||||
|
"AcDbAttributeDefinition",
|
||||||
|
"D2",
|
||||||
|
TagString="CONST",
|
||||||
|
TextString="ACME",
|
||||||
|
Constant=True,
|
||||||
|
InsertionPoint=(25.0, 7.0, 0.0),
|
||||||
|
Height=2.0,
|
||||||
|
Rotation=0.5236,
|
||||||
|
Layer="TITLEBLOCK",
|
||||||
|
Invisible=False,
|
||||||
|
Alignment=4,
|
||||||
|
TextAlignmentPoint=(25.0, 7.0, 0.0),
|
||||||
|
)
|
||||||
|
line = _FakeObject("AcDbLine", "L1")
|
||||||
|
ref = _FakeObject(
|
||||||
|
"AcDbBlockReference",
|
||||||
|
"8F",
|
||||||
|
Name="TB",
|
||||||
|
OwnerID=1,
|
||||||
|
GetAttributes=lambda: (tag,),
|
||||||
|
GetConstantAttributes=lambda: (constant,),
|
||||||
|
Explode=lambda: (line, placeholder, constant),
|
||||||
|
)
|
||||||
|
document.objects["8F"] = ref
|
||||||
|
|
||||||
|
result = await backend.block_explode("8F")
|
||||||
|
|
||||||
|
assert result["inserted_handles"] == ["L1"]
|
||||||
|
assert result["attribute_texts"] == ["T1", "T2"], "the constant value is a TEXT too"
|
||||||
|
assert placeholder.deleted is True and constant.deleted is True and ref.deleted is True
|
||||||
|
assert space.calls == [
|
||||||
|
("AddText", ("P-101", (7.0, 15.2, 0.0), 5.0)),
|
||||||
|
("AddText", ("ACME", (25.0, 7.0, 0.0), 2.0)),
|
||||||
|
]
|
||||||
|
writes = [
|
||||||
|
(name, tuple(value.value) if hasattr(value, "value") else value)
|
||||||
|
for name, value in space.texts[1].writes
|
||||||
|
]
|
||||||
|
assert writes == [
|
||||||
|
("Rotation", 0.5236),
|
||||||
|
("Alignment", 4),
|
||||||
|
("TextAlignmentPoint", (25.0, 7.0, 0.0)),
|
||||||
|
("Layer", "TITLEBLOCK"),
|
||||||
|
], "the constant ATTDEF's placement, angle and alignment ride onto its TEXT"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_explode_falls_back_to_the_constant_tag_list_when_constant_is_absent(com):
|
||||||
|
"""An object that does not expose ``Constant`` is still recognised through
|
||||||
|
``GetConstantAttributes()``; a value-less placeholder is still just deleted."""
|
||||||
|
backend, document, space = com
|
||||||
|
constant = _FakeObject(
|
||||||
|
"AcDbAttributeDefinition",
|
||||||
|
"D2",
|
||||||
|
TagString="CONST",
|
||||||
|
TextString="ACME",
|
||||||
|
InsertionPoint=(1.0, 2.0, 0.0),
|
||||||
|
Height=2.0,
|
||||||
|
Rotation=0.0,
|
||||||
|
Layer="0",
|
||||||
|
)
|
||||||
|
placeholder = _FakeObject("AcDbAttributeDefinition", "D1", TagString="TAG")
|
||||||
|
ref = _FakeObject(
|
||||||
|
"AcDbBlockReference",
|
||||||
|
"9A",
|
||||||
|
Name="TB",
|
||||||
|
OwnerID=1,
|
||||||
|
GetAttributes=lambda: (),
|
||||||
|
GetConstantAttributes=lambda: (constant,),
|
||||||
|
Explode=lambda: (placeholder, constant),
|
||||||
|
)
|
||||||
|
document.objects["9A"] = ref
|
||||||
|
|
||||||
|
result = await backend.block_explode("9A")
|
||||||
|
|
||||||
|
assert result["attribute_texts"] == ["T1"]
|
||||||
|
assert space.calls == [("AddText", ("ACME", (1.0, 2.0, 0.0), 2.0))]
|
||||||
|
assert placeholder.deleted is True and constant.deleted is True
|
||||||
|
|
||||||
|
|
||||||
|
def _define_block_with_constant_attdef(backend, name: str = "TBC") -> None:
|
||||||
|
from ezdxf.enums import TextEntityAlignment
|
||||||
|
|
||||||
|
blk = backend._doc.blocks.new(name=name)
|
||||||
|
blk.add_line((0, 0), (10, 0))
|
||||||
|
blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
|
||||||
|
const = blk.add_attdef("CONST", insert=(5, -3), text="ACME", dxfattribs={"height": 2.0})
|
||||||
|
const.set_placement((5, -3), align=TextEntityAlignment.MIDDLE_CENTER)
|
||||||
|
const.dxf.flags |= 2 # ATTRIB_CONST
|
||||||
|
assert const.is_const
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explode_keeps_a_constant_attdef_that_has_no_attrib_as_text(backend):
|
||||||
|
"""The AutoCAD-authored shape: a constant attribute has no ATTRIB on the
|
||||||
|
reference, and ``virtual_entities()`` skips ATTDEF, so its text used to
|
||||||
|
vanish with a clean audit. The TEXT lands where ``add_auto_attribs``
|
||||||
|
would have put the ATTRIB (mirrored + rotated reference)."""
|
||||||
|
_define_block_with_constant_attdef(backend)
|
||||||
|
ref = backend._msp().add_blockref(
|
||||||
|
"TBC", (20, 10), dxfattribs={"xscale": -1.0, "yscale": 1.0, "rotation": 30.0}
|
||||||
|
)
|
||||||
|
ref.add_auto_attribs({"TAG": "P-101"})
|
||||||
|
expected = {a.dxf.tag: a.dxfattribs() for a in ref.attribs}["CONST"]
|
||||||
|
ref.delete_attrib("CONST") # AutoCAD writes no ATTRIB for a constant attribute
|
||||||
|
assert [a.dxf.tag for a in ref.attribs] == ["TAG"], "the premise"
|
||||||
|
|
||||||
|
result = await backend.block_explode(ref.dxf.handle)
|
||||||
|
|
||||||
|
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"]
|
||||||
|
texts = {}
|
||||||
|
for handle in result["attribute_texts"]:
|
||||||
|
raw = backend._doc.entitydb[handle]
|
||||||
|
assert raw.dxftype() == "TEXT"
|
||||||
|
texts[raw.dxf.text] = raw
|
||||||
|
assert set(texts) == {"P-101", "ACME"}
|
||||||
|
acme = texts["ACME"]
|
||||||
|
for key in ("insert", "align_point", "extrusion"):
|
||||||
|
assert tuple(acme.dxf.get(key)) == pytest.approx(tuple(expected[key])), key
|
||||||
|
assert acme.dxf.rotation == pytest.approx(expected["rotation"])
|
||||||
|
assert acme.dxf.height == expected["height"] == 2.0
|
||||||
|
assert acme.get_align_enum().name == "MIDDLE_CENTER"
|
||||||
|
assert acme.dxf.invisible == 0
|
||||||
|
assert backend._doc.audit().errors == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explode_does_not_duplicate_a_constant_attdef_that_already_has_an_attrib(backend):
|
||||||
|
"""The MCP-authored shape: ``block_insert`` attaches an ATTRIB for the
|
||||||
|
constant ATTDEF too, so it is converted once, not once per source."""
|
||||||
|
_define_block_with_constant_attdef(backend)
|
||||||
|
ref = await backend.block_insert("TBC", 20, 10, attributes={"TAG": "P-101"})
|
||||||
|
raw_ref = backend._doc.entitydb[ref.handle]
|
||||||
|
assert sorted(a.dxf.tag for a in raw_ref.attribs) == ["CONST", "TAG"], "the premise"
|
||||||
|
|
||||||
|
result = await backend.block_explode(ref.handle)
|
||||||
|
|
||||||
|
values = sorted(backend._doc.entitydb[h].dxf.text for h in result["attribute_texts"])
|
||||||
|
assert values == ["ACME", "P-101"]
|
||||||
|
assert backend._doc.audit().errors == []
|
||||||
|
|
||||||
|
|
||||||
async def test_explode_of_a_mirrored_reference_keeps_the_tag_where_the_attrib_was(backend):
|
async def test_explode_of_a_mirrored_reference_keeps_the_tag_where_the_attrib_was(backend):
|
||||||
"""An ATTRIB is an OCS entity. Mirroring reflects it to extrusion
|
"""An ATTRIB is an OCS entity. Mirroring reflects it to extrusion
|
||||||
``(0, 0, -1)`` while the INSERT keeps +Z with ``yscale=-1``; the TEXT
|
``(0, 0, -1)`` while the INSERT keeps +Z with ``yscale=-1``; the TEXT
|
||||||
|
|||||||
Reference in New Issue
Block a user