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:
co-authored by
Claude Opus 5
parent
125a8ed3b8
commit
75c13196b9
@@ -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 ──────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
+121
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user