diff --git a/backends/com_backend.py b/backends/com_backend.py index 244822b..0fd7ca7 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3299,11 +3299,77 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) 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(): doc = _acad_doc() ent = doc.HandleToObject(handle) - ent.Explode() - return {"ok": True, "exploded_handle": handle} + if ent.ObjectName != "AcDbBlockReference": + 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) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index f8e3fda..08e77c2 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -4521,21 +4521,65 @@ class EzdxfBackend(AutoCADBackend): 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: + """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(): ent = self._get_entity(handle) if ent.dxftype() != "INSERT": raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") msp = self._msp() - # Decompose: add individual entities to modelspace inserted = [] for sub in ent.virtual_entities(): sub_copy = sub.copy() msp.add_entity(sub_copy) 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) 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) diff --git a/discovery/aliases.py b/discovery/aliases.py index e8b9af9..e801578 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -220,13 +220,15 @@ TOOL_ALIASES: dict[str, ToolAliases] = { ), ), "block_explode": ToolAliases( - acad=("EXPLODE",), + acad=("EXPLODE", "BURST"), synonyms=( "explode", "break a block apart", "ungroup", "split a block into entities", "flatten a symbol", + "burst a block", + "explode and keep the attribute text", ), ), "block_find_references": ToolAliases( diff --git a/server.py b/server.py index 5c3e095..a257c43 100644 --- a/server.py +++ b/server.py @@ -3026,7 +3026,17 @@ async def block_explode( handle: Annotated[str, "Block reference (INSERT) entity handle"], ctx: Context = None, ) -> 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}") return await _backend(ctx).block_explode(handle) diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 32f278d..15eda99 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -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"): await backend.block_insert("NO_SUCH_BLOCK", 1, 2, attributes={"TAG": "P-1"}) 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 == []