diff --git a/backends/com_backend.py b/backends/com_backend.py index 0fd7ca7..8926ce4 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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"], diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 08e77c2..3cccfb8 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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() diff --git a/server.py b/server.py index a257c43..32e8e6d 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 15eda99..b32a1e1 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -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