fix(task-15): angle sysvars are radians on both engines; OSMODE is registry-saved and refused headlessly

Three authored claims in SYSVAR_CATALOG were false against the engines:

- HPANG said "degrees" but ActiveX GetVariable/SetVariable (and AutoLISP
  getvar) hold radians - measured on AutoCAD 2026 in a scratch document:
  SetVariable(pi/4) reads back 0.7853981633974483 on both paths. The row
  now says radians, like POLARANG.
- ANGBASE said "radians" with both engines True, but the DXF header stores
  $ANGBASE in degrees (AutoCAD saved the 1.5708-radian seat value as 90.0)
  and ezdxf read/wrote it raw, so the two engines disagreed by a factor of
  57.3. EzdxfBackend now translates that header variable at its boundary
  (_HEADER_DEGREE_SYSVARS): the tool speaks radians on both engines, the
  file keeps degrees.
- OSMODE claimed engines.ezdxf True, but $OSMODE is an R12-only header
  variable ezdxf never exports for R2000+: a headless write reported
  ok: True and read back None after save + reopen. It is registry-saved
  in AutoCAD, so it joins _UNSAVED_SYSVARS (refused with
  capability: registry_sysvar; the facade's `osmode` key refuses and
  snapshots None headlessly, like polar / polar_angle). Headless reads of
  every registry-saved variable now report None instead of whatever the
  ezdxf template holds in memory.

The gate that should have caught all three, tests/test_sysvar_catalog.py's
default round-trip, wrote 56 no-ops out of 66 and never saved: it now
writes a value that differs from the fresh document's for every writable
ezdxf-True entry, saves, reopens and reads back from the file. Two new
tests pin OSMODE's refusal and ANGBASE's radians-at-the-tool /
degrees-in-the-file split.

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-17 15:20:02 +03:00
co-authored by Claude Opus 5
parent 125a8ed3b8
commit 75c13196b9
5 changed files with 175 additions and 37 deletions
+5 -2
View File
@@ -44,8 +44,9 @@ class SettingsContract:
unknown key; a value outside ``_SETTING_RANGES``; a malformed unknown key; a value outside ``_SETTING_RANGES``; a malformed
``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file ``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file
headlessly (no OBJECTS section, so the value would vanish at save — headlessly (no OBJECTS section, so the value would vanish at save —
save as R2000 or newer first); ``polar`` / ``polar_angle`` on the save as R2000 or newer first); ``osmode`` / ``polar`` / ``polar_angle``
headless engine (registry-saved — ``capability: registry_sysvar``); 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. ``dimstyle`` / ``textstyle`` when the backend has no styles contract.
Note that a no-argument snapshot is one ``system_get_variable`` per key 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"), "text_size": ("TEXTSIZE", "float"),
"point_mode": ("PDMODE", "int"), "point_mode": ("PDMODE", "int"),
"point_size": ("PDSIZE", "float"), "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"), "osmode": ("OSMODE", "int"),
"fillet_radius": ("FILLETRAD", "float"), "fillet_radius": ("FILLETRAD", "float"),
# ── track E: the environment a drawing lives in ────────────────────── # ── track E: the environment a drawing lives in ──────────────────────
+24 -3
View File
@@ -73,12 +73,18 @@ _VPORT_SYSVARS: dict[str, tuple[str, str]] = {
#: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved #: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved
#: in" column). A file has nowhere to keep them, so the headless engine refuses #: 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 #: 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( _UNSAVED_SYSVARS: frozenset[str] = frozenset(
{ {
"AUTOSNAP", "AUTOSNAP",
"POLARANG", "POLARANG",
"POLARMODE", "POLARMODE",
"OSMODE",
"ATTREQ", "ATTREQ",
"ATTDIA", "ATTDIA",
"SAVETIME", "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__) log = logging.getLogger(__name__)
@@ -5573,7 +5586,12 @@ class EzdxfBackend(AutoCADBackend):
return self._dictionary_variable(doc, "CANNOSCALE", "1:1") return self._dictionary_variable(doc, "CANNOSCALE", "1:1")
if key == "CANNOSCALEVALUE": if key == "CANNOSCALEVALUE":
return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1")) 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) return await self._async(_sync)
@@ -5607,8 +5625,11 @@ class EzdxfBackend(AutoCADBackend):
"CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead." "CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead."
) )
else: else:
stored = value
if key in _HEADER_DEGREE_SYSVARS:
stored = math.degrees(float(value))
try: try:
doc.header[f"${key}"] = value doc.header[f"${key}"] = stored
except DXFKeyError as exc: except DXFKeyError as exc:
# Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py) # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py)
# while saying why the write is refused rather than dropped. # while saying why the write is refused rather than dropped.
+19 -6
View File
@@ -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 dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved
and never-saved variables are ``False`` because a file has nowhere to keep and never-saved variables are ``False`` because a file has nowhere to keep
them, and ``EzdxfBackend.system_set_variable`` refuses them with them, and ``EzdxfBackend.system_set_variable`` refuses them with
``capability: registry_sysvar``. A handful of drawing-saved variables are also ``capability: registry_sysvar`` (OSMODE is one of them: ``$OSMODE`` is an R12
``False`` because ezdxf 1.4 has no header slot for them (CTAB, VIEWCTR, header variable that ezdxf never exports for R2000+, so a headless write
VIEWSIZE, ISOLINES, FACETRES); that refusal is a plain ``ValueError``. 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 / Sources: the AutoCAD 2026 System Variables reference (type / saved-in /
initial value columns), the DXF Reference HEADER and VPORT sections, and initial value columns), the DXF Reference HEADER and VPORT sections, and
@@ -464,9 +476,10 @@ _ENTRIES: tuple[SysVar, ...] = (
_v( _v(
"OSMODE", "OSMODE",
"int", "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), range_=(0, 32767),
default=4133, default=4133,
saved_in="registry",
friendly_key="osmode", friendly_key="osmode",
), ),
# ── current properties ─────────────────────────────────────────────────── # ── current properties ───────────────────────────────────────────────────
@@ -543,7 +556,7 @@ _ENTRIES: tuple[SysVar, ...] = (
_v( _v(
"ANGBASE", "ANGBASE",
"float", "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, default=0.0,
), ),
_v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0), _v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0),
@@ -655,7 +668,7 @@ _ENTRIES: tuple[SysVar, ...] = (
_v( _v(
"HPANG", "HPANG",
"float", "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, default=0.0,
saved_in="not_saved", saved_in="not_saved",
), ),
+6 -5
View File
@@ -5093,11 +5093,12 @@ async def drawing_settings(
Refusals, per key, nothing else rolled back: an unknown key; a value outside 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 its range (grid/snap spacing > 0, polar_angle 0–360, precision 0–8, …); a
malformed `limits` or `annotation_scale`; `polar` / `polar_angle` on the malformed `limits` or `annotation_scale`; `osmode` / `polar` / `polar_angle`
headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the on the headless engine (`capability: registry_sysvar` — AutoCAD keeps them in
registry, a file cannot); `dimstyle` / `textstyle` when the backend has no the registry, a file cannot, and a headless snapshot reports them as `None`);
styles contract. On the live engine `annotation_scale` must name a scale in `dimstyle` / `textstyle` when the backend has no styles contract. On the
the drawing's scale list (SCALELISTEDIT) and AutoCAD refuses the write on a 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 paper-space layout with no active viewport; the headless engine adds the
scale, seeding AutoCAD's default list first when the drawing has none, 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 and refuses it on an R12 file (no OBJECTS section, so the value would
+121 -21
View File
@@ -1,14 +1,18 @@
"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines. """SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines.
Every `engines["ezdxf"]` claim is checked against the real headless backend Every `engines["ezdxf"]` claim is checked against the real headless backend
(read, and write of the default where the variable is writable); every (read, and - where the variable is writable - a write of a value that differs
`saved_in` that is not "drawing" is checked to be exactly the set the backend from the fresh document's, followed by a save and a reopen, so a header slot
refuses with `capability: registry_sysvar`. The catalogue cannot say a thing ezdxf holds in memory but never exports cannot pass); every `saved_in` that is
the backend does not do. 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 from __future__ import annotations
import math
import pytest import pytest
from backends.base import UnsupportedCapabilityError from backends.base import UnsupportedCapabilityError
@@ -71,10 +75,47 @@ SPEC_NAMES = (
"ISOLINES FACETRES DISPSILH" "ISOLINES FACETRES DISPSILH"
).split() ).split()
#: The two engines legitimately represent these differently (a character vs. #: A value that is not the fresh document's, for every writable headless entry
#: its code; a colour name vs. its ACI number), so their default cannot be #: the generic rule below cannot pick one for: the two engines represent DIMDSEP
#: written verbatim headlessly. Everything else round-trips its default. #: and CECOLOR differently (a character vs. its code; a colour name vs. its ACI
_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"} #: 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 ──────────────────────────────────────────────────── # ── the data is complete ────────────────────────────────────────────────────
@@ -200,20 +241,79 @@ async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend):
@asyncio_test @asyncio_test
async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend): async def test_every_writable_ezdxf_true_entry_survives_a_save_and_reopen(backend, tmp_path):
for name, var in SYSVAR_CATALOG.items(): """The write must land in the file, not only in memory.
if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS:
continue The earlier form of this gate wrote each entry's *default* and read it back
await backend.system_set_variable(name, var.default) 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) back = await backend.system_get_variable(name)
if var.type == "point": if back is None or not _same(writable[name], back, probe):
assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name lost[name] = (probe, back)
elif var.type in ("bool", "int", "enum"): assert lost == {}, f"engines.ezdxf is True but the value did not survive the file: {lost}"
assert int(back) == int(var.default), name
elif var.type == "float":
assert float(back) == pytest.approx(float(var.default)), name @asyncio_test
else: async def test_osmode_is_registry_saved_and_the_headless_engine_says_so(backend):
assert back == var.default, name """`$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 @asyncio_test