From b12fa854c1b866f43c89d771cd4a0ca904f8eac0 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:52:58 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 138 +++++++++++++++++-------- backends/ezdxf_backend.py | 36 +++++-- server.py | 5 +- tests/test_track_a_hardening.py | 177 ++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 51 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index d59224a..89305e4 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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() diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 3cccfb8..9906e3a 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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 { diff --git a/server.py b/server.py index 32e8e6d..4379748 100644 --- a/server.py +++ b/server.py @@ -3031,8 +3031,9 @@ async def block_explode( Every attached ATTRIB becomes a TEXT with the same value, placement, height, rotation and layer (an invisible attribute becomes an invisible - TEXT); AutoCAD's plain EXPLODE would keep only the tag-name placeholders - and drop the values. Everything lands in the layout that owns the + TEXT), and a constant attribute (which has no ATTRIB) becomes a TEXT of + 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 current. Returns `inserted_handles` (the geometry), `attribute_texts` (one TEXT handle per attribute), `exploded_handle` and `backend`. diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index dcf4efe..837f33a 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -27,6 +27,14 @@ def _define_tagged_block(backend, name: str = "TB") -> None: # ── 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: """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) 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)) 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))] text = space.texts[0] # 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 = [ (name, tuple(value.value) if hasattr(value, "value") else value) 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"] +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): """An ATTRIB is an OCS entity. Mirroring reflects it to extrusion ``(0, 0, -1)`` while the INSERT keeps +Z with ``yscale=-1``; the TEXT