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:
co-authored by
Claude Opus 5
parent
19610b8f05
commit
13851a3d20
+42
-2
@@ -193,6 +193,38 @@ def _msp():
|
|||||||
return doc.ModelSpace
|
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):
|
def _require_block_defined(doc, name):
|
||||||
"""``doc.Blocks.Item(name)``, or a ``ValueError`` before ``InsertBlock`` runs.
|
"""``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
|
surface; ``Explode()``'s return shape is the ActiveX documented one
|
||||||
(an array of the new objects) and is exercised live by the settings
|
(an array of the new objects) and is exercised live by the settings
|
||||||
smoke.
|
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():
|
def _sync():
|
||||||
@@ -3319,6 +3359,7 @@ class ComBackend(AutoCADBackend):
|
|||||||
ent = doc.HandleToObject(handle)
|
ent = doc.HandleToObject(handle)
|
||||||
if ent.ObjectName != "AcDbBlockReference":
|
if ent.ObjectName != "AcDbBlockReference":
|
||||||
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
|
||||||
|
owner = _owner_layout_block(doc, ent, handle)
|
||||||
captured = []
|
captured = []
|
||||||
try:
|
try:
|
||||||
attrs = ent.GetAttributes()
|
attrs = ent.GetAttributes()
|
||||||
@@ -3347,11 +3388,10 @@ class ComBackend(AutoCADBackend):
|
|||||||
obj.Delete() # the tag placeholder EXPLODE leaves; the value is below
|
obj.Delete() # the tag placeholder EXPLODE leaves; the value is below
|
||||||
continue
|
continue
|
||||||
inserted.append(str(obj.Handle))
|
inserted.append(str(obj.Handle))
|
||||||
mspace = _msp()
|
|
||||||
attribute_texts = []
|
attribute_texts = []
|
||||||
for item in captured:
|
for item in captured:
|
||||||
ins = item["insertion"]
|
ins = item["insertion"]
|
||||||
text = mspace.AddText(
|
text = owner.AddText(
|
||||||
item["text"],
|
item["text"],
|
||||||
_apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0),
|
_apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0),
|
||||||
item["height"],
|
item["height"],
|
||||||
|
|||||||
@@ -4521,8 +4521,38 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._async(_sync)
|
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.
|
#: 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 = (
|
_ATTRIB_TO_TEXT = (
|
||||||
"layer",
|
"layer",
|
||||||
"color",
|
"color",
|
||||||
@@ -4536,6 +4566,8 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"text_generation_flag",
|
"text_generation_flag",
|
||||||
"insert",
|
"insert",
|
||||||
"align_point",
|
"align_point",
|
||||||
|
"extrusion",
|
||||||
|
"thickness",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def block_explode(self, handle) -> dict:
|
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
|
style and layer, carrying the *value*; an invisible ATTRIB becomes an
|
||||||
invisible TEXT rather than appearing. The new handles are reported as
|
invisible TEXT rather than appearing. The new handles are reported as
|
||||||
``attribute_texts``, separate from the geometry's ``inserted_handles``.
|
``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():
|
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._owner_layout_for_explode(ent, handle)
|
||||||
inserted = []
|
inserted = []
|
||||||
for sub in ent.virtual_entities():
|
for sub in ent.virtual_entities():
|
||||||
sub_copy = sub.copy()
|
sub_copy = sub.copy()
|
||||||
|
|||||||
@@ -3032,10 +3032,14 @@ async def block_explode(
|
|||||||
Every attached ATTRIB becomes a TEXT with the same value, placement,
|
Every attached ATTRIB becomes a TEXT with the same value, placement,
|
||||||
height, rotation and layer (an invisible attribute becomes an invisible
|
height, rotation and layer (an invisible attribute becomes an invisible
|
||||||
TEXT); AutoCAD's plain EXPLODE would keep only the tag-name placeholders
|
TEXT); AutoCAD's plain EXPLODE would keep only the tag-name placeholders
|
||||||
and drop the values. Returns `inserted_handles` (the geometry),
|
and drop the values. Everything lands in the layout that owns the
|
||||||
`attribute_texts` (one TEXT handle per attribute), `exploded_handle` and
|
reference (model space or its paper-space sheet), whichever tab is
|
||||||
`backend`. Refused: a handle that is not a block reference (nothing is
|
current. Returns `inserted_handles` (the geometry), `attribute_texts`
|
||||||
written). Not undoable except through `drawing_undo` / a transaction.
|
(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}")
|
await ctx.warning(f"Exploding block reference {handle}")
|
||||||
return await _backend(ctx).block_explode(handle)
|
return await _backend(ctx).block_explode(handle)
|
||||||
|
|||||||
@@ -103,12 +103,14 @@ class _FakeLayers:
|
|||||||
|
|
||||||
|
|
||||||
class _FakeSpace:
|
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.calls: list[tuple[str, tuple]] = []
|
||||||
self.attributes = attributes or {}
|
self.attributes = attributes or {}
|
||||||
self.inserted: list[_FakeObject] = []
|
self.inserted: list[_FakeObject] = []
|
||||||
|
self.Name = name
|
||||||
|
self.IsLayout = True
|
||||||
|
|
||||||
def InsertBlock(self, point, name, sx, sy, sz, rotation):
|
def InsertBlock(self, point, name, sx, sy, sz, rotation):
|
||||||
self.calls.append(("InsertBlock", (tuple(point.value), 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."""
|
"""A ComBackend over a recording fake: ``document`` and ``space`` are inspectable."""
|
||||||
from backends import com_backend as module
|
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]
|
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()
|
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, "_acad_doc", lambda: document)
|
||||||
monkeypatch.setattr(module, "_msp", lambda: space)
|
monkeypatch.setattr(module, "_msp", lambda: space)
|
||||||
monkeypatch.setattr(module, "_regen", lambda: None)
|
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",
|
"AcDbBlockReference",
|
||||||
"2F",
|
"2F",
|
||||||
Name="TB",
|
Name="TB",
|
||||||
|
OwnerID=1,
|
||||||
GetAttributes=lambda: (visible, hidden),
|
GetAttributes=lambda: (visible, hidden),
|
||||||
Explode=lambda: (line, attdef),
|
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"):
|
with pytest.raises(RuntimeError, match="not a block reference"):
|
||||||
await backend.block_explode("3A")
|
await backend.block_explode("3A")
|
||||||
assert space.calls == []
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user