fix(task-2): block_explode keeps the ATTRIB frame and writes into the owner layout

Two silent wrong-place defects in the BURST path. An ATTRIB is an OCS
entity, so the TEXT that replaces it now carries extrusion/thickness with
insert/align_point; a mirrored reference (ATTRIB reflected to (0,0,-1),
INSERT +Z with yscale=-1) put the tag 50 units across the mirror axis with
a clean audit. Members and TEXTs now go into the INSERT's owner layout
(ezdxf get_layout, COM ObjectIdToObject(OwnerID)) instead of the current
space: a sheet title block exploded while Model was current duplicated
geometry into model space and then died on delete_entity headless, and on
COM landed the tag text in model space with ok: True. A reference nested
in a block definition is refused before any write on both engines.

Five regression tests (mirrored INSERT, sheet-owned INSERT with Model
current, nested INSERT, and the COM owner-block / nested-refusal call
shapes) fail on the previous commit and pass here.

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 21:43:08 +03:00
co-authored by Claude Opus 5
parent 19610b8f05
commit 13851a3d20
4 changed files with 225 additions and 11 deletions
+42 -2
View File
@@ -193,6 +193,38 @@ def _msp():
return doc.ModelSpace
def _owner_layout_block(doc, ent, handle):
"""The block table record that owns ``ent``, refused unless it is a layout.
``Explode()`` places the members in the owner space, so anything this
method adds alongside them must go there too — never ``ActiveLayout``,
which is whatever tab the user happens to have open. An owner that is a
block definition means ``ent`` is a nested reference; exploding it in place
would redefine the block under every other reference, so it is refused
before any call is dispatched. An owner that cannot be read at all is
refused too rather than guessed.
"""
try:
owner = doc.ObjectIdToObject(ent.OwnerID)
is_layout = bool(owner.IsLayout)
except Exception as exc:
raise RuntimeError(
f"Entity {handle} has no resolvable owner layout ({exc}); refusing to "
"explode into a guessed space"
) from exc
if not is_layout:
try:
name = str(owner.Name)
except Exception:
name = "?"
raise RuntimeError(
f"Entity {handle} is nested inside block definition {name!r}; explode the "
"outer reference instead (exploding here would redefine the block under "
"every other reference)"
)
return owner
def _require_block_defined(doc, name):
"""``doc.Blocks.Item(name)``, or a ``ValueError`` before ``InsertBlock`` runs.
@@ -3312,6 +3344,14 @@ class ComBackend(AutoCADBackend):
surface; ``Explode()``'s return shape is the ActiveX documented one
(an array of the new objects) and is exercised live by the settings
smoke.
The TEXTs go into the reference's *owner* block
(``ObjectIdToObject(OwnerID)``), which is where ``Explode()`` puts the
members; ``HandleToObject`` resolves a handle in any layout, so a title
block on a sheet exploded while Model was active used to get its tag
text in model space with ``ok: True``. A reference nested inside a
block definition (owner ``IsLayout`` false) is refused before the
explode is dispatched.
"""
def _sync():
@@ -3319,6 +3359,7 @@ class ComBackend(AutoCADBackend):
ent = doc.HandleToObject(handle)
if ent.ObjectName != "AcDbBlockReference":
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
owner = _owner_layout_block(doc, ent, handle)
captured = []
try:
attrs = ent.GetAttributes()
@@ -3347,11 +3388,10 @@ class ComBackend(AutoCADBackend):
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(
text = owner.AddText(
item["text"],
_apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0),
item["height"],
+42 -2
View File
@@ -4521,8 +4521,38 @@ class EzdxfBackend(AutoCADBackend):
return await self._async(_sync)
@staticmethod
def _owner_layout_for_explode(ent, handle: str):
"""The layout that owns ``ent`` — model space or a paper-space layout.
Raises before any write when the owner is a block definition (a nested
reference) or cannot be resolved at all; guessing the current space
instead is how geometry ends up duplicated into the wrong tab.
"""
try:
layout = ent.get_layout()
except Exception as exc:
raise RuntimeError(
f"Entity {handle} has no resolvable owner layout ({exc}); refusing to "
"explode into a guessed space"
) from exc
if layout is None or not layout.is_any_layout:
owner = getattr(layout, "name", None) or ent.dxf.get("owner", "?")
raise RuntimeError(
f"Entity {handle} is nested inside block definition {owner!r}; explode "
"the outer reference instead (exploding here would redefine the block "
"under every other reference)"
)
return layout
#: ATTRIB DXF attributes carried onto the TEXT that replaces it on explode.
#: ``insert``/``align_point`` are already WCS on an attached ATTRIB.
#: An ATTRIB is an OCS entity: ``insert``/``align_point`` are numbers in the
#: plane of its own ``extrusion``, so the frame travels with them. Without
#: it the TEXT took the OCS numbers with the default +Z frame, and a
#: mirrored reference (ezdxf reflects the ATTRIB to extrusion ``(0, 0, -1)``
#: while the INSERT keeps +Z with ``yscale=-1``) put the tag 50 units away
#: on the other side of the mirror axis — audit clean, ``plane_normal``
#: None, nothing said. Same numbers in the same frame is the same place.
_ATTRIB_TO_TEXT = (
"layer",
"color",
@@ -4536,6 +4566,8 @@ class EzdxfBackend(AutoCADBackend):
"text_generation_flag",
"insert",
"align_point",
"extrusion",
"thickness",
)
async def block_explode(self, handle) -> dict:
@@ -4548,13 +4580,21 @@ class EzdxfBackend(AutoCADBackend):
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``.
The members and the TEXTs go into the INSERT's *owner* layout, not the
current one: ``_get_entity`` resolves a handle in any layout, so a title
block on a sheet exploded while Model was current used to have its
geometry copied into model space and then die on ``delete_entity``
with the reference still alive on the sheet. A reference nested inside
a block definition is refused before anything is written — exploding
it there would redefine the block under every other reference.
"""
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()
msp = self._owner_layout_for_explode(ent, handle)
inserted = []
for sub in ent.virtual_entities():
sub_copy = sub.copy()
+8 -4
View File
@@ -3032,10 +3032,14 @@ async def block_explode(
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.
and drop the values. Everything lands in the layout that owns the
reference (model space or its paper-space sheet), whichever tab is
current. Returns `inserted_handles` (the geometry), `attribute_texts`
(one TEXT handle per attribute), `exploded_handle` and `backend`.
Refused, with nothing written: a handle that is not a block reference,
and a reference nested inside a block definition (explode the outer
reference instead). Not undoable except through `drawing_undo` / a
transaction.
"""
await ctx.warning(f"Exploding block reference {handle}")
return await _backend(ctx).block_explode(handle)
+133 -3
View File
@@ -103,12 +103,14 @@ class _FakeLayers:
class _FakeSpace:
"""The active layout block: records InsertBlock / AddText and hands back styled objects."""
"""A layout block: records InsertBlock / AddText and hands back styled objects."""
def __init__(self, attributes: dict[str, str] | None = None):
def __init__(self, attributes: dict[str, str] | None = None, name: str = "*Model_Space"):
self.calls: list[tuple[str, tuple]] = []
self.attributes = attributes or {}
self.inserted: list[_FakeObject] = []
self.Name = name
self.IsLayout = True
def InsertBlock(self, point, name, sx, sy, sz, rotation):
self.calls.append(("InsertBlock", (tuple(point.value), name, sx, sy, sz, rotation)))
@@ -132,9 +134,14 @@ def com(monkeypatch):
"""A ComBackend over a recording fake: ``document`` and ``space`` are inspectable."""
from backends import com_backend as module
document = types.SimpleNamespace(Blocks=_FakeBlocks("TB"), Layers=_FakeLayers(), objects={})
document = types.SimpleNamespace(
Blocks=_FakeBlocks("TB"), Layers=_FakeLayers(), objects={}, owners={}
)
document.HandleToObject = lambda handle: document.objects[handle]
# ``OwnerID`` -> block table record, the way ``ObjectIdToObject`` resolves it live.
document.ObjectIdToObject = lambda object_id: document.owners[object_id]
space = _FakeSpace()
document.owners[1] = space # the active layout; a sheet registers itself under 2
monkeypatch.setattr(module, "_acad_doc", lambda: document)
monkeypatch.setattr(module, "_msp", lambda: space)
monkeypatch.setattr(module, "_regen", lambda: None)
@@ -308,6 +315,7 @@ async def test_com_explode_deletes_attdefs_adds_text_per_attrib_and_deletes_the_
"AcDbBlockReference",
"2F",
Name="TB",
OwnerID=1,
GetAttributes=lambda: (visible, hidden),
Explode=lambda: (line, attdef),
)
@@ -337,3 +345,125 @@ async def test_com_explode_refuses_a_non_insert_before_any_call(com):
with pytest.raises(RuntimeError, match="not a block reference"):
await backend.block_explode("3A")
assert space.calls == []
async def test_com_explode_adds_the_text_to_the_owner_block_not_the_active_layout(com):
"""ActiveX ``Explode()`` places the members in the owner space; the tag
TEXT must land beside them, not in whatever tab is active."""
backend, document, space = com
sheet = _FakeSpace(name="Sheet")
document.owners[2] = sheet
title = _FakeObject(
"AcDbAttribute",
"A1",
TagString="TITLE",
TextString="GEAR-01",
InsertionPoint=(100.0, 50.0, 0.0),
Height=3.5,
Rotation=0.0,
Layer="TITLEBLOCK",
Invisible=False,
)
line = _FakeObject("AcDbLine", "L1")
ref = _FakeObject(
"AcDbBlockReference",
"4B",
Name="TB",
OwnerID=2,
GetAttributes=lambda: (title,),
Explode=lambda: (line,),
)
document.objects["4B"] = ref
result = await backend.block_explode("4B")
assert result["ok"] is True and result["inserted_handles"] == ["L1"]
assert sheet.calls == [("AddText", ("GEAR-01", (100.0, 50.0, 0.0), 3.5))]
assert space.calls == [], "nothing may be written into the active layout"
assert ref.deleted is True
async def test_com_explode_refuses_a_nested_reference_before_explode(com):
backend, document, space = com
document.owners[3] = _FakeBlock("OUTER") # a block definition, IsLayout False
dispatched = []
def _explode():
dispatched.append("Explode")
return ()
ref = _FakeObject(
"AcDbBlockReference",
"5C",
Name="TB",
OwnerID=3,
GetAttributes=lambda: (),
Explode=_explode,
)
document.objects["5C"] = ref
with pytest.raises(RuntimeError, match="nested inside block definition 'OUTER'"):
await backend.block_explode("5C")
assert dispatched == [] and space.calls == [] and ref.deleted is False
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
that replaces it must carry the frame, or it lands on the other side
of the mirror axis with a clean audit."""
from backends.ocs import to_wcs_2d
blk = backend._doc.blocks.new(name="SYM")
blk.add_line((0, 0), (10, 0))
blk.add_attdef("TAG", insert=(5, 3), text="", dxfattribs={"height": 2.5})
ref = await backend.block_insert("SYM", 20, 10, attributes={"TAG": "P-101"})
mirrored = await backend.entity_mirror(ref.handle, 0, 0, 0, 1, delete_original=True)
raw = backend._doc.entitydb[mirrored.handle]
(attrib,) = raw.attribs
assert tuple(attrib.dxf.extrusion) == (0.0, 0.0, -1.0), "the premise: a reflected ATTRIB"
expected = to_wcs_2d(attrib.dxf.extrusion, *attrib.dxf.insert)
assert expected == pytest.approx([-25.0, 13.0])
result = await backend.block_explode(mirrored.handle)
(line_handle,) = result["inserted_handles"]
(text_handle,) = result["attribute_texts"]
line = await backend.entity_get(line_handle)
text = await backend.entity_get(text_handle)
assert text.properties["insertion"] == pytest.approx(expected)
assert text.properties["insertion"][0] < 0 and line.properties["start"][0] < 0, (
"tag and geometry stay on the same side of the mirror axis"
)
assert backend._doc.audit().errors == []
async def test_explode_writes_into_the_inserts_owner_layout_not_the_current_one(backend):
_define_tagged_block(backend)
await backend.layout_create("Sheet")
await backend.layout_set_current("Sheet")
ref = await backend.block_insert("TB", 100, 50, attributes={"TAG": "GEAR-01"})
await backend.layout_set_current("Model")
model_before = len(list(backend._doc.modelspace()))
result = await backend.block_explode(ref.handle)
assert result["ok"] is True
sheet = backend._doc.layouts.get("Sheet")
assert sorted(e.dxftype() for e in sheet) == ["LINE", "TEXT"]
assert len(list(backend._doc.modelspace())) == model_before, "model space untouched"
for handle in result["inserted_handles"] + result["attribute_texts"]:
assert backend._doc.entitydb[handle].get_layout().name == "Sheet"
with pytest.raises(RuntimeError, match="not found"):
await backend.entity_get(ref.handle)
assert backend._doc.audit().errors == []
async def test_explode_refuses_a_nested_reference_before_writing(backend):
_define_tagged_block(backend)
outer = backend._doc.blocks.new(name="OUTER")
nested = outer.add_blockref("TB", (0, 0))
before = (len(list(backend._doc.modelspace())), len(list(outer)))
with pytest.raises(RuntimeError, match="nested inside block definition 'OUTER'"):
await backend.block_explode(nested.dxf.handle)
assert (len(list(backend._doc.modelspace())), len(list(outer))) == before
assert backend._doc.entitydb[nested.dxf.handle].is_alive