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