From 415de73137300f6ed800981e282c2a585bbc220f Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 22:01:08 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 108 ++++++++++++++++++++++++---- tests/test_track_a_hardening.py | 120 +++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 13 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 8926ce4..d59224a 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3330,6 +3330,64 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + #: ActiveX twins of the headless ``_ATTRIB_TO_TEXT`` carry, written onto + #: the TEXT in this order and *before* ``Rotation``: ``Normal`` is the + #: frame ``Rotation`` is measured in, ``Thickness`` rides on the frame, + #: and the style members are frame-independent. ``Alignment`` / + #: ``TextAlignmentPoint`` (halign/valign + align_point) and ``Color`` are + #: handled apart because ActiveX orders them against the insertion point. + _ATTRIB_FRAME_MEMBERS = ( + "Normal", + "Thickness", + "StyleName", + "ObliqueAngle", + "ScaleFactor", + "Backward", + "UpsideDown", + ) + _ATTRIB_TEXT_MEMBERS = _ATTRIB_FRAME_MEMBERS + ("Alignment", "TextAlignmentPoint", "Color") + + @classmethod + def _capture_attrib_text_members(cls, attr) -> dict: + """Read the optional ATTRIB members a TEXT can take, skipping absent ones. + + ``Normal`` is a WCS unit vector and is only worth carrying when it is + not already +Z; ``TextAlignmentPoint`` is meaningless (and refused by + ActiveX on write) for a left-aligned text, so it is dropped when + ``Alignment`` is ``acAlignmentLeft`` (0). + """ + out: dict = {} + for name in cls._ATTRIB_TEXT_MEMBERS: + try: + raw = getattr(attr, name) + except Exception: + continue + if name in ("Normal", "TextAlignmentPoint"): + try: + raw = tuple(float(v) for v in raw) + except (TypeError, ValueError): + continue + if name == "Normal" and ocs.is_wcs_frame(raw): + continue + elif name in ("Alignment", "Color"): + try: + raw = int(raw) + except (TypeError, ValueError): + continue + elif name in ("Backward", "UpsideDown"): + raw = bool(raw) + elif name == "StyleName": + raw = str(raw) + else: + try: + raw = float(raw) + except (TypeError, ValueError): + continue + out[name] = raw + if out.get("Alignment", 0) == 0: + out.pop("TextAlignmentPoint", None) + return out + async def block_explode(self, handle) -> dict: """Explode an INSERT through ActiveX; ATTRIB values survive as TEXT. @@ -3337,13 +3395,25 @@ class ComBackend(AutoCADBackend): ATTDEF entities) and discards the values; ActiveX ``Explode()`` does the same and, unlike the command, leaves the original reference in place. This is the BURST rule instead: each ATTRIB's value, placement, - height, rotation and layer are read before the explode, the ATTDEFs - the explode returns are deleted, one ``AddText`` per ATTRIB carries the - value (an invisible ATTRIB becomes an invisible TEXT), and the - reference itself is deleted. Unit-tested against a fake ActiveX - surface; ``Explode()``'s return shape is the ActiveX documented one - (an array of the new objects) and is exercised live by the settings - smoke. + height, rotation, layer, frame and text style are read before the + explode, the ATTDEFs the explode returns are deleted, one ``AddText`` + per ATTRIB carries the value (an invisible ATTRIB becomes an invisible + TEXT), and the reference itself is deleted. Unit-tested against a fake + ActiveX surface; ``Explode()``'s return shape is the ActiveX documented + one (an array of the new objects) and is exercised live by the + settings smoke. + + 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 + ``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. The TEXTs go into the reference's *owner* block (``ObjectIdToObject(OwnerID)``), which is where ``Explode()`` puts the @@ -3379,6 +3449,7 @@ class ComBackend(AutoCADBackend): "rotation": float(attr.Rotation), "layer": str(attr.Layer), "invisible": invisible, + "members": self._capture_attrib_text_members(attr), } ) exploded = ent.Explode() @@ -3391,13 +3462,26 @@ class ComBackend(AutoCADBackend): attribute_texts = [] for item in captured: ins = item["insertion"] - text = owner.AddText( - item["text"], - _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0), - item["height"], - ) + 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)) diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index b32a1e1..dcf4efe 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -109,6 +109,7 @@ class _FakeSpace: self.calls: list[tuple[str, tuple]] = [] self.attributes = attributes or {} self.inserted: list[_FakeObject] = [] + self.texts: list[_FakeObject] = [] self.Name = name self.IsLayout = True @@ -126,7 +127,9 @@ class _FakeSpace: def AddText(self, text, point, height): self.calls.append(("AddText", (text, tuple(point.value), height))) - return _FakeObject("AcDbText", f"T{len(self.calls)}") + obj = _FakeObject("AcDbText", f"T{len(self.calls)}") + self.texts.append(obj) + return obj @pytest.fixture @@ -406,6 +409,121 @@ async def test_com_explode_refuses_a_nested_reference_before_explode(com): assert dispatched == [] and space.calls == [] and ref.deleted is False +async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com): + """The COM twin of the mirrored-reference test. ``AddText`` builds a +Z + TEXT; an ATTRIB reflected to ``Normal (0, 0, -1)`` has an OCS ``Rotation`` + and mirrored glyphs, so the TEXT must take the frame *before* the angle + and have its WCS anchor re-asserted after the frame changes it. The + style members ezdxf carries (style, oblique, width, generation flags, + alignment + alignment point, colour) ride along the same way.""" + backend, document, space = com + attrib = _FakeObject( + "AcDbAttribute", + "A1", + TagString="TAG", + TextString="P-101", + InsertionPoint=(-25.0, 13.0, 0.0), + Height=2.5, + Rotation=0.5236, + Layer="PID-TAG", + Invisible=False, + Normal=(0.0, 0.0, -1.0), + Thickness=0.7, + StyleName="ISO", + ObliqueAngle=0.2618, + ScaleFactor=0.8, + Backward=True, + UpsideDown=False, + Alignment=4, + TextAlignmentPoint=(-20.0, 13.0, 0.0), + Color=3, + ) + ref = _FakeObject( + "AcDbBlockReference", + "6D", + Name="TB", + OwnerID=1, + GetAttributes=lambda: (attrib,), + Explode=lambda: (), + ) + document.objects["6D"] = ref + + result = await backend.block_explode("6D") + + assert result["ok"] is True and result["attribute_texts"] == ["T1"] + 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. + writes = [ + (name, tuple(value.value) if hasattr(value, "value") else value) + for name, value in text.writes + ] + names = [name for name, _ in writes] + assert ("Normal", (0.0, 0.0, -1.0)) in writes, "the ATTRIB's frame is carried" + assert names.index("Normal") < names.index("Rotation"), ( + "Rotation is the OCS angle: it only means the same thing inside the same frame" + ) + assert names.index("Normal") < names.index("InsertionPoint"), ( + "the WCS anchor is re-asserted after the frame change moves the OCS origin" + ) + assert names.index("Alignment") < names.index("TextAlignmentPoint"), ( + "ActiveX rejects an alignment point on a left-aligned text" + ) + assert dict(writes) == { + "Normal": (0.0, 0.0, -1.0), + "Thickness": 0.7, + "StyleName": "ISO", + "ObliqueAngle": 0.2618, + "ScaleFactor": 0.8, + "Backward": True, + "UpsideDown": False, + "Rotation": 0.5236, + "InsertionPoint": (-25.0, 13.0, 0.0), + "Alignment": 4, + "TextAlignmentPoint": (-20.0, 13.0, 0.0), + "Layer": "PID-TAG", + "Color": 3, + } + + +async def test_com_explode_skips_frame_members_the_attrib_does_not_expose(com): + """A bare ATTRIB (the shape the earlier fakes use) still explodes: an + absent optional member is skipped, never written as a guess, and a + left-aligned ATTRIB never gets a TextAlignmentPoint.""" + backend, document, space = com + attrib = _FakeObject( + "AcDbAttribute", + "A1", + TagString="TAG", + TextString="P-101", + InsertionPoint=(7.0, 15.2, 0.0), + Height=5.0, + Rotation=0.0, + Layer="0", + Invisible=False, + Alignment=0, + TextAlignmentPoint=(99.0, 99.0, 0.0), + ) + ref = _FakeObject( + "AcDbBlockReference", + "7E", + Name="TB", + OwnerID=1, + GetAttributes=lambda: (attrib,), + Explode=lambda: (), + ) + document.objects["7E"] = ref + + result = await backend.block_explode("7E") + + assert result["ok"] is True + names = [name for name, _ in space.texts[0].writes] + assert "Normal" not in names and "StyleName" not in names + assert "InsertionPoint" not in names, "a +Z text keeps the anchor AddText was given" + assert "TextAlignmentPoint" not in names, "left alignment has no alignment point" + assert names == ["Rotation", "Alignment", "Layer"] + + 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