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:
co-authored by
Claude Opus 5
parent
b464bcdaf5
commit
a51c1b6e14
+108
-19
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user