diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index ec2dcc4..1a12036 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -44,8 +44,9 @@ class SettingsContract: unknown key; a value outside ``_SETTING_RANGES``; a malformed ``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file headlessly (no OBJECTS section, so the value would vanish at save — - save as R2000 or newer first); ``polar`` / ``polar_angle`` on the - headless engine (registry-saved — ``capability: registry_sysvar``); + save as R2000 or newer first); ``osmode`` / ``polar`` / ``polar_angle`` + on the headless engine (registry-saved — ``capability: registry_sysvar``; + a snapshot reports them as ``None`` there, a file holds no value); ``dimstyle`` / ``textstyle`` when the backend has no styles contract. Note that a no-argument snapshot is one ``system_get_variable`` per key @@ -145,6 +146,8 @@ _SETTING_MAP: dict[str, tuple[str, str]] = { "text_size": ("TEXTSIZE", "float"), "point_mode": ("PDMODE", "int"), "point_size": ("PDSIZE", "float"), + # OSMODE is registry-saved (a DXF R2000+ file has no $OSMODE), so the + # headless engine refuses it like the polar pair below. "osmode": ("OSMODE", "int"), "fillet_radius": ("FILLETRAD", "float"), # ── track E: the environment a drawing lives in ────────────────────── diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 2b7148a..247e882 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -73,12 +73,18 @@ _VPORT_SYSVARS: dict[str, tuple[str, str]] = { #: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved #: in" column). A file has nowhere to keep them, so the headless engine refuses #: a write with `capability: registry_sysvar` instead of storing a value that -#: would vanish; `engineering.standards.sysvars` pins this set to its catalogue. +#: would vanish, and reads report `None` rather than the value ezdxf's document +#: template happens to hold in memory; `engineering.standards.sysvars` pins this +#: set to its catalogue. OSMODE / ATTREQ / ATTDIA are the trap: ezdxf accepts +#: `doc.header["$OSMODE"]` (an R12 header variable, `maxdxf="AC1009"` in +#: `HEADER_VAR_MAP`) but never exports it for R2000+ - measured 2026-09-17: a +#: write of 4134 to a fresh AC1024 document read back `None` after save + open. _UNSAVED_SYSVARS: frozenset[str] = frozenset( { "AUTOSNAP", "POLARANG", "POLARMODE", + "OSMODE", "ATTREQ", "ATTDIA", "SAVETIME", @@ -95,6 +101,13 @@ _UNSAVED_SYSVARS: frozenset[str] = frozenset( } ) +#: Header variables the DXF file stores in degrees (group code 50) while the +#: sysvar boundary speaks radians - the unit ActiveX `GetVariable` and AutoLISP +#: `getvar` use, so both engines report the same number. Measured on AutoCAD +#: 2026 (2026-09-17): `SETVAR ANGBASE 90` reads back 1.5707963267948966 over +#: COM and the DXF AutoCAD saves carries `$ANGBASE = 90.0`. +_HEADER_DEGREE_SYSVARS: frozenset[str] = frozenset({"ANGBASE"}) + log = logging.getLogger(__name__) @@ -5573,7 +5586,12 @@ class EzdxfBackend(AutoCADBackend): return self._dictionary_variable(doc, "CANNOSCALE", "1:1") if key == "CANNOSCALEVALUE": return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1")) - return doc.header.get(f"${key}", None) + if key in _UNSAVED_SYSVARS: + return None # a file holds no value; the template's memory is not one + raw = doc.header.get(f"${key}", None) + if key in _HEADER_DEGREE_SYSVARS and raw is not None: + return math.radians(float(raw)) + return raw return await self._async(_sync) @@ -5607,8 +5625,11 @@ class EzdxfBackend(AutoCADBackend): "CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead." ) else: + stored = value + if key in _HEADER_DEGREE_SYSVARS: + stored = math.degrees(float(value)) try: - doc.header[f"${key}"] = value + doc.header[f"${key}"] = stored except DXFKeyError as exc: # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py) # while saying why the write is refused rather than dropped. diff --git a/engineering/standards/sysvars.py b/engineering/standards/sysvars.py index be6adc0..026036d 100644 --- a/engineering/standards/sysvars.py +++ b/engineering/standards/sysvars.py @@ -12,9 +12,21 @@ variable ezdxf will export, the active VPORT (grid/snap), the variable dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved and never-saved variables are ``False`` because a file has nowhere to keep them, and ``EzdxfBackend.system_set_variable`` refuses them with -``capability: registry_sysvar``. A handful of drawing-saved variables are also -``False`` because ezdxf 1.4 has no header slot for them (CTAB, VIEWCTR, -VIEWSIZE, ISOLINES, FACETRES); that refusal is a plain ``ValueError``. +``capability: registry_sysvar`` (OSMODE is one of them: ``$OSMODE`` is an R12 +header variable that ezdxf never exports for R2000+, so a headless write +looked accepted and vanished at save until it joined the refused set). A +handful of drawing-saved variables are also ``False`` because ezdxf 1.4 has no +header slot for them (CTAB, VIEWCTR, VIEWSIZE, ISOLINES, FACETRES); that +refusal is a plain ``ValueError``. + +Angles are **radians** at the ``system_get_variable`` / ``system_set_variable`` +boundary on both engines - the unit ActiveX ``GetVariable`` / ``SetVariable`` +and AutoLISP ``getvar`` use (measured on AutoCAD 2026: ``SETVAR ANGBASE 90`` +reads back 1.5707963267948966, and so does HPANG after ``SETVAR HPANG 45``). +The DXF header stores ``$ANGBASE`` in degrees (group code 50; the same +document saved by AutoCAD carries ``$ANGBASE = 90.0``), so the headless engine +translates that one variable at its boundary and the two engines report the +same number. ``drawing_settings`` speaks degrees for its friendly keys. Sources: the AutoCAD 2026 System Variables reference (type / saved-in / initial value columns), the DXF Reference HEADER and VPORT sections, and @@ -464,9 +476,10 @@ _ENTRIES: tuple[SysVar, ...] = ( _v( "OSMODE", "int", - "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); headlessly held in memory only, DXF R2000+ does not carry it", + "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); registry-saved, and a DXF R2000+ file carries no $OSMODE, so the headless engine refuses it", range_=(0, 32767), default=4133, + saved_in="registry", friendly_key="osmode", ), # ── current properties ─────────────────────────────────────────────────── @@ -543,7 +556,7 @@ _ENTRIES: tuple[SysVar, ...] = ( _v( "ANGBASE", "float", - "direction of angle 0 relative to the current UCS, in radians", + "direction of angle 0 relative to the current UCS, in radians on both engines (90 degrees = 1.5708; the DXF header stores degrees and the headless engine translates)", default=0.0, ), _v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0), @@ -655,7 +668,7 @@ _ENTRIES: tuple[SysVar, ...] = ( _v( "HPANG", "float", - "default hatch pattern angle in degrees", + "default hatch pattern angle in radians, as ActiveX and AutoLISP hold it (45 degrees = 0.7854)", default=0.0, saved_in="not_saved", ), diff --git a/server.py b/server.py index bcaa319..1a81ae3 100644 --- a/server.py +++ b/server.py @@ -5093,11 +5093,12 @@ async def drawing_settings( Refusals, per key, nothing else rolled back: an unknown key; a value outside its range (grid/snap spacing > 0, polar_angle 0–360, precision 0–8, …); a - malformed `limits` or `annotation_scale`; `polar` / `polar_angle` on the - headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the - registry, a file cannot); `dimstyle` / `textstyle` when the backend has no - styles contract. On the live engine `annotation_scale` must name a scale in - the drawing's scale list (SCALELISTEDIT) and AutoCAD refuses the write on a + malformed `limits` or `annotation_scale`; `osmode` / `polar` / `polar_angle` + on the headless engine (`capability: registry_sysvar` — AutoCAD keeps them in + the registry, a file cannot, and a headless snapshot reports them as `None`); + `dimstyle` / `textstyle` when the backend has no styles contract. On the + live engine `annotation_scale` must name a scale in the drawing's scale list + (SCALELISTEDIT) and AutoCAD refuses the write on a paper-space layout with no active viewport; the headless engine adds the scale, seeding AutoCAD's default list first when the drawing has none, and refuses it on an R12 file (no OBJECTS section, so the value would diff --git a/tests/test_sysvar_catalog.py b/tests/test_sysvar_catalog.py index acba209..e55cd2b 100644 --- a/tests/test_sysvar_catalog.py +++ b/tests/test_sysvar_catalog.py @@ -1,14 +1,18 @@ """SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines. Every `engines["ezdxf"]` claim is checked against the real headless backend -(read, and write of the default where the variable is writable); every -`saved_in` that is not "drawing" is checked to be exactly the set the backend -refuses with `capability: registry_sysvar`. The catalogue cannot say a thing -the backend does not do. +(read, and - where the variable is writable - a write of a value that differs +from the fresh document's, followed by a save and a reopen, so a header slot +ezdxf holds in memory but never exports cannot pass); every `saved_in` that is +not "drawing" is checked to be exactly the set the backend refuses with +`capability: registry_sysvar`. The catalogue cannot say a thing the backend +does not do. """ from __future__ import annotations +import math + import pytest from backends.base import UnsupportedCapabilityError @@ -71,10 +75,47 @@ SPEC_NAMES = ( "ISOLINES FACETRES DISPSILH" ).split() -#: The two engines legitimately represent these differently (a character vs. -#: its code; a colour name vs. its ACI number), so their default cannot be -#: written verbatim headlessly. Everything else round-trips its default. -_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"} +#: A value that is not the fresh document's, for every writable headless entry +#: the generic rule below cannot pick one for: the two engines represent DIMDSEP +#: and CECOLOR differently (a character vs. its code; a colour name vs. its ACI +#: number), so the headless form is written; the name-valued ones must name +#: something the drawing has (`drawing_new` bootstraps GEOMETRY and CENTER; +#: `_set_annotation_scale` seeds AutoCAD's default scale list). +_PROBE_VALUES: dict[str, object] = { + "DIMDSEP": 44, # ',' as the character code the header holds (a fresh document has 46) + "CECOLOR": 1, # ACI red; the header holds the number + "CLAYER": "GEOMETRY", + "CELTYPE": "CENTER", + "CANNOSCALE": "1:2", +} + + +def _probe_value(var: SysVar, fresh): + """A legal value for ``var`` that differs from ``fresh``, so the write is not a no-op.""" + if var.name in _PROBE_VALUES: + return _PROBE_VALUES[var.name] + if var.type == "bool": + return 0 if int(fresh) else 1 + if var.type == "enum": + return next(code for code in var.enum if code != int(fresh)) + if var.type in ("int", "float"): + step = 1 if var.type == "int" else 1.0 + low, high = var.range if var.range is not None else (-math.inf, math.inf) + candidate = fresh + step + return candidate if candidate <= high else fresh - step + if var.type == "point": + return (float(fresh[0]) + 1.0, float(fresh[1]) + 2.0) + return f"{fresh}_PROBE" + + +def _same(var: SysVar, back, expected) -> bool: + if var.type == "point": + return tuple(float(v) for v in back[:2]) == tuple(float(v) for v in expected[:2]) + if var.type in ("bool", "int", "enum"): + return int(back) == int(expected) + if var.type == "float": + return float(back) == pytest.approx(float(expected)) + return back == expected # ── the data is complete ──────────────────────────────────────────────────── @@ -200,20 +241,79 @@ async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend): @asyncio_test -async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend): - for name, var in SYSVAR_CATALOG.items(): - if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS: - continue - await backend.system_set_variable(name, var.default) +async def test_every_writable_ezdxf_true_entry_survives_a_save_and_reopen(backend, tmp_path): + """The write must land in the file, not only in memory. + + The earlier form of this gate wrote each entry's *default* and read it back + without saving: 56 of its 66 writes were no-ops (default == fresh value) + and the rest could not tell a header slot ezdxf exports from one it holds + in memory and drops at save (`$OSMODE`, R12-only). Every write here moves + the value, and every read-back is from a reopened file. + """ + writable = { + name: var + for name, var in SYSVAR_CATALOG.items() + if var.engines["ezdxf"] and not var.read_only + } + probes: dict[str, object] = {} + for name, var in writable.items(): + fresh = await backend.system_get_variable(name) + probe = _probe_value(var, fresh) + assert not _same(var, fresh, probe), f"{name}: the probe is a no-op write" + out = await backend.system_set_variable(name, probe) + assert out["ok"] is True, name + probes[name] = probe + + path = str(tmp_path / "sysvars.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + + lost = {} + for name, probe in probes.items(): back = await backend.system_get_variable(name) - if var.type == "point": - assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name - elif var.type in ("bool", "int", "enum"): - assert int(back) == int(var.default), name - elif var.type == "float": - assert float(back) == pytest.approx(float(var.default)), name - else: - assert back == var.default, name + if back is None or not _same(writable[name], back, probe): + lost[name] = (probe, back) + assert lost == {}, f"engines.ezdxf is True but the value did not survive the file: {lost}" + + +@asyncio_test +async def test_osmode_is_registry_saved_and_the_headless_engine_says_so(backend): + """`$OSMODE` is an R12 header variable; ezdxf keeps it in memory and never + exports it for R2000+, so a write used to report `ok: True` and vanish at + save. It is registry-saved in AutoCAD and refused headlessly like POLARANG; + the read reports `None` instead of the template's memory.""" + row = describe_sysvar("OSMODE") + assert row["saved_in"] == "registry" and row["engines"] == {"ezdxf": False, "com": True} + assert await backend.system_get_variable("OSMODE") is None + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.system_set_variable("OSMODE", 4134) + assert excinfo.value.capability == "registry_sysvar" + # the facade follows: no guess in the snapshot, a per-key refusal on write + assert (await backend.drawing_settings())["settings"]["osmode"] is None + res = await backend.drawing_settings({"osmode": 4134}) + assert "registry" in res["errors"]["osmode"] + + +@asyncio_test +async def test_angbase_is_radians_at_the_boundary_and_degrees_in_the_file(backend, tmp_path): + """ActiveX `GetVariable("ANGBASE")` and AutoLISP `getvar` hold radians + (measured: `SETVAR ANGBASE 90` reads back 1.5707963267948966), while the + DXF header stores `$ANGBASE` in degrees (the same drawing saved by AutoCAD + carries 90.0). The headless engine translates, so both engines report the + same number and the catalogue's "radians" is true on both.""" + assert "radians" in describe_sysvar("ANGBASE")["meaning"] + assert "radians" in describe_sysvar("HPANG")["meaning"] + await backend.system_set_variable("ANGBASE", math.pi / 2) + assert backend._doc.header["$ANGBASE"] == pytest.approx(90.0) + assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2) + + path = str(tmp_path / "angbase.dxf") + await backend.drawing_save_as(path) + text = open(path, encoding="utf-8").read() + start = text.index("$ANGBASE") + assert "90.0" in text[start : start + 40], text[start : start + 40] + await backend.drawing_open(path) + assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2) @asyncio_test