fix(blocks): block_explode keeps attribute text as TEXT (BURST rule) on both engines
virtual_entities() yields geometry only, so an exploded tagged symbol lost its tag value silently. Every attached ATTRIB now becomes a TEXT at the same WCS placement/height/rotation/style/layer (invisible stays invisible), reported as attribute_texts. COM reads GetAttributes before Explode, deletes the ATTDEF placeholders Explode returns, AddText per value, and deletes the reference ActiveX Explode leaves behind. 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
b9510bf377
commit
19610b8f05
+68
-2
@@ -3299,11 +3299,77 @@ class ComBackend(AutoCADBackend):
|
|||||||
return await self._run(_sync)
|
return await self._run(_sync)
|
||||||
|
|
||||||
async def block_explode(self, handle) -> dict:
|
async def block_explode(self, handle) -> dict:
|
||||||
|
"""Explode an INSERT through ActiveX; ATTRIB values survive as TEXT.
|
||||||
|
|
||||||
|
AutoCAD's EXPLODE keeps the attribute *definitions* (the tag names as
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
def _sync():
|
def _sync():
|
||||||
doc = _acad_doc()
|
doc = _acad_doc()
|
||||||
ent = doc.HandleToObject(handle)
|
ent = doc.HandleToObject(handle)
|
||||||
ent.Explode()
|
if ent.ObjectName != "AcDbBlockReference":
|
||||||
return {"ok": True, "exploded_handle": handle}
|
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exploded = ent.Explode()
|
||||||
|
inserted = []
|
||||||
|
for obj in exploded or ():
|
||||||
|
if obj.ObjectName == "AcDbAttributeDefinition":
|
||||||
|
obj.Delete() # the tag placeholder EXPLODE leaves; the value is below
|
||||||
|
continue
|
||||||
|
inserted.append(str(obj.Handle))
|
||||||
|
mspace = _msp()
|
||||||
|
attribute_texts = []
|
||||||
|
for item in captured:
|
||||||
|
ins = item["insertion"]
|
||||||
|
text = mspace.AddText(
|
||||||
|
item["text"],
|
||||||
|
_apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0),
|
||||||
|
item["height"],
|
||||||
|
)
|
||||||
|
text.Rotation = item["rotation"]
|
||||||
|
text.Layer = item["layer"]
|
||||||
|
if item["invisible"]:
|
||||||
|
text.Visible = False
|
||||||
|
attribute_texts.append(str(text.Handle))
|
||||||
|
ent.Delete()
|
||||||
|
_regen()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"exploded_handle": handle,
|
||||||
|
"inserted_handles": inserted,
|
||||||
|
"attribute_texts": attribute_texts,
|
||||||
|
"backend": "com",
|
||||||
|
}
|
||||||
|
|
||||||
return await self._run(_sync)
|
return await self._run(_sync)
|
||||||
|
|
||||||
|
|||||||
@@ -4521,21 +4521,65 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._async(_sync)
|
return await self._async(_sync)
|
||||||
|
|
||||||
|
#: ATTRIB DXF attributes carried onto the TEXT that replaces it on explode.
|
||||||
|
#: ``insert``/``align_point`` are already WCS on an attached ATTRIB.
|
||||||
|
_ATTRIB_TO_TEXT = (
|
||||||
|
"layer",
|
||||||
|
"color",
|
||||||
|
"style",
|
||||||
|
"height",
|
||||||
|
"rotation",
|
||||||
|
"width",
|
||||||
|
"oblique",
|
||||||
|
"halign",
|
||||||
|
"valign",
|
||||||
|
"text_generation_flag",
|
||||||
|
"insert",
|
||||||
|
"align_point",
|
||||||
|
)
|
||||||
|
|
||||||
async def block_explode(self, handle) -> dict:
|
async def block_explode(self, handle) -> dict:
|
||||||
|
"""Explode an INSERT into its members; ATTRIB values survive as TEXT.
|
||||||
|
|
||||||
|
``virtual_entities()`` yields the definition's geometry only — no
|
||||||
|
ATTDEF, no ATTRIB — so an exploded tagged symbol used to lose its tag
|
||||||
|
text silently. This is the BURST rule (Express Tools): every attached
|
||||||
|
ATTRIB becomes a TEXT at the same WCS placement, height, rotation,
|
||||||
|
style and layer, carrying the *value*; an invisible ATTRIB becomes an
|
||||||
|
invisible TEXT rather than appearing. The new handles are reported as
|
||||||
|
``attribute_texts``, separate from the geometry's ``inserted_handles``.
|
||||||
|
"""
|
||||||
|
|
||||||
def _sync():
|
def _sync():
|
||||||
ent = self._get_entity(handle)
|
ent = self._get_entity(handle)
|
||||||
if ent.dxftype() != "INSERT":
|
if ent.dxftype() != "INSERT":
|
||||||
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||||
msp = self._msp()
|
msp = self._msp()
|
||||||
# Decompose: add individual entities to modelspace
|
|
||||||
inserted = []
|
inserted = []
|
||||||
for sub in ent.virtual_entities():
|
for sub in ent.virtual_entities():
|
||||||
sub_copy = sub.copy()
|
sub_copy = sub.copy()
|
||||||
msp.add_entity(sub_copy)
|
msp.add_entity(sub_copy)
|
||||||
inserted.append(sub_copy.dxf.handle)
|
inserted.append(sub_copy.dxf.handle)
|
||||||
|
attribute_texts = []
|
||||||
|
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)
|
||||||
|
if attrib.is_invisible:
|
||||||
|
text.dxf.invisible = 1
|
||||||
|
attribute_texts.append(text.dxf.handle)
|
||||||
msp.delete_entity(ent)
|
msp.delete_entity(ent)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
return {"ok": True, "inserted_handles": inserted}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"exploded_handle": handle,
|
||||||
|
"inserted_handles": inserted,
|
||||||
|
"attribute_texts": attribute_texts,
|
||||||
|
"backend": "ezdxf",
|
||||||
|
}
|
||||||
|
|
||||||
return await self._async(_sync)
|
return await self._async(_sync)
|
||||||
|
|
||||||
|
|||||||
@@ -220,13 +220,15 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
"block_explode": ToolAliases(
|
"block_explode": ToolAliases(
|
||||||
acad=("EXPLODE",),
|
acad=("EXPLODE", "BURST"),
|
||||||
synonyms=(
|
synonyms=(
|
||||||
"explode",
|
"explode",
|
||||||
"break a block apart",
|
"break a block apart",
|
||||||
"ungroup",
|
"ungroup",
|
||||||
"split a block into entities",
|
"split a block into entities",
|
||||||
"flatten a symbol",
|
"flatten a symbol",
|
||||||
|
"burst a block",
|
||||||
|
"explode and keep the attribute text",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"block_find_references": ToolAliases(
|
"block_find_references": ToolAliases(
|
||||||
|
|||||||
@@ -3026,7 +3026,17 @@ async def block_explode(
|
|||||||
handle: Annotated[str, "Block reference (INSERT) entity handle"],
|
handle: Annotated[str, "Block reference (INSERT) entity handle"],
|
||||||
ctx: Context = None,
|
ctx: Context = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Explode a block reference into its individual component entities."""
|
"""Explode a block reference into its component entities, keeping its
|
||||||
|
attribute text (the Express Tools BURST rule, on both engines).
|
||||||
|
|
||||||
|
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. Returns `inserted_handles` (the geometry),
|
||||||
|
`attribute_texts` (one TEXT handle per attribute), `exploded_handle` and
|
||||||
|
`backend`. Refused: a handle that is not a block reference (nothing is
|
||||||
|
written). Not undoable except through `drawing_undo` / a transaction.
|
||||||
|
"""
|
||||||
await ctx.warning(f"Exploding block reference {handle}")
|
await ctx.warning(f"Exploding block reference {handle}")
|
||||||
return await _backend(ctx).block_explode(handle)
|
return await _backend(ctx).block_explode(handle)
|
||||||
|
|
||||||
|
|||||||
@@ -218,3 +218,122 @@ async def test_com_block_insert_shares_the_same_gate(com):
|
|||||||
with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
|
with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
|
||||||
await backend.block_insert("NO_SUCH_BLOCK", 1, 2, attributes={"TAG": "P-1"})
|
await backend.block_insert("NO_SUCH_BLOCK", 1, 2, attributes={"TAG": "P-1"})
|
||||||
assert space.calls == []
|
assert space.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task 2: block_explode keeps ATTRIB text ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explode_emits_a_text_per_attrib_at_the_attribs_placement(backend):
|
||||||
|
from ezdxf.enums import TextEntityAlignment
|
||||||
|
|
||||||
|
blk = backend._doc.blocks.new(name="TB2")
|
||||||
|
blk.add_line((0, 0), (10, 0))
|
||||||
|
tag = blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
|
||||||
|
tag.set_placement((0, 3), align=TextEntityAlignment.CENTER)
|
||||||
|
hidden = blk.add_attdef("LINK", insert=(0, -3), text="L-1", dxfattribs={"height": 2.0})
|
||||||
|
hidden.is_invisible = True
|
||||||
|
ref = await backend.block_insert(
|
||||||
|
"TB2", 10, 10, scale_x=2.0, scale_y=2.0, rotation=30.0, attributes={"TAG": "P-101"}
|
||||||
|
)
|
||||||
|
raw_ref = backend._doc.entitydb[ref.handle]
|
||||||
|
expected = {a.dxf.tag: (tuple(a.dxf.insert)[:2], a.dxf.height) for a in raw_ref.attribs}
|
||||||
|
|
||||||
|
result = await backend.block_explode(ref.handle)
|
||||||
|
|
||||||
|
assert result["ok"] is True and result["exploded_handle"] == ref.handle
|
||||||
|
assert result["backend"] == "ezdxf"
|
||||||
|
assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"]
|
||||||
|
assert len(result["attribute_texts"]) == 2
|
||||||
|
texts = {}
|
||||||
|
for handle in result["attribute_texts"]:
|
||||||
|
info = await backend.entity_get(handle)
|
||||||
|
assert info.type == "TEXT"
|
||||||
|
texts[info.properties["text"]] = info
|
||||||
|
tag_text = texts["P-101"]
|
||||||
|
assert tuple(tag_text.properties["insertion"]) == pytest.approx(expected["TAG"][0])
|
||||||
|
assert tag_text.properties["height"] == expected["TAG"][1] == 5.0
|
||||||
|
assert tag_text.properties["rotation"] == pytest.approx(30.0)
|
||||||
|
raw = backend._doc.entitydb[tag_text.handle]
|
||||||
|
assert raw.get_align_enum().name == "CENTER", "alignment survives the conversion"
|
||||||
|
assert raw.dxf.invisible == 0
|
||||||
|
assert backend._doc.entitydb[texts["L-1"].handle].dxf.invisible == 1
|
||||||
|
with pytest.raises(RuntimeError, match="not found"):
|
||||||
|
await backend.entity_get(ref.handle)
|
||||||
|
assert backend._doc.audit().errors == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explode_without_attribs_reports_an_empty_list(backend):
|
||||||
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||||
|
await backend.block_create_from_entities("PLAIN", [line.handle])
|
||||||
|
ref = await backend.block_insert("PLAIN", 0, 0)
|
||||||
|
result = await backend.block_explode(ref.handle)
|
||||||
|
assert result["attribute_texts"] == [] and len(result["inserted_handles"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explode_refuses_a_non_insert_without_writing(backend):
|
||||||
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
||||||
|
before = len(list(backend._msp()))
|
||||||
|
with pytest.raises(RuntimeError, match="not a block reference"):
|
||||||
|
await backend.block_explode(line.handle)
|
||||||
|
assert len(list(backend._msp())) == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_explode_deletes_attdefs_adds_text_per_attrib_and_deletes_the_reference(com):
|
||||||
|
backend, document, space = com
|
||||||
|
attdef = _FakeObject("AcDbAttributeDefinition", "D1")
|
||||||
|
line = _FakeObject("AcDbLine", "L1")
|
||||||
|
visible = _FakeObject(
|
||||||
|
"AcDbAttribute",
|
||||||
|
"A1",
|
||||||
|
TagString="TAG",
|
||||||
|
TextString="P-101",
|
||||||
|
InsertionPoint=(7.0, 15.2, 0.0),
|
||||||
|
Height=5.0,
|
||||||
|
Rotation=0.5236,
|
||||||
|
Layer="PID-TAG",
|
||||||
|
Invisible=False,
|
||||||
|
)
|
||||||
|
hidden = _FakeObject(
|
||||||
|
"AcDbAttribute",
|
||||||
|
"A2",
|
||||||
|
TagString="LINK",
|
||||||
|
TextString="L-1",
|
||||||
|
InsertionPoint=(13.0, 4.8, 0.0),
|
||||||
|
Height=4.0,
|
||||||
|
Rotation=0.5236,
|
||||||
|
Layer="0",
|
||||||
|
Invisible=True,
|
||||||
|
)
|
||||||
|
ref = _FakeObject(
|
||||||
|
"AcDbBlockReference",
|
||||||
|
"2F",
|
||||||
|
Name="TB",
|
||||||
|
GetAttributes=lambda: (visible, hidden),
|
||||||
|
Explode=lambda: (line, attdef),
|
||||||
|
)
|
||||||
|
document.objects["2F"] = ref
|
||||||
|
|
||||||
|
result = await backend.block_explode("2F")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"ok": True,
|
||||||
|
"exploded_handle": "2F",
|
||||||
|
"inserted_handles": ["L1"],
|
||||||
|
"attribute_texts": ["T1", "T2"],
|
||||||
|
"backend": "com",
|
||||||
|
}
|
||||||
|
assert attdef.deleted is True, "the value-less ATTDEF placeholder is removed"
|
||||||
|
assert line.deleted is False
|
||||||
|
assert ref.deleted is True, "ActiveX Explode leaves the reference; BURST removes it"
|
||||||
|
assert [c for c in space.calls] == [
|
||||||
|
("AddText", ("P-101", (7.0, 15.2, 0.0), 5.0)),
|
||||||
|
("AddText", ("L-1", (13.0, 4.8, 0.0), 4.0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_com_explode_refuses_a_non_insert_before_any_call(com):
|
||||||
|
backend, document, space = com
|
||||||
|
document.objects["3A"] = _FakeObject("AcDbLine", "3A")
|
||||||
|
with pytest.raises(RuntimeError, match="not a block reference"):
|
||||||
|
await backend.block_explode("3A")
|
||||||
|
assert space.calls == []
|
||||||
|
|||||||
Reference in New Issue
Block a user