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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 22:01:08 +03:00
co-authored by Claude Opus 5
parent 13851a3d20
commit 415de73137
2 changed files with 215 additions and 13 deletions
+96 -12
View File
@@ -3330,6 +3330,64 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync) 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: 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.
@@ -3337,13 +3395,25 @@ class ComBackend(AutoCADBackend):
ATTDEF entities) and discards the values; ActiveX ``Explode()`` does ATTDEF entities) and discards the values; ActiveX ``Explode()`` does
the same and, unlike the command, leaves the original reference in the same and, unlike the command, leaves the original reference in
place. This is the BURST rule instead: each ATTRIB's value, placement, place. This is the BURST rule instead: each ATTRIB's value, placement,
height, rotation and layer are read before the explode, the ATTDEFs height, rotation, layer, frame and text style are read before the
the explode returns are deleted, one ``AddText`` per ATTRIB carries the explode, the ATTDEFs the explode returns are deleted, one ``AddText``
value (an invisible ATTRIB becomes an invisible TEXT), and the per ATTRIB carries the value (an invisible ATTRIB becomes an invisible
reference itself is deleted. Unit-tested against a fake ActiveX TEXT), and the reference itself is deleted. Unit-tested against a fake
surface; ``Explode()``'s return shape is the ActiveX documented one ActiveX surface; ``Explode()``'s return shape is the ActiveX documented
(an array of the new objects) and is exercised live by the settings one (an array of the new objects) and is exercised live by the
smoke. 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 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
@@ -3379,6 +3449,7 @@ class ComBackend(AutoCADBackend):
"rotation": float(attr.Rotation), "rotation": float(attr.Rotation),
"layer": str(attr.Layer), "layer": str(attr.Layer),
"invisible": invisible, "invisible": invisible,
"members": self._capture_attrib_text_members(attr),
} }
) )
exploded = ent.Explode() exploded = ent.Explode()
@@ -3391,13 +3462,26 @@ class ComBackend(AutoCADBackend):
attribute_texts = [] attribute_texts = []
for item in captured: for item in captured:
ins = item["insertion"] ins = item["insertion"]
text = owner.AddText( point = _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0)
item["text"], text = owner.AddText(item["text"], point, item["height"])
_apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0), members = item["members"]
item["height"], for name in self._ATTRIB_FRAME_MEMBERS:
) if name in members:
setattr(text, name, members[name])
text.Rotation = item["rotation"] 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"] text.Layer = item["layer"]
if "Color" in members:
text.Color = members["Color"]
if item["invisible"]: if item["invisible"]:
text.Visible = False text.Visible = False
attribute_texts.append(str(text.Handle)) attribute_texts.append(str(text.Handle))
+119 -1
View File
@@ -109,6 +109,7 @@ class _FakeSpace:
self.calls: list[tuple[str, tuple]] = [] self.calls: list[tuple[str, tuple]] = []
self.attributes = attributes or {} self.attributes = attributes or {}
self.inserted: list[_FakeObject] = [] self.inserted: list[_FakeObject] = []
self.texts: list[_FakeObject] = []
self.Name = name self.Name = name
self.IsLayout = True self.IsLayout = True
@@ -126,7 +127,9 @@ class _FakeSpace:
def AddText(self, text, point, height): def AddText(self, text, point, height):
self.calls.append(("AddText", (text, tuple(point.value), 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 @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 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): 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