diff --git a/backends/com_backend.py b/backends/com_backend.py index 6036819..953ef9f 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -858,6 +858,9 @@ class ComBackend(AutoCADBackend): False, reason="window_capture_has_no_render_to_label" ), "measure_area_acis": FeatureCapability(True, "native"), + "explode_opaque_members": FeatureCapability( + True, "native", reason="autocad_explode;ole_and_proxy_members_unverified_live" + ), "ocs_normalized": FeatureCapability( True, "activex_wcs", diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index fd44754..cec275a 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1027,6 +1027,13 @@ class EzdxfBackend(AutoCADBackend): "measure_area_acis": FeatureCapability( False, reason="acis_evaluation_requires_live_autocad" ), + "explode_opaque_members": FeatureCapability( + False, + reason=( + "ezdxf_cannot_transform:OLE2FRAME,VIEWPORT," + "ACAD_PROXY_ENTITY_without_proxy_graphic;refused_before_writing" + ), + ), "ocs_normalized": FeatureCapability( True, "wcs", @@ -4631,6 +4638,45 @@ class EzdxfBackend(AutoCADBackend): "multi-insert cannot be exploded" ) + @staticmethod + def _explodable_members(ent, handle: str) -> list: + """The reference's members as virtual entities, or a typed refusal + before any write when ezdxf would drop one. + + ``virtual_entities()`` skips every member it cannot copy (OLE2FRAME) + or transform (VIEWPORT) with a debug-level log only, and an + ACAD_PROXY_ENTITY contributes its proxy graphic -- nothing at all when + it carries none -- so a title block's OLE logo or a vertical product's + proxy geometry used to vanish with ``ok: True`` and a clean audit. The + list is materialized here so the skips are known before the layout is + touched; AutoCAD explodes all of these natively. + """ + skipped: list[str] = [] + + def _collect(entity, reason): + entity_handle = entity.dxf.get("handle") or "?" + skipped.append(f"{entity.dxftype()} {entity_handle} ({reason})") + + members = list(ent.virtual_entities(skipped_entity_callback=_collect)) + block = ent.block() + for member in block if block is not None else (): + if member.dxftype() != "ACAD_PROXY_ENTITY": + continue + if not any(True for _ in member.virtual_entities()): + skipped.append(f"ACAD_PROXY_ENTITY {member.dxf.handle} (no proxy graphic)") + if skipped: + name = ent.dxf.get("name", "?") + raise UnsupportedCapabilityError( + "explode_opaque_members", + f"block_explode: entity {handle} references block {name!r} whose " + f"members {', '.join(skipped)} cannot be exploded by the headless " + "ezdxf backend (it cannot transform them; exploding would delete " + "them). Nothing was written. Switch to the live COM backend " + "(AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD), which hands " + "the explode to AutoCAD.", + ) + return members + async def block_explode(self, handle) -> dict: """Explode an INSERT into its members; ATTRIB values survive as TEXT. @@ -4667,7 +4713,12 @@ class EzdxfBackend(AutoCADBackend): it there would redefine the block under every other reference), an xref, and a MINSERT -- see ``_refuse_unexplodable_reference`` for the last two, which ``virtual_entities()`` cannot represent and which used - to be deleted with ``ok: True``. + to be deleted with ``ok: True`` -- and a reference whose definition + holds a member ezdxf cannot copy or transform (OLE2FRAME, VIEWPORT, a + proxy entity without proxy graphics), refused by member with the + capability key ``explode_opaque_members`` -- see + ``_explodable_members``; those used to be dropped from the result with + ``ok: True`` while the reference was deleted. """ def _sync(): @@ -4676,8 +4727,9 @@ class EzdxfBackend(AutoCADBackend): raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)") self._refuse_unexplodable_reference(ent, handle) msp = self._owner_layout_for_explode(ent, handle) + members = self._explodable_members(ent, handle) inserted = [] - for sub in ent.virtual_entities(): + for sub in members: sub_copy = sub.copy() msp.add_entity(sub_copy) inserted.append(sub_copy.dxf.handle) @@ -4707,6 +4759,39 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + @staticmethod + def _attrib_value(attrib) -> str: + """The value an ATTRIB carries, as AutoCAD's ``TextString`` reports it. + + A multi-line attribute keeps its content in an embedded MTEXT and its + ``dxf.text`` is only the first line when ezdxf authored it (AutoCAD + writes ``Line1\\PLine2``), so the embedded content is the value; a + single-line attribute's value is ``dxf.text``. + """ + if attrib.has_embedded_mtext_entity: + return attrib.virtual_mtext_entity().text + return attrib.dxf.text + + @staticmethod + def _write_attrib_value(attrib, value: str) -> None: + """Write ``value`` into ``attrib`` on every surface it has. + + Setting only ``dxf.text`` on a multi-line ATTRIB left the embedded + MTEXT at its old content: ``block_get_attributes`` reported the new + value, ``block_explode`` burst the old one, and on an R2010 document + (no embedded MTEXT exported) a save/reload carried the new value the + exploded drawing never showed. The embedded MTEXT is rebuilt from its + own virtual entity with the new content -- the round trip ezdxf's + ``BaseAttrib.transform`` itself uses -- so ``dxf.text`` (first line), + the MTEXT and the placement stay one attribute. + """ + if attrib.has_embedded_mtext_entity: + mtext = attrib.virtual_mtext_entity() + mtext.text = value + attrib.set_mtext(mtext, graphic_properties=False) + else: + attrib.dxf.text = value + async def block_get_attributes(self, handle) -> dict: def _sync(): ent = self._get_entity(handle) @@ -4714,7 +4799,7 @@ class EzdxfBackend(AutoCADBackend): raise RuntimeError(f"Entity {handle} is not a block reference") result = {} for attrib in ent.attribs: - result[attrib.dxf.tag] = attrib.dxf.text + result[attrib.dxf.tag] = self._attrib_value(attrib) return result return await self._async(_sync) @@ -4728,7 +4813,7 @@ class EzdxfBackend(AutoCADBackend): for attrib in ent.attribs: tag = attrib.dxf.tag if tag in attributes: - attrib.dxf.text = str(attributes[tag]) + self._write_attrib_value(attrib, str(attributes[tag])) updated.append(tag) self._mark_dirty() return {"ok": True, "updated_tags": updated} diff --git a/server.py b/server.py index 116e9a3..1b58caf 100644 --- a/server.py +++ b/server.py @@ -3040,8 +3040,12 @@ async def block_explode( (one TEXT or MTEXT handle per attribute), `exploded_handle` and `backend`. Refused, with nothing written: a handle that is not a block reference, a reference nested inside a block definition (explode the - outer reference instead), an external reference (bind it first), and a - MINSERT grid. Not undoable except through `drawing_undo` / a transaction. + outer reference instead), an external reference (bind it first), a + MINSERT grid, and -- headless only, capability `explode_opaque_members` + -- a block holding a member ezdxf cannot transform (an OLE2FRAME logo, a + VIEWPORT, a proxy entity without proxy graphics), named by member; the + live engine hands those to AutoCAD. Not undoable except through + `drawing_undo` / a transaction. """ await ctx.warning(f"Exploding block reference {handle}") return await _backend(ctx).block_explode(handle) @@ -3056,7 +3060,11 @@ async def block_get_attributes( handle: Annotated[str, "Block reference (INSERT) entity handle"], ctx: Context = None, ) -> dict: - """Get all attribute values from a block reference as {TAG: value} dict.""" + """Get all attribute values from a block reference as {TAG: value} dict. + + A multi-line attribute reports its whole content with `\\P` between the + lines (AutoCAD's `TextString`), on both engines. + """ return await _backend(ctx).block_get_attributes(handle) @@ -3073,7 +3081,12 @@ async def block_set_attributes( attributes: Annotated[dict, "Attribute values to update: {TAG: new_value}"], ctx: Context = None, ) -> dict: - """Update attribute values in a block reference.""" + """Update attribute values in a block reference. + + A multi-line attribute takes `Line1\\PLine2` and keeps every line; the + value written is the one `block_get_attributes` reads back and + `block_explode` bursts, on both engines. + """ return await _backend(ctx).block_set_attributes(handle, attributes) diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index 93d5587..47f7895 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -1075,3 +1075,157 @@ async def test_com_explode_refuses_a_minsert_by_name_before_explode(com): with pytest.raises(RuntimeError, match=r"MINSERT \(2x3 grid of 'CELL'\)"): await backend.block_explode("9B") assert dispatched == [] and space.calls == [] and grid.deleted is False + + +# ── Task 2 (review 2): the multi-line writer, and members ezdxf cannot explode ─ + + +async def _insert_multi_line(backend): + """An INSERT of a LINE + ATTDEF block whose ATTRIB is multi-line (embedded + MTEXT ``OLD ONE\\POLD TWO``), built the way the tests above build one.""" + blk = backend._doc.blocks.new(name="MLW") + blk.add_line((0, 0), (10, 0)) + blk.add_attdef("NOTE", insert=(0, 3), text="x", dxfattribs={"height": 2.5}) + ref = await backend.block_insert("MLW", 10, 10, rotation=30.0, attributes={"NOTE": "first"}) + attrib = backend._doc.entitydb[ref.handle].attribs[0] + _embed_multi_line(attrib, ["OLD ONE", "OLD TWO"], attrib.dxf.insert) + return ref, attrib + + +async def test_get_attributes_reports_every_line_of_a_multi_line_attrib(backend): + """``dxf.text`` of an ezdxf-embedded multi-line ATTRIB is only the first + line; AutoCAD's ``TextString`` (what the COM engine reports) is the whole + ``Line1\\PLine2`` content, and so is the headless reader now.""" + ref, _ = await _insert_multi_line(backend) + assert await backend.block_get_attributes(ref.handle) == {"NOTE": "OLD ONE\\POLD TWO"} + + +async def test_set_attributes_rewrites_the_embedded_mtext_of_a_multi_line_attrib(backend): + """The writer used to set only ``dxf.text``, so after an edit the reader + said ``NEW VALUE`` while the explode burst the pre-edit MTEXT -- and an + R2010 save (no embedded MTEXT exported) carried a value the exploded + drawing never showed. One value, on every surface.""" + ref, attrib = await _insert_multi_line(backend) + keys = ("insert", "align_point", "rotation", "height", "halign", "valign", "style") + before = {key: attrib.dxf.get(key) for key in keys} + + result = await backend.block_set_attributes(ref.handle, {"NOTE": "NEW VALUE"}) + + assert result == {"ok": True, "updated_tags": ["NOTE"]} + assert attrib.has_embedded_mtext_entity, "still a multi-line attribute" + assert attrib.virtual_mtext_entity().text == "NEW VALUE" + assert attrib.dxf.text == "NEW VALUE" + after = {key: attrib.dxf.get(key) for key in keys} + assert after == before, "rewriting the content does not move the attribute" + assert await backend.block_get_attributes(ref.handle) == {"NOTE": "NEW VALUE"} + exploded = await backend.block_explode(ref.handle) + (handle,) = exploded["attribute_texts"] + raw = backend._doc.entitydb[handle] + assert raw.dxftype() == "MTEXT" and raw.text == "NEW VALUE" + assert backend._doc.audit().errors == [] + + +async def test_set_attributes_multi_line_value_keeps_every_line(backend): + """A ``\\P`` value on a multi-line attribute is what AutoCAD's + ``TextString`` write takes; every line survives, and the empty value + empties the attribute rather than leaving the old content behind.""" + ref, attrib = await _insert_multi_line(backend) + + await backend.block_set_attributes(ref.handle, {"NOTE": "NEW ONE\\PNEW TWO\\PNEW THREE"}) + + mtext = attrib.virtual_mtext_entity() + assert mtext.plain_text(split=True) == ["NEW ONE", "NEW TWO", "NEW THREE"] + assert attrib.dxf.text == "NEW ONE", "ezdxf's single-line mirror is the first line" + assert await backend.block_get_attributes(ref.handle) == { + "NOTE": "NEW ONE\\PNEW TWO\\PNEW THREE" + } + + await backend.block_set_attributes(ref.handle, {"NOTE": ""}) + assert await backend.block_get_attributes(ref.handle) == {"NOTE": ""} + assert attrib.virtual_mtext_entity().text == "" and attrib.dxf.text == "" + + exploded = await backend.block_explode(ref.handle) + (handle,) = exploded["attribute_texts"] + assert backend._doc.entitydb[handle].text == "" + assert backend._doc.audit().errors == [] + + +async def test_set_attributes_on_a_multi_line_attrib_survives_an_r2010_round_trip( + backend, tmp_path +): + """The default document is R2010, where ezdxf exports no embedded MTEXT: + the saved file carries only ``dxf.text``. After the fix that is the + edited value, so the reloaded drawing and the exploded one agree.""" + ref, _ = await _insert_multi_line(backend) + assert backend._doc.dxfversion == "AC1024", "the premise" + await backend.block_set_attributes(ref.handle, {"NOTE": "NEW VALUE"}) + path = tmp_path / "ml.dxf" + await backend.drawing_save_as(str(path)) + await backend.drawing_open(str(path)) + + reloaded = next(e for e in backend._msp() if e.dxftype() == "INSERT") + assert not reloaded.attribs[0].has_embedded_mtext_entity, "R2010 drops the embedded MTEXT" + assert await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": "NEW VALUE"} + exploded = await backend.block_explode(reloaded.dxf.handle) + (handle,) = exploded["attribute_texts"] + raw = backend._doc.entitydb[handle] + assert raw.dxftype() == "TEXT" and raw.dxf.text == "NEW VALUE" + + +async def test_set_attributes_leaves_a_single_line_attrib_single_line(backend): + _define_tagged_block(backend, "SL") + ref = await backend.block_insert("SL", 0, 0, attributes={"TAG": "P-1"}) + await backend.block_set_attributes(ref.handle, {"TAG": "P-2"}) + attrib = backend._doc.entitydb[ref.handle].attribs[0] + assert not attrib.has_embedded_mtext_entity and attrib.dxf.text == "P-2" + assert await backend.block_get_attributes(ref.handle) == {"TAG": "P-2"} + + +async def test_explode_refuses_members_ezdxf_cannot_explode_before_writing(backend): + """``virtual_entities()`` drops every member it cannot copy or transform + (OLE2FRAME, VIEWPORT) with a debug log, and a proxy entity without proxy + graphics contributes nothing -- so a title block's OLE logo or a vertical + product's proxy geometry used to be destroyed with ``ok: True`` and a + clean audit. Refused by member before anything is written.""" + from ezdxf.entities import factory + + from backends.base import UnsupportedCapabilityError + + blk = backend._doc.blocks.new(name="PRX") + blk.add_line((0, 0), (10, 0)) + for dxftype in ("ACAD_PROXY_ENTITY", "OLE2FRAME", "VIEWPORT"): + blk.add_entity(factory.create_db_entry(dxftype, {}, backend._doc)) + ref = await backend.block_insert("PRX", 0, 0) + before = [e.dxf.handle for e in backend._msp()] + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.block_explode(ref.handle) + + assert excinfo.value.capability == "explode_opaque_members" + message = str(excinfo.value) + for name in ("ACAD_PROXY_ENTITY", "OLE2FRAME", "VIEWPORT", "'PRX'", "com"): + assert name in message, message + assert [e.dxf.handle for e in backend._msp()] == before, "nothing written" + assert backend._doc.entitydb[ref.handle].is_alive + + +async def test_explode_still_bursts_a_block_every_member_of_which_it_can_explode(backend): + """The refusal is per member, not per block: ordinary geometry keeps + exploding, in definition order, under a uniform scale.""" + blk = backend._doc.blocks.new(name="PLAIN2") + blk.add_line((0, 0), (10, 0)) + blk.add_circle((5, 5), 1) + blk.add_hatch().paths.add_polyline_path([(0, 0), (1, 0), (1, 1)]) + ref = await backend.block_insert("PLAIN2", 0, 0, scale_x=2.0, scale_y=2.0) + result = await backend.block_explode(ref.handle) + types = [(await backend.entity_get(h)).type for h in result["inserted_handles"]] + assert types == ["LINE", "CIRCLE", "HATCH"] + + +async def test_explode_opaque_members_is_declared_on_both_engines(): + from backends.com_backend import ComBackend + from backends.ezdxf_backend import EzdxfBackend + + headless = EzdxfBackend().capabilities().features["explode_opaque_members"] + assert headless.supported is False and "OLE2FRAME" in (headless.reason or "") + assert ComBackend().capabilities().features["explode_opaque_members"].supported is True