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)
|
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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user