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:
co-authored by
Claude Opus 5
parent
13851a3d20
commit
415de73137
+96
-12
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user