fix(task-2): a moved multi-line attribute keeps one placement and its whole value on the headless engine

ezdxf keeps a multi-line ATTRIB's two placement surfaces (the ATTRIB's own
insert/align_point and the embedded MTEXT's insert) in step through
transform() but not translate(): Insert.translate moves only the ATTRIB's
points (1.4.4 attrib.py has no translate). entity_move / entity_copy /
entity_array_rectangular therefore left the embedded MTEXT behind, so
block_explode burst the note 50 units from its symbol (audit clean), and
block_set_attributes / text_find_replace -- which rebuild the MTEXT and let
set_mtext re-place the ATTRIB from it -- teleported the attribute back to
the old spot while the INSERT stayed put (a regression of e7dafb8/b753a12,
which before wrote only dxf.text).

- _anchored_mtext(attrib): the virtual MTEXT re-anchored to the ATTRIB's
  own placement (get_placement() through its OCS, the inverse of ezdxf's
  _update_location_from_mtext). _burst_attrib and _write_attrib_value read
  through it, so a drawing whose surfaces already disagree bursts and
  writes at the ATTRIB's placement instead of the stale MTEXT's.
- _translate(ent, ...): translate + settle every multi-line ATTRIB's MTEXT
  onto the ATTRIB (the set_mtext round trip ezdxf's own transform uses),
  keeping the whole value in group 1. Used by move, copy, rectangular array.
- _transform(ent, m): transform + restore group 1 of every multi-line
  ATTRIB -- BaseAttrib.transform goes through set_mtext, which mirrors only
  the first line, so rotate/scale/mirror/polar array/change_space turned
  'A\PB' into 'A' on the one surface an R2010 save keeps.
- block_insert of a multi-line ATTDEF: add_auto_attribs embeds through
  embed_mtext with the same first-line mirror; the whole value goes into
  group 1 the way _write_attrib_value keeps it.

Tests: move/copy/rectangular array carry the MTEXT (explode lands on the
symbol), set_attributes and find_replace after a move leave the attribute
where the move put it, a rotated reference moves both surfaces by the same
delta, a stale drawing bursts/writes at the ATTRIB placement, rotate/scale/
mirror/polar and a fresh insert keep the whole value across an R2010
round trip, and a single-line reference is unchanged. 13 fail before the
fix, all pass after; full suite 2511 passed.

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-18 10:12:56 +03:00
co-authored by Claude Opus 5
parent b464bcdaf5
commit a51c1b6e14
2 changed files with 340 additions and 19 deletions
+108 -19
View File
@@ -2259,7 +2259,7 @@ class EzdxfBackend(AutoCADBackend):
frozen_text = self._format_measurement(entity.get_measurement()) frozen_text = self._format_measurement(entity.get_measurement())
entity.dxf.text = frozen_text entity.dxf.text = frozen_text
entity.transform(matrix) self._transform(entity, matrix)
source.move_to_layout(entity, target) source.move_to_layout(entity, target)
# Where the entity now sits, expressed in paper coordinates. # Where the entity now sits, expressed in paper coordinates.
@@ -3545,7 +3545,7 @@ class EzdxfBackend(AutoCADBackend):
async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: async def entity_move(self, handle, dx, dy, dz=0.0) -> dict:
def _sync(): def _sync():
ent = self._get_entity(handle) ent = self._get_entity(handle)
ent.translate(float(dx), float(dy), float(dz)) self._translate(ent, float(dx), float(dy), float(dz))
self._mark_dirty() self._mark_dirty()
return {"ok": True, "handle": handle} return {"ok": True, "handle": handle}
@@ -3556,7 +3556,7 @@ class EzdxfBackend(AutoCADBackend):
ent = self._get_entity(handle) ent = self._get_entity(handle)
copy = ent.copy() copy = ent.copy()
self._msp().add_entity(copy) self._msp().add_entity(copy)
copy.translate(float(dx), float(dy), float(dz)) self._translate(copy, float(dx), float(dy), float(dz))
self._mark_dirty() self._mark_dirty()
return _entity_info_dxf(copy) return _entity_info_dxf(copy)
@@ -3570,7 +3570,9 @@ class EzdxfBackend(AutoCADBackend):
m = Matrix44.z_rotate(math.radians(float(angle_deg))) m = Matrix44.z_rotate(math.radians(float(angle_deg)))
# Translate to origin, rotate, translate back # Translate to origin, rotate, translate back
bx, by = float(base_x), float(base_y) bx, by = float(base_x), float(base_y)
ent.transform(Matrix44.translate(-bx, -by, 0) @ m @ Matrix44.translate(bx, by, 0)) self._transform(
ent, Matrix44.translate(-bx, -by, 0) @ m @ Matrix44.translate(bx, by, 0)
)
self._mark_dirty() self._mark_dirty()
return {"ok": True, "handle": handle} return {"ok": True, "handle": handle}
@@ -3583,10 +3585,11 @@ class EzdxfBackend(AutoCADBackend):
s = float(factor) s = float(factor)
bx, by = float(base_x), float(base_y) bx, by = float(base_x), float(base_y)
ent.transform( self._transform(
ent,
Matrix44.translate(-bx, -by, 0) Matrix44.translate(-bx, -by, 0)
@ Matrix44.scale(s, s, s) @ Matrix44.scale(s, s, s)
@ Matrix44.translate(bx, by, 0) @ Matrix44.translate(bx, by, 0),
) )
self._mark_dirty() self._mark_dirty()
return {"ok": True, "handle": handle} return {"ok": True, "handle": handle}
@@ -3637,7 +3640,9 @@ class EzdxfBackend(AutoCADBackend):
) )
) )
tx, ty = float(x1), float(y1) tx, ty = float(x1), float(y1)
copy.transform(Matrix44.translate(-tx, -ty, 0) @ m @ Matrix44.translate(tx, ty, 0)) self._transform(
copy, Matrix44.translate(-tx, -ty, 0) @ m @ Matrix44.translate(tx, ty, 0)
)
if delete_original: if delete_original:
self._msp().delete_entity(ent) self._msp().delete_entity(ent)
self._mark_dirty() self._mark_dirty()
@@ -3744,7 +3749,7 @@ class EzdxfBackend(AutoCADBackend):
continue # skip original continue # skip original
copy = ent.copy() copy = ent.copy()
msp.add_entity(copy) msp.add_entity(copy)
copy.translate(c * float(col_spacing), r * float(row_spacing), 0) self._translate(copy, c * float(col_spacing), r * float(row_spacing), 0)
results.append(_entity_info_dxf(copy)) results.append(_entity_info_dxf(copy))
self._mark_dirty() self._mark_dirty()
return results return results
@@ -3780,7 +3785,7 @@ class EzdxfBackend(AutoCADBackend):
@ Matrix44.z_rotate(angle) @ Matrix44.z_rotate(angle)
@ Matrix44.translate(cx, cy, 0) @ Matrix44.translate(cx, cy, 0)
) )
copy.transform(m) self._transform(copy, m)
results.append(_entity_info_dxf(copy)) results.append(_entity_info_dxf(copy))
self._mark_dirty() self._mark_dirty()
return results return results
@@ -4541,6 +4546,13 @@ class EzdxfBackend(AutoCADBackend):
# `add_auto_attribs` attaches ATTRIBs to this INSERT for every # `add_auto_attribs` attaches ATTRIBs to this INSERT for every
# ATTDEF the definition has; values for tags it lacks are ignored. # ATTDEF the definition has; values for tags it lacks are ignored.
ref.add_auto_attribs(values) ref.add_auto_attribs(values)
# A multi-line ATTDEF becomes a multi-line ATTRIB through
# `embed_mtext`, which mirrors only the first line of the value
# into `dxf.text`; the whole value goes there (the convention
# `_write_attrib_value` keeps) so an R2010 save keeps every line.
for attrib in ref.attribs:
if attrib.has_embedded_mtext_entity:
attrib.dxf.text = self._attrib_value(attrib)
self._mark_dirty() self._mark_dirty()
return _entity_info_dxf(ref) return _entity_info_dxf(ref)
@@ -4600,6 +4612,79 @@ class EzdxfBackend(AutoCADBackend):
"""The ``_ATTRIB_TO_TEXT`` members ``attrib`` (ATTRIB or ATTDEF) carries.""" """The ``_ATTRIB_TO_TEXT`` members ``attrib`` (ATTRIB or ATTDEF) carries."""
return {key: attrib.dxf.get(key) for key in cls._ATTRIB_TO_TEXT if attrib.dxf.hasattr(key)} return {key: attrib.dxf.get(key) for key in cls._ATTRIB_TO_TEXT if attrib.dxf.hasattr(key)}
@staticmethod
def _anchored_mtext(attrib):
"""The embedded MTEXT of a multi-line ``attrib`` (ATTRIB or ATTDEF) as a
virtual entity placed where the attribute itself is.
A multi-line attribute keeps its placement on two surfaces: the
ATTRIB's own ``insert``/``align_point`` (OCS, the frame of its
``extrusion``) and the embedded MTEXT's ``insert`` (WCS). ezdxf keeps
them in step through ``transform`` (``BaseAttrib`` overrides it) but
not through ``translate``: ``Insert.translate`` calls ``Text.translate``
on every ATTRIB, which moves the ATTRIB's two points and nothing else
(ezdxf 1.4.4 ``attrib.py`` has no ``translate``). So after a move the
virtual MTEXT still said where the attribute *was*, ``block_explode``
burst the note 50 units from its symbol with a clean audit, and a
value write (``set_mtext`` re-places the ATTRIB from the MTEXT) pulled
the attribute back to the old spot while the INSERT stayed put.
The ATTRIB's placement is the attribute's placement -- it is what
``entity_get`` reports, what a single-line attribute has, and what
every ezdxf write path maintains -- so the virtual entity is anchored
to it: the inverse of ezdxf's own ``_update_location_from_mtext``
(``get_placement()`` picks ``insert`` for a LEFT-aligned text and
``align_point`` otherwise, the same rule the renderer uses).
"""
mtext = attrib.virtual_mtext_entity()
mtext.dxf.insert = attrib.ocs().to_wcs(attrib.get_placement()[1])
return mtext
@classmethod
def _settle_attrib_mtext(cls, attrib) -> None:
"""Put the embedded MTEXT of ``attrib`` where the ATTRIB now is; a
no-op for a single-line attribute.
Runs after every ``translate`` of an INSERT (``entity_move``,
``entity_copy``, ``entity_array_rectangular``). The round trip is the
one ezdxf's ``BaseAttrib.transform`` uses; ``set_mtext`` also mirrors
only the first line into ``dxf.text``, so the value (the whole
``Line1\\PLine2`` -- see ``_write_attrib_value``) is put back.
"""
if not attrib.has_embedded_mtext_entity:
return
text = attrib.dxf.text
attrib.set_mtext(cls._anchored_mtext(attrib), graphic_properties=False)
attrib.dxf.text = text
@classmethod
def _translate(cls, ent, dx: float, dy: float, dz: float) -> None:
"""``ent.translate(dx, dy, dz)`` that carries a multi-line ATTRIB's
embedded MTEXT along (see ``_anchored_mtext``)."""
ent.translate(dx, dy, dz)
if ent.dxftype() == "INSERT":
for attrib in ent.attribs:
cls._settle_attrib_mtext(attrib)
@staticmethod
def _transform(ent, m) -> None:
"""``ent.transform(m)`` that keeps group 1 of a multi-line ATTRIB whole.
ezdxf's ``BaseAttrib.transform`` rebuilds the ATTRIB through
``set_mtext``, which mirrors only the *first line* into ``dxf.text``:
a rotate, scale or mirror after ``block_set_attributes`` turned
``A\\PB`` back into ``A`` on the one surface an R2010 save keeps, and
the reloaded drawing had lost every line but the first. The content
does not change under a transform, so the value is put back.
"""
if ent.dxftype() != "INSERT":
ent.transform(m)
return
texts = [(a, a.dxf.text) for a in ent.attribs if a.has_embedded_mtext_entity]
ent.transform(m)
for attrib, text in texts:
attrib.dxf.text = text
@classmethod @classmethod
def _burst_attrib(cls, layout, attrib, matrix=None): def _burst_attrib(cls, layout, attrib, matrix=None):
"""Add the entity that stands in for ``attrib`` (ATTRIB or ATTDEF) to """Add the entity that stands in for ``attrib`` (ATTRIB or ATTDEF) to
@@ -4610,13 +4695,13 @@ class EzdxfBackend(AutoCADBackend):
``dxf.text`` is only the first line (ezdxf) or ``Line1\\PLine2...`` ``dxf.text`` is only the first line (ezdxf) or ``Line1\\PLine2...``
(AutoCAD), so a TEXT built from it drops the other lines or renders (AutoCAD), so a TEXT built from it drops the other lines or renders
literal ``\\P`` -- with a clean audit. BURST emits an MTEXT there. literal ``\\P`` -- with a clean audit. BURST emits an MTEXT there.
``virtual_mtext_entity()`` already carries the ATTRIB's graphic ``_anchored_mtext`` carries the ATTRIB's graphic properties and its
properties and its WCS placement (ezdxf transforms the embedded MTEXT placement (the embedded MTEXT's own ``insert`` is stale after a
with the ATTRIB). ``matrix``, when given, is the reference's matrix, ``translate``, see there). ``matrix``, when given, is the reference's
for an ATTDEF still in block coordinates. matrix, for an ATTDEF still in block coordinates.
""" """
if attrib.has_embedded_mtext_entity: if attrib.has_embedded_mtext_entity:
entity = attrib.virtual_mtext_entity() entity = cls._anchored_mtext(attrib)
if matrix is not None: if matrix is not None:
entity.transform(matrix) entity.transform(matrix)
layout.add_entity(entity) layout.add_entity(entity)
@@ -4791,8 +4876,8 @@ class EzdxfBackend(AutoCADBackend):
return attrib.virtual_mtext_entity().text return attrib.virtual_mtext_entity().text
return attrib.dxf.text return attrib.dxf.text
@staticmethod @classmethod
def _write_attrib_value(attrib, value: str) -> None: def _write_attrib_value(cls, attrib, value: str) -> None:
"""Write ``value`` into ``attrib`` on every surface it has. """Write ``value`` into ``attrib`` on every surface it has.
Setting only ``dxf.text`` on a multi-line ATTRIB left the embedded Setting only ``dxf.text`` on a multi-line ATTRIB left the embedded
@@ -4800,9 +4885,13 @@ class EzdxfBackend(AutoCADBackend):
value, ``block_explode`` burst the old one, and on an R2010 document value, ``block_explode`` burst the old one, and on an R2010 document
(no embedded MTEXT exported) a save/reload carried the new value the (no embedded MTEXT exported) a save/reload carried the new value the
exploded drawing never showed. The embedded MTEXT is rebuilt from its exploded drawing never showed. The embedded MTEXT is rebuilt from its
own virtual entity with the new content -- the round trip ezdxf's virtual entity with the new content -- the round trip ezdxf's
``BaseAttrib.transform`` itself uses -- so the MTEXT and the placement ``BaseAttrib.transform`` itself uses -- so the MTEXT and the placement
stay one attribute. stay one attribute. The virtual entity is anchored to the ATTRIB's
own placement first (``_anchored_mtext``): ``set_mtext`` re-places
the ATTRIB from the MTEXT, and after an ``entity_move`` the embedded
MTEXT still sat where the attribute *was*, so a value edit teleported
the attribute 50 units away from its symbol.
``set_mtext`` then mirrors only the *first line* into ``dxf.text`` ``set_mtext`` then mirrors only the *first line* into ``dxf.text``
(ezdxf's own choice), and below R2018 ezdxf exports no embedded MTEXT, (ezdxf's own choice), and below R2018 ezdxf exports no embedded MTEXT,
@@ -4814,7 +4903,7 @@ class EzdxfBackend(AutoCADBackend):
carries every line on every DXF version. carries every line on every DXF version.
""" """
if attrib.has_embedded_mtext_entity: if attrib.has_embedded_mtext_entity:
mtext = attrib.virtual_mtext_entity() mtext = cls._anchored_mtext(attrib)
mtext.text = value mtext.text = value
attrib.set_mtext(mtext, graphic_properties=False) attrib.set_mtext(mtext, graphic_properties=False)
attrib.dxf.text = value attrib.dxf.text = value
+232
View File
@@ -1391,6 +1391,238 @@ async def test_find_replace_leaves_a_single_line_attrib_single_line(backend):
assert await backend.block_get_attributes(ref.handle) == {"TAG": "NEW-1"} assert await backend.block_get_attributes(ref.handle) == {"TAG": "NEW-1"}
# ── Task 2 (review 4): a moved multi-line attribute keeps one placement ───────
def _placements(attrib) -> tuple[tuple, tuple]:
"""(ATTRIB anchor, embedded MTEXT insert), both WCS -- the two surfaces a
multi-line attribute keeps its placement on."""
anchor = attrib.ocs().to_wcs(attrib.get_placement()[1])
return tuple(anchor), tuple(attrib.virtual_mtext_entity().dxf.insert)
async def _insert_multi_line_plain(backend, name: str = "MLK"):
"""The review's repro: unrotated INSERT at (10, 10) of LINE + ATTDEF NOTE,
the ATTRIB made multi-line (``L1\\PL2``) at its own insert (10, 13)."""
blk = backend._doc.blocks.new(name=name)
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(name, 10, 10, attributes={"NOTE": "first"})
attrib = backend._doc.entitydb[ref.handle].attribs[0]
_embed_multi_line(attrib, ["L1", "L2"], attrib.dxf.insert)
assert _placements(attrib) == ((10.0, 13.0, 0.0), (10.0, 13.0, 0.0)), "the premise"
return ref, attrib
async def test_move_carries_the_embedded_mtext_of_a_multi_line_attrib(backend):
"""``Insert.translate`` moves each ATTRIB's ``insert``/``align_point`` and
nothing else -- ezdxf's ``BaseAttrib`` overrides ``transform`` but not
``translate`` -- so after ``entity_move`` the embedded MTEXT still sat at
the old place and ``block_explode`` burst it there: the LINE at (60, 60),
the note 50 units away at (10, 13), with a clean audit."""
ref, attrib = await _insert_multi_line_plain(backend)
await backend.entity_move(ref.handle, 50, 50)
assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0))
assert attrib.dxf.text == "L1", "a move does not touch the value"
result = await backend.block_explode(ref.handle)
(line_handle,) = result["inserted_handles"]
(note_handle,) = result["attribute_texts"]
assert tuple(backend._doc.entitydb[line_handle].dxf.start) == (60.0, 60.0, 0.0)
note = backend._doc.entitydb[note_handle]
assert note.dxftype() == "MTEXT" and note.text == "L1\\PL2"
assert tuple(note.dxf.insert) == (60.0, 63.0, 0.0), "the note moved with its symbol"
assert backend._doc.audit().errors == []
async def test_set_attributes_after_a_move_leaves_the_attribute_where_the_move_put_it(backend):
"""``_write_attrib_value`` rebuilt the embedded MTEXT from the stale virtual
entity and ``set_mtext`` re-placed the ATTRIB from it: a value edit after a
move teleported the attribute from (60, 63) back to (10, 13) while the
INSERT stayed at (60, 60). Before Task 2 the writer touched only
``dxf.text`` and the ATTRIB stayed put, so this was a regression."""
ref, attrib = await _insert_multi_line_plain(backend)
await backend.entity_move(ref.handle, 50, 50)
await backend.block_set_attributes(ref.handle, {"NOTE": "NEW1\\PNEW2"})
assert tuple(backend._doc.entitydb[ref.handle].dxf.insert) == (60.0, 60.0, 0.0)
assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0))
assert await backend.block_get_attributes(ref.handle) == {"NOTE": "NEW1\\PNEW2"}
assert attrib.dxf.text == "NEW1\\PNEW2"
async def test_find_replace_after_a_move_leaves_the_attribute_where_the_move_put_it(backend):
ref, attrib = await _insert_multi_line_plain(backend)
await backend.entity_move(ref.handle, 50, 50)
result = await backend.text_find_replace("L1", "X1")
assert result["replaced"] == 1
assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0))
assert await backend.block_get_attributes(ref.handle) == {"NOTE": "X1\\PL2"}
async def test_copy_carries_the_embedded_mtext_of_a_multi_line_attrib(backend):
"""``entity_copy`` is ``copy()`` + ``translate()``: the copy's ATTRIB moved,
its embedded MTEXT did not, and exploding the copy put the note on the
original."""
ref, original = await _insert_multi_line_plain(backend)
copied = await backend.entity_copy(ref.handle, 50, 50)
attrib = backend._doc.entitydb[copied.handle].attribs[0]
assert _placements(attrib) == ((60.0, 63.0, 0.0), (60.0, 63.0, 0.0))
assert _placements(original) == ((10.0, 13.0, 0.0), (10.0, 13.0, 0.0)), "the original stays"
result = await backend.block_explode(copied.handle)
(note_handle,) = result["attribute_texts"]
note = backend._doc.entitydb[note_handle]
assert note.text == "L1\\PL2" and tuple(note.dxf.insert) == (60.0, 63.0, 0.0)
assert backend._doc.audit().errors == []
async def test_rectangular_array_carries_the_embedded_mtext_of_a_multi_line_attrib(backend):
ref, _ = await _insert_multi_line_plain(backend)
cells = await backend.entity_array_rectangular(ref.handle, 1, 3, 0, 20)
for index, cell in enumerate(cells, start=1):
attrib = backend._doc.entitydb[cell.handle].attribs[0]
expected = (10.0 + 20.0 * index, 13.0, 0.0)
assert _placements(attrib) == (expected, expected), f"cell {index}"
async def test_move_of_a_rotated_reference_carries_the_embedded_mtext(backend):
"""The rotated fixture the other multi-line tests use: the same delta on
both surfaces, whatever the reference's frame."""
ref, attrib = await _insert_multi_line(backend)
anchor, mtext = _placements(attrib)
assert anchor == pytest.approx(mtext), "the premise"
await backend.entity_move(ref.handle, 5, -7, 3)
moved_anchor, moved_mtext = _placements(attrib)
delta = (5.0, -7.0, 3.0)
assert moved_anchor == pytest.approx(tuple(a + d for a, d in zip(anchor, delta, strict=True)))
assert moved_mtext == pytest.approx(moved_anchor)
async def test_explode_bursts_a_multi_line_attrib_at_the_attribs_own_placement(backend):
"""A drawing whose two surfaces already disagree (written by a tool that,
like ``Insert.translate``, moved only the ATTRIB): the ATTRIB's own
placement is the attribute's placement -- it is what ``entity_get`` reports
and what a single-line attribute has -- so the burst MTEXT goes there, and
a value write keeps it there instead of snapping to the stale MTEXT."""
ref, attrib = await _insert_multi_line_plain(backend)
attrib.dxf.insert = (40.0, 41.0, 0.0)
attrib.dxf.align_point = (40.0, 41.0, 0.0)
assert _placements(attrib) == ((40.0, 41.0, 0.0), (10.0, 13.0, 0.0)), "the premise"
await backend.block_set_attributes(ref.handle, {"NOTE": "N1\\PN2"})
assert _placements(attrib) == ((40.0, 41.0, 0.0), (40.0, 41.0, 0.0))
result = await backend.block_explode(ref.handle)
(note_handle,) = result["attribute_texts"]
assert tuple(backend._doc.entitydb[note_handle].dxf.insert) == (40.0, 41.0, 0.0)
async def test_explode_of_a_stale_multi_line_attrib_reads_the_attrib_placement(backend):
"""The read-only twin: no write before the explode, the burst still lands
on the ATTRIB's placement."""
ref, attrib = await _insert_multi_line_plain(backend)
attrib.dxf.insert = (40.0, 41.0, 0.0)
attrib.dxf.align_point = (40.0, 41.0, 0.0)
result = await backend.block_explode(ref.handle)
(note_handle,) = result["attribute_texts"]
assert tuple(backend._doc.entitydb[note_handle].dxf.insert) == (40.0, 41.0, 0.0)
async def _rotated(backend, handle):
await backend.entity_rotate(handle, 0, 0, 90)
return handle
async def _scaled(backend, handle):
await backend.entity_scale(handle, 0, 0, 2.0)
return handle
async def _mirrored(backend, handle):
"""``entity_mirror`` transforms a *copy* and deletes the original."""
return (await backend.entity_mirror(handle, 0, 0, 0, 1, delete_original=True)).handle
async def _polar_cell(backend, handle):
(cell,) = await backend.entity_array_polar(handle, 2, 180, 0, 0)
await backend.entity_delete(handle)
return cell.handle
@pytest.mark.parametrize(
"op", [_rotated, _scaled, _mirrored, _polar_cell], ids=["rotate", "scale", "mirror", "polar"]
)
async def test_transform_keeps_the_whole_multi_line_value_in_group_1(backend, op, tmp_path):
"""The ``transform`` twin of the move defect: ezdxf's ``BaseAttrib.transform``
goes through ``set_mtext``, which mirrors only the first line into
``dxf.text`` -- so a rotate, scale or mirror after ``block_set_attributes``
turned ``A\\PB`` back into ``A`` on the surface an R2010 save keeps, and
the reloaded drawing had lost every line but the first."""
ref, attrib = await _insert_multi_line_plain(backend)
await backend.block_set_attributes(ref.handle, {"NOTE": "A\\PB"})
assert attrib.dxf.text == "A\\PB", "the premise"
handle = await op(backend, ref.handle)
attrib = backend._doc.entitydb[handle].attribs[0]
anchor, mtext = _placements(attrib)
assert anchor == pytest.approx(mtext), "the two surfaces still agree on placement"
assert attrib.dxf.text == "A\\PB"
assert await backend.block_get_attributes(handle) == {"NOTE": "A\\PB"}
path = tmp_path / "ml-transformed.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 await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": "A\\PB"}
async def test_insert_of_a_multi_line_attdef_keeps_the_whole_value_in_group_1(backend, tmp_path):
"""The chain the review names: a title block whose ATTDEF is multi-line
(opened from an AutoCAD file), then ``block_insert``. ezdxf's
``add_auto_attribs`` embeds the MTEXT through ``embed_mtext``, which
mirrors only the first line into ``dxf.text``, so an R2010 save of a
fresh insert kept ``A`` of ``A\\PB``."""
blk = backend._doc.blocks.new(name="MLT")
blk.add_line((0, 0), (10, 0))
attdef = blk.add_attdef("NOTE", insert=(0, 3), text="x", dxfattribs={"height": 2.5})
_embed_multi_line(attdef, ["D1", "D2"], (0, 3))
ref = await backend.block_insert("MLT", 10, 10, attributes={"NOTE": "A\\PB"})
attrib = backend._doc.entitydb[ref.handle].attribs[0]
assert attrib.has_embedded_mtext_entity and attrib.dxf.text == "A\\PB"
assert _placements(attrib) == ((10.0, 13.0, 0.0), (10.0, 13.0, 0.0))
assert await backend.block_get_attributes(ref.handle) == {"NOTE": "A\\PB"}
path = tmp_path / "ml-insert.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 await backend.block_get_attributes(reloaded.dxf.handle) == {"NOTE": "A\\PB"}
async def test_move_of_a_single_line_attrib_reference_is_unchanged(backend):
_define_tagged_block(backend, "SLM")
ref = await backend.block_insert("SLM", 0, 0, attributes={"TAG": "P-1"})
attrib = backend._doc.entitydb[ref.handle].attribs[0]
await backend.entity_move(ref.handle, 5, 5)
assert not attrib.has_embedded_mtext_entity
assert tuple(attrib.dxf.insert) == (5.0, 8.0, 0.0) and attrib.dxf.text == "P-1"
def _boom(*_args): def _boom(*_args):
raise RuntimeError("RPC_E_CALL_REJECTED: the application is busy") raise RuntimeError("RPC_E_CALL_REJECTED: the application is busy")