diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index f9d244f..7729517 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -387,15 +387,26 @@ def _normalise_dimstyle_rounding(doc) -> None: itself — silently rounded every dimension to a whole unit *before* DIMDEC got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6. + Every style in the table, not `Standard` alone: ezdxf's exporter writes + group 45 = 0.0 for *each* DIMSTYLE entry, so a user style (the ISO-25 / + ANSI presets `dimstyle_create` makes) came back poisoned from every DXF + round trip — save/open, `transaction_rollback`, `drawing_undo` — and a + 33.333 mm feature dimensioned with it rendered "33" while `dimstyle_list` + still reported DIMDEC 2. Measured before this ran on the whole table. + Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm rounding keeps it. """ try: - style = doc.dimstyles.get("Standard") + styles = list(doc.dimstyles) except Exception: return - if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0: - style.dxf.discard("dimrnd") + for style in styles: + try: + if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0: + style.dxf.discard("dimrnd") + except (TypeError, ValueError): + continue def _apply_iso_dimstyle(doc) -> None: @@ -801,17 +812,42 @@ def _write_dimstyle_values(style, values: dict[str, Any]) -> None: def _sync_header_to_dimstyle(doc, style) -> None: - """Load the style's stored variables into ``$DIM*`` — a live seat's Restore.""" + """Load the style into ``$DIM*`` — every whitelist variable, a live seat's Restore. + + ``-DIMSTYLE Restore`` replaces *all* the DIM* system variables with the + style's settings; a DIMSTYLE entry that omits a code means its schema + default. Writing only the attributes the style *stores* left every other + ``$DIM*`` at the previous style's value — measured, ISO-25 with DIMZIN 0 / + DIMSCALE 2.0 made current and then ``dimstyle_set_current("Standard")`` + rendered the next dimension at 5.0 mm as "33.30": `_with_header_dimvars` + folded the stale header onto it, and the saved file carried the same + values next to ``$DIMSTYLE Standard``, which AutoCAD reads as overrides. + The same gap kept ``$DIMRND 0.5`` in the header after `dimstyle_modify` + discarded the style's own attribute. + + Measured on ezdxf 1.4.4: a fresh header equals the schema default for + every whitelist variable a fresh `Standard` does not store, so restoring + `Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no + schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value` + reports. + """ from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST for var in sorted(DIM_VARIABLE_WHITELIST): attr = var.lower() - if not style.dxf.hasattr(attr): + value = style.dxf.get(attr) if style.dxf.hasattr(attr) else None + if value is None: + value = _dimstyle_schema_default(var) + if value is None and var == "DIMTXSTY": + value = "Standard" + key = f"${var}" + if value is None: + try: + del doc.header[key] + except KeyError: + pass continue - raw = style.dxf.get(attr) - if raw is None: - continue - doc.header[f"${var}"] = raw + doc.header[key] = value def _font_available(font_file: str) -> bool: @@ -859,6 +895,57 @@ def _dimensions_using(doc, style_name: str) -> list[str]: return handles +def _style_table_references(doc) -> tuple[set[str], set[str]]: + """``(block names, text-style names)`` the style tables and header reference. + + Lower-cased, for `drawing_purge`. A DIMSTYLE names its text style and up + to four arrowhead blocks (stored in ezdxf's spelling, ``OBLIQUE``, while + the block is ``_OBLIQUE`` — both spellings are counted); an MLEADERSTYLE + references its text style and arrowhead by handle; the header names the + current text style and dimension text style. ``Standard`` is never purged. + """ + blocks: set[str] = set() + styles: set[str] = {"standard"} + + def _add_block(name) -> None: + if isinstance(name, str) and name: + lowered = name.lower() + blocks.add(lowered) + blocks.add(lowered if lowered.startswith("_") else f"_{lowered}") + + def _add_style(name) -> None: + if isinstance(name, str) and name: + styles.add(name.lower()) + + def _add_by_handle(handle, into) -> None: + entity = doc.entitydb.get(handle) if handle else None + if entity is not None: + into(entity.dxf.get("name")) + + try: + for var in ("$TEXTSTYLE", "$DIMTXSTY"): + _add_style(doc.header.get(var, None)) + for var in ("$DIMBLK", "$DIMBLK1", "$DIMBLK2", "$DIMLDRBLK"): + _add_block(doc.header.get(var, None)) + except Exception: # an opened document may not have a readable header + pass + for style in doc.dimstyles: + _add_style(style.dxf.get("dimtxsty")) + for attr in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"): + _add_block(style.dxf.get(attr)) + for _name, style in doc.mleader_styles: + _add_by_handle(style.dxf.get("text_style_handle"), _add_style) + _add_by_handle(style.dxf.get("arrow_head_handle"), _add_block) + for entity in doc.entitydb.values(): + if entity.dxftype() != "MULTILEADER": + continue + context = getattr(entity, "context", None) + _add_by_handle(getattr(context, "text_style_handle", None), _add_style) + for arrow in getattr(entity, "arrow_heads", None) or (): + _add_by_handle(getattr(arrow, "handle", None), _add_block) + return blocks, styles + + def _round_measure(result: dict) -> dict: """Six decimal places, matching analysis_measure_distance.""" result["area"] = round(float(result["area"]), 6) @@ -2538,17 +2625,31 @@ class EzdxfBackend(AutoCADBackend): raise self._solid_unsupported("solid_boolean") async def drawing_purge(self) -> dict: + """Delete every unreferenced block, layer, linetype and text style. + + A reference is not only an entity's own attribute. The style tables + reference each other — a DIMSTYLE names its text style (DIMTXSTY) and + its arrowhead blocks, an MLEADERSTYLE holds a text-style handle — and + the header names the current text style (``$TEXTSTYLE``, ``$DIMTXSTY``). + Measured before those were counted: ``dimstyle_create("ISO-25", …, + set_current=True)`` then ``drawing_purge()`` removed ISOCP, the next + dimension silently rendered in ``Standard`` while `dimstyle_list` still + said ISOCP, and ``drawing_save_as`` raised ``DXFTableEntryError`` after + leaving a truncated file on disk. Names are compared case-insensitively, + AutoCAD's rule for every symbol table. + """ + def _sync(): doc = self._require_doc() purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0} + used_blocks, used_styles = _style_table_references(doc) - used_blocks: set[str] = set() for ent in doc.entitydb.values(): if ent.dxftype() == "INSERT": - used_blocks.add(ent.dxf.name) + used_blocks.add(str(ent.dxf.name).lower()) for blk in list(doc.blocks): name = blk.name - if name.startswith("*") or name in used_blocks: + if name.startswith("*") or name.lower() in used_blocks: continue try: doc.blocks.delete_block(name, safe=True) @@ -2586,13 +2687,12 @@ class EzdxfBackend(AutoCADBackend): except Exception: pass - used_styles: set[str] = {"Standard"} for ent in doc.entitydb.values(): if hasattr(ent.dxf, "style"): - used_styles.add(ent.dxf.style) + used_styles.add(str(ent.dxf.style).lower()) for st in list(doc.styles): name = st.dxf.name - if name in used_styles: + if name.lower() in used_styles: continue try: doc.styles.remove(name) diff --git a/tests/test_styles.py b/tests/test_styles.py index 3aa989c..171e22e 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -12,8 +12,10 @@ from __future__ import annotations import types +import ezdxf import pytest +import config from backends.base import UnsupportedCapabilityError from backends.capability import is_capability_default from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name @@ -187,6 +189,139 @@ async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(ba rows = await backend.dimstyle_list() assert [r["name"] for r in rows if r["current"]] == ["ISO-25"] assert rows[0]["name"] == "ISO-25" and rows[0]["values"]["DIMDSEP"] == "," + # The table's report was never the thing that could lie; the render is. + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + + +async def test_a_user_dimstyle_survives_every_dxf_round_trip_without_rounding( + backend, tmp_path, monkeypatch +): + """ezdxf's exporter writes DIMRND 0.0 for *every* DIMSTYLE, and a stored 0.0 + makes the renderer round every dimension to a whole unit. Measured before + `_normalise_dimstyle_rounding` walked the whole table: ISO-25 came back from + a save/open, a `transaction_rollback` and a `drawing_undo` rendering 33.333 + as "33" and R6.35 as "R6" while `dimstyle_list` still reported DIMDEC 2.""" + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + await backend.drawing_new() # a history baseline with undo switched on + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None)) + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + + def _rounding_stored() -> dict[str, bool]: + return {s.dxf.name: s.dxf.hasattr("dimrnd") for s in backend._doc.dimstyles} + + path = str(tmp_path / "roundtrip.dxf") + await backend.drawing_save_as(path) + assert ezdxf.readfile(path).dimstyles.get("ISO-25").dxf.get("dimrnd") == 0.0, ( + "the premise: the file on disk carries the 0.0 sentinel for a user style" + ) + await backend.drawing_open(path) + assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False} + reopened = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60) + assert _rendered(backend, reopened.handle) == ("33,33", 2.5, [2.5]) + radius = await backend.dimension_radius(50, 50, 56.35, 50) + assert _rendered(backend, radius.handle)[0] == "R6,35" + await backend.dimstyle_set_current("ANSI") + ansi = await backend.dimension_linear(0, 0, 12.75, 0, 6, -90) + assert _rendered(backend, ansi.handle)[0] == "12.75" + + await backend.dimstyle_set_current("ISO-25") + await backend.transaction_begin() + await backend.entity_create_line(0, 0, 10, 10) + await backend.transaction_rollback() + assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False} + rolled_back = await backend.dimension_linear(0, 0, 33.333, 0, 16, -120) + assert _rendered(backend, rolled_back.handle) == ("33,33", 2.5, [2.5]) + + await backend.entity_create_line(0, 0, 20, 20) + assert (await backend.drawing_undo())["ok"] is True + assert _rounding_stored() == {"Standard": False, "ISO-25": False, "ANSI": False} + undone = await backend.dimension_linear(0, 0, 33.333, 0, 16, -150) + assert _rendered(backend, undone.handle) == ("33,33", 2.5, [2.5]) + + await backend.dimstyle_modify("ISO-25", {"DIMRND": 0.5}) + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert backend._doc.dimstyles.get("ISO-25").dxf.dimrnd == 0.5, ( + "only the 0.0 sentinel is discarded; a rounding the drafter asked for survives" + ) + + +async def test_drawing_purge_keeps_what_the_style_tables_and_header_reference(backend, tmp_path): + """`drawing_purge` followed only entity `.dxf.style`; a text style that only + a DIMSTYLE, an MLEADERSTYLE or the header referenced was deleted, the next + dimension silently rendered in Standard, and `drawing_save_as` raised + `DXFTableEntryError` after leaving a truncated file on disk.""" + await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True) + await backend.mleaderstyle_create("ANSI", resolve_mleaderstyle("ansi", None)) + await backend.textstyle_create("Notes", "arial.ttf", set_current=True) + await backend.textstyle_create("Junk", "isocpeur.ttf") + await backend.block_define("MYARROW", [{"type": "line", "x1": 0, "y1": 0, "x2": -1, "y2": 0.2}]) + await backend.dimstyle_create("ARROWED", resolve_dimstyle("ansi", {"DIMBLK": "MYARROW"})) + + result = await backend.drawing_purge() + assert result["purged"]["text_styles"] == 1, "only the style nothing references goes" + assert result["purged"]["blocks"] == 0 + names = {row["name"] for row in await backend.textstyle_list()} + assert {"Standard", "ISOCP", "ROMANS", "Notes"} <= names and "Junk" not in names + assert [r["name"] for r in await backend.textstyle_list() if r["current"]] == ["Notes"] + assert "MYARROW" in backend._doc.blocks + leader_styles = {r["name"]: r["text_style"] for r in await backend.mleaderstyle_list()} + assert leader_styles["ANSI"] == "ROMANS" + + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + path = str(tmp_path / "purged.dxf") + await backend.drawing_save_as(path) + reopened = ezdxf.readfile(path) + assert reopened.dimstyles.get("ISO-25").dxf.dimtxsty == "ISOCP" + assert reopened.header["$TEXTSTYLE"] == "Notes" + + +async def test_set_current_restores_every_dim_variable_not_just_the_stored_ones(backend, tmp_path): + """A live seat's `-DIMSTYLE Restore` replaces *all* DIM* variables. Writing + only what the target style stores left the previous style's values in the + header: measured, ISO-25 with DIMZIN 0 / DIMSCALE 2.0 then + `dimstyle_set_current("Standard")` rendered the next dimension at 5.0 mm as + "33.30" and the saved file carried those next to `$DIMSTYLE Standard`.""" + from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST + + header = backend._doc.header + fresh = {f"${var}": header.get(f"${var}", None) for var in DIM_VARIABLE_WHITELIST} + baseline = await backend.dimension_linear(0, 0, 33.3, 0, 16, -30) + + values = resolve_dimstyle("iso-25", {"DIMZIN": 0, "DIMSCALE": 2.0}) + await backend.dimstyle_create("ISO-25", values, set_current=True) + assert header["$DIMZIN"] == 0 and header["$DIMSCALE"] == 2.0 + assert header["$DIMTXSTY"] == "ISOCP" + result = await backend.dimstyle_set_current("Standard") + assert result["current"] == "Standard" + assert {key: header.get(key, None) for key in fresh} == fresh, ( + "restoring Standard must reproduce the header drawing_new made" + ) + dim = await backend.dimension_linear(0, 0, 33.3, 0, 16, -60) + expected = ("33.3", 2.5, [2.5]) + assert _rendered(backend, dim.handle) == _rendered(backend, baseline.handle) == expected + assert backend._doc.entitydb.get(dim.handle).override().dimstyle_attribs == {}, ( + "nothing stale is folded onto the dimension" + ) + + # DIMRND 0 discards the style attribute; the header twin must follow. + await backend.dimstyle_set_current("ISO-25") + assert header["$DIMZIN"] == 0 and header["$DIMSCALE"] == 2.0 + await backend.dimstyle_modify("ISO-25", {"DIMRND": 0.5}) + assert header["$DIMRND"] == 0.5 + cleared = await backend.dimstyle_modify("ISO-25", {"DIMRND": 0}) + assert cleared["changed"] == {"DIMRND": [0.5, 0]} + assert header["$DIMRND"] == 0.0 + await backend.dimstyle_set_current("Standard") + path = str(tmp_path / "restored.dxf") + await backend.drawing_save_as(path) + saved = ezdxf.readfile(path).header + assert saved["$DIMSTYLE"] == "Standard" + assert {key: saved.get(key, None) for key in fresh} == fresh async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend):