fix(task-14): seed AutoCAD's default scale list before adding an annotation scale headlessly

An ezdxf-authored drawing has no ACAD_SCALELIST entries, and
`_ensure_scale_entry` appended only the requested scale. AutoCAD repopulates
its default list only when the dictionary is *empty*, so the saved file
opened with a one-entry scale list and `SetVariable("CANNOSCALE", "1:1")`
failed with `Error setting system variable` -- the annotation scale could
no longer be changed in AutoCAD, while the tool reported `ok: True` and the
test pinned the degraded state (`len(keys) == 1`).

An empty list is now seeded with AutoCAD's own default metric list before
the requested scale is considered: the 17 entries a fresh AutoCAD 2026
drawing carries (`Documents.Add()` saved as DXF, MEASUREMENT 1), in that
order and under AutoCAD's own keys A0 ... A9, B0 ... B6. New entries take
the next key in that sequence (`_scale_list_key`) instead of being keyed by
their name; presence is still decided by group 300, so a non-empty
AutoCAD-authored list is never re-seeded or duplicated. Verified live: the
headless file now opens in AutoCAD 2026 with the 17 keys, CANNOSCALE 1:50
current, and switching to 1:1 and 1:100 works.

Tests: the fresh-drawing test asserts the seeded names and keys, that
setting 1:50 appends nothing (it is a default), that 1:25 lands under B7
once, and that the list survives save + reopen with 1:1 present; a new test
pins that *reading* the annotation scale creates nothing (the read side
stays pure, AutoCAD fills an empty list itself); the AutoCAD-keyed list test
now expects the A3 key. The `drawing_settings` docstring says what each
engine does and that AutoCAD refuses the live write on a paper-space layout
with no active viewport (measured).

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 09:49:38 +03:00
co-authored by Claude Opus 5
parent 33604c4408
commit 909bc47e5e
3 changed files with 186 additions and 39 deletions
+66 -22
View File
@@ -5954,39 +5954,52 @@ class EzdxfBackend(AutoCADBackend):
return str(tag.value) return str(tag.value)
return None return None
@classmethod # AutoCAD's default metric ACAD_SCALELIST, in its own order (measured:
def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool: # AutoCAD 2026, MEASUREMENT 1, `Documents.Add()` saved as DXF — 17 SCALE
"""Add `name` to ACAD_SCALELIST unless present; True when added. # objects keyed A0 … A9, B0 … B6). An imperial seat (MEASUREMENT 0) adds the
# architectural fractions on top; those are not seeded — every ratio here
# is valid in either unit system, and AutoCAD's SCALELISTEDIT can add more.
_DEFAULT_SCALE_LIST: tuple[str, ...] = (
"1:1",
"1:2",
"1:4",
"1:5",
"1:8",
"1:10",
"1:16",
"1:20",
"1:30",
"1:40",
"1:50",
"1:100",
"2:1",
"4:1",
"8:1",
"10:1",
"100:1",
)
Presence is decided by the SCALE objects' group-300 names, never by @staticmethod
the dictionary key — an AutoCAD-authored drawing keys the list `A0`, def _scale_list_key(index: int) -> str:
`A1`, … (its default metric list already carries 1:1 … 1:100, 2:1), """The dictionary key AutoCAD gives the ``index``-th SCALE: A0 … A9, B0 …"""
and matching on the key appended a duplicate `1:50` to every one of return f"{chr(ord('A') + index // 10)}{index % 10}"
them. A new entry is keyed by its name unless that key is taken, in
which case it takes the next free `A<n>` slot like AutoCAD does. @classmethod
def _add_scale_object(cls, doc, scales, name: str, paper: float, drawing: float) -> None:
"""Author one SCALE object and file it under the next free AutoCAD-style key.
ezdxf has no SCALE entity class, so the object is authored as raw tags ezdxf has no SCALE entity class, so the object is authored as raw tags
and loaded through the factory (a `DXFTagStorage` that exports and loaded through the factory (a `DXFTagStorage` that exports
verbatim). Measured: it round-trips, ezdxf registers the CLASS, and verbatim). Measured: it round-trips, ezdxf registers the CLASS, and
`doc.audit()` is clean after reopen. AutoCAD only honours a CANNOSCALE `doc.audit()` is clean after reopen.
that names an entry of this list.
""" """
from ezdxf.entities import factory from ezdxf.entities import factory
from ezdxf.lldxf.extendedtags import ExtendedTags from ezdxf.lldxf.extendedtags import ExtendedTags
scales = doc.rootdict.get("ACAD_SCALELIST")
if scales is None:
scales = doc.rootdict.add_new_dict("ACAD_SCALELIST")
existing_keys = set(scales.keys()) existing_keys = set(scales.keys())
for _key, entry in scales.items():
if cls._scale_entry_name(entry) == name:
return False
key = name
if key in existing_keys:
index = 0 index = 0
while f"A{index}" in existing_keys: while cls._scale_list_key(index) in existing_keys:
index += 1 index += 1
key = f"A{index}"
handle = doc.entitydb.next_handle() handle = doc.entitydb.next_handle()
unit = 1 if paper == drawing else 0 unit = 1 if paper == drawing else 0
text = ( text = (
@@ -5996,7 +6009,38 @@ class EzdxfBackend(AutoCADBackend):
entry = factory.load(ExtendedTags.from_text(text), doc) entry = factory.load(ExtendedTags.from_text(text), doc)
doc.entitydb.add(entry) doc.entitydb.add(entry)
doc.objects.add_object(entry) doc.objects.add_object(entry)
scales.add(key, entry) scales.add(cls._scale_list_key(index), entry)
@classmethod
def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool:
"""Add `name` to ACAD_SCALELIST unless present; True when added.
Presence is decided by the SCALE objects' group-300 names, never by
the dictionary key — an AutoCAD-authored drawing keys the list `A0`,
`A1`, … and only group 300 says `1:50`; matching on the key appended a
duplicate `1:50` to every one of them.
An ezdxf-authored drawing has **no** ACAD_SCALELIST entries at all.
AutoCAD repopulates its default list only when the dictionary is
empty, so appending the one requested scale to an empty list shipped a
file whose scale list *was* that one entry — measured (AutoCAD 2026):
`SetVariable("CANNOSCALE", "1:1")` then fails with `Error setting
system variable` and the annotation scale cannot be changed in the
UI. An empty list is therefore seeded with AutoCAD's own default list
first, so the saved file offers what a fresh AutoCAD drawing offers.
AutoCAD only honours a CANNOSCALE that names an entry of this list.
"""
scales = doc.rootdict.get("ACAD_SCALELIST")
if scales is None:
scales = doc.rootdict.add_new_dict("ACAD_SCALELIST")
if len(scales) == 0:
for default in cls._DEFAULT_SCALE_LIST:
_, default_paper, default_drawing = parse_scale(default)
cls._add_scale_object(doc, scales, default, default_paper, default_drawing)
for _key, entry in scales.items():
if cls._scale_entry_name(entry) == name:
return False
cls._add_scale_object(doc, scales, name, paper, drawing)
return True return True
def _set_annotation_scale(self, doc, value: Any) -> str: def _set_annotation_scale(self, doc, value: Any) -> str:
+3 -1
View File
@@ -5082,7 +5082,9 @@ async def drawing_settings(
headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the
registry, a file cannot); `dimstyle` / `textstyle` when the backend has no registry, a file cannot); `dimstyle` / `textstyle` when the backend has no
styles contract. On the live engine `annotation_scale` must name a scale in styles contract. On the live engine `annotation_scale` must name a scale in
the drawing's scale list (SCALELISTEDIT); the headless engine adds it. 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.
Headlessly, grid/snap are stored on the active VPORT and the annotation Headlessly, grid/snap are stored on the active VPORT and the annotation
scale in the variable dictionary — the places AutoCAD reads them from. scale in the variable dictionary — the places AutoCAD reads them from.
""" """
+115 -14
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json import json
import math import math
from types import SimpleNamespace
import pytest import pytest
@@ -156,13 +157,30 @@ async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(ba
vardict = backend._doc.rootdict.get("AcDbVariableDictionary") vardict = backend._doc.rootdict.get("AcDbVariableDictionary")
assert vardict["CANNOSCALE"].dxf.value == "1:50" assert vardict["CANNOSCALE"].dxf.value == "1:50"
scales = backend._doc.rootdict.get("ACAD_SCALELIST") scales = backend._doc.rootdict.get("ACAD_SCALELIST")
assert scales.get("1:50") is not None, "AutoCAD only honours a scale that is in the list" assert "1:50" in _scale_names(scales), "AutoCAD only honours a scale that is in the list"
assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02) assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02)
# Regression: the list used to be *only* the requested scale. AutoCAD
# repopulates its default list only when the dictionary is empty, so the
# saved file opened with a one-entry scale list and `CANNOSCALE` could no
# longer be changed there (measured, AutoCAD 2026). An empty list is seeded
# with AutoCAD's default metric list, keyed and ordered the way AutoCAD
# writes it, so 1:50 was already there and nothing was appended.
assert _scale_names(scales) == list(_AUTOCAD_DEFAULT_SCALES)
assert list(scales.keys()) == _AUTOCAD_DEFAULT_KEYS
# re-setting the same scale is not a change and adds no second list entry # re-setting the same scale is not a change and adds no second list entry
again = await backend.drawing_settings({"annotation_scale": "1:50"}) again = await backend.drawing_settings({"annotation_scale": "1:50"})
assert again["changed"] == {} assert again["changed"] == {}
assert len(list(scales.keys())) == 1 assert len(list(scales.keys())) == 17
# a scale outside the default list is appended once, under the next key
# AutoCAD itself would use, and the defaults are not seeded twice
res = await backend.drawing_settings({"annotation_scale": "1:25"})
assert res["ok"] is True, res.get("errors")
assert _scale_names(scales) == [*_AUTOCAD_DEFAULT_SCALES, "1:25"]
assert list(scales.keys()) == [*_AUTOCAD_DEFAULT_KEYS, "B7"]
await backend.drawing_settings({"annotation_scale": "1:50"})
path = str(tmp_path / "scale.dxf") path = str(tmp_path / "scale.dxf")
await backend.drawing_save_as(path) await backend.drawing_save_as(path)
@@ -171,9 +189,32 @@ async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(ba
"name": "1:50", "name": "1:50",
"value": 0.02, "value": 0.02,
} }
reopened = backend._doc.rootdict.get("ACAD_SCALELIST")
assert _scale_names(reopened) == [*_AUTOCAD_DEFAULT_SCALES, "1:25"]
assert "1:1" in _scale_names(reopened), "AutoCAD refuses a list without its unit scale"
assert backend._doc.audit().has_errors is False assert backend._doc.audit().has_errors is False
async def test_reading_the_annotation_scale_never_creates_the_scale_list(backend):
"""The read side stays pure: a fresh headless drawing keeps its empty
ACAD_SCALELIST (AutoCAD fills it on open) until a scale is written."""
assert (await backend.drawing_settings())["settings"]["annotation_scale"] == {
"name": "1:1",
"value": 1.0,
}
scales = backend._doc.rootdict.get("ACAD_SCALELIST")
assert scales is None or len(list(scales.keys())) == 0
# AutoCAD 2026's default metric list, in its own order and under its own keys
# (`Documents.Add()` saved as DXF, MEASUREMENT 1): 17 SCALE objects, A0 … B6.
_AUTOCAD_DEFAULT_SCALES = (
"1:1", "1:2", "1:4", "1:5", "1:8", "1:10", "1:16", "1:20", "1:30", "1:40",
"1:50", "1:100", "2:1", "4:1", "8:1", "10:1", "100:1",
) # fmt: skip
_AUTOCAD_DEFAULT_KEYS = [f"A{i}" for i in range(10)] + [f"B{i}" for i in range(7)]
def _seed_autocad_style_scale_list(doc, names): def _seed_autocad_style_scale_list(doc, names):
"""ACAD_SCALELIST the way AutoCAD saves it: keyed A0, A1, ... with the scale """ACAD_SCALELIST the way AutoCAD saves it: keyed A0, A1, ... with the scale
name only in the SCALE object's group 300 (the default metric list already name only in the SCALE object's group 300 (the default metric list already
@@ -222,11 +263,13 @@ async def test_annotation_scale_matches_an_autocad_keyed_scale_list_by_name(back
assert _scale_names(scales).count("1:50") == 1 assert _scale_names(scales).count("1:50") == 1
assert backend._doc.rootdict.get("AcDbVariableDictionary")["CANNOSCALE"].dxf.value == "1:50" assert backend._doc.rootdict.get("AcDbVariableDictionary")["CANNOSCALE"].dxf.value == "1:50"
# a scale the list does not carry is still appended, exactly once, under a # a scale the list does not carry is still appended, exactly once, under
# key that does not collide with AutoCAD's A<n> keys # the next key in AutoCAD's own A0 … A9, B0 … sequence; a non-empty list
# is never re-seeded with the defaults
res = await backend.drawing_settings({"annotation_scale": "1:25"}) res = await backend.drawing_settings({"annotation_scale": "1:25"})
assert res["ok"] is True, res.get("errors") assert res["ok"] is True, res.get("errors")
assert _scale_names(scales).count("1:25") == 1 assert _scale_names(scales).count("1:25") == 1
assert list(scales.keys()) == ["A0", "A1", "A2", "A3"]
assert len(list(scales.keys())) == 4 assert len(list(scales.keys())) == 4
await backend.drawing_settings({"annotation_scale": "1:25"}) await backend.drawing_settings({"annotation_scale": "1:25"})
assert len(list(scales.keys())) == 4 assert len(list(scales.keys())) == 4
@@ -383,6 +426,24 @@ async def test_server_tool_carries_the_new_keys(backend):
class _FakeApp: class _FakeApp:
"""The ActiveX *Application*: it has an ActiveDocument and no sysvar
members at all. `GetVariable` / `SetVariable` live on `AcadDocument`;
a backend that calls them on the Application raises `AttributeError:
AutoCAD.Application.GetVariable` on a live seat before anything reaches
AutoCAD (measured, AutoCAD 2026) -- so this fake refuses them the same way
instead of quietly answering."""
def __init__(self):
self.ActiveDocument = _FakeDoc()
self.Documents = SimpleNamespace(Count=1)
def __getattr__(self, name):
if name in ("GetVariable", "SetVariable"):
raise AttributeError(f"AutoCAD.Application.{name}")
raise AttributeError(name)
class _FakeDoc:
"""Records GetVariable / SetVariable; stores what AutoCAD would store.""" """Records GetVariable / SetVariable; stores what AutoCAD would store."""
def __init__(self): def __init__(self):
@@ -425,6 +486,8 @@ def com_backend(monkeypatch):
from backends import com_backend as module from backends import com_backend as module
app = _FakeApp() app = _FakeApp()
# Only the Application is faked: the real `_acad_doc()` runs and resolves
# `ActiveDocument`, so the test exercises the true call path.
monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend() backend = module.ComBackend()
@@ -432,11 +495,11 @@ def com_backend(monkeypatch):
return func(*args, **kwargs) return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline) monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app return backend, app.ActiveDocument
async def test_com_facade_sends_autocad_shaped_values(com_backend): async def test_com_facade_sends_autocad_shaped_values(com_backend):
backend, app = com_backend backend, doc = com_backend
res = await backend.drawing_settings( res = await backend.drawing_settings(
{ {
"limits": [[0, 0], [500, 350]], "limits": [[0, 0], [500, 350]],
@@ -450,7 +513,7 @@ async def test_com_facade_sends_autocad_shaped_values(com_backend):
} }
) )
assert res["ok"] is True, res.get("errors") assert res["ok"] is True, res.get("errors")
sets = [call[1:] for call in app.calls if call[0] == "SetVariable"] sets = [call[1:] for call in doc.calls if call[0] == "SetVariable"]
assert sets == [ assert sets == [
("LIMMIN", (0.0, 0.0)), ("LIMMIN", (0.0, 0.0)),
("LIMMAX", (500.0, 350.0)), ("LIMMAX", (500.0, 350.0)),
@@ -469,15 +532,15 @@ async def test_com_facade_sends_autocad_shaped_values(com_backend):
async def test_com_point_variables_travel_as_variant_double_arrays(com_backend): async def test_com_point_variables_travel_as_variant_double_arrays(com_backend):
import pythoncom import pythoncom
backend, app = com_backend backend, doc = com_backend
seen: dict = {} seen: dict = {}
real_set = app.SetVariable real_set = doc.SetVariable
def _capture(name, value): def _capture(name, value):
seen[name] = value seen[name] = value
real_set(name, value) real_set(name, value)
app.SetVariable = _capture doc.SetVariable = _capture
res = await backend.system_set_variable("LIMMAX", (300, 200)) res = await backend.system_set_variable("LIMMAX", (300, 200))
variant = seen["LIMMAX"] variant = seen["LIMMAX"]
assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8 assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8
@@ -498,7 +561,7 @@ async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backe
import server import server
backend, app = com_backend backend, doc = com_backend
async def _fake_make_backend(): async def _fake_make_backend():
return backend return backend
@@ -507,11 +570,49 @@ async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backe
async with Client(server.mcp) as client: async with Client(server.mcp) as client:
res = await client.call_tool("system_set_variable", {"name": "LIMMAX", "value": [300, 200]}) res = await client.call_tool("system_set_variable", {"name": "LIMMAX", "value": [300, 200]})
assert res.data == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]} assert res.data == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]}
assert app.store["LIMMAX"] == (300.0, 200.0) assert doc.store["LIMMAX"] == (300.0, 200.0)
async def test_com_refuses_before_touching_activex(com_backend): async def test_com_refuses_before_touching_activex(com_backend):
backend, app = com_backend backend, doc = com_backend
res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"}) res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"})
assert res["ok"] is False assert res["ok"] is False
assert not [call for call in app.calls if call[0] == "SetVariable"] assert not [call for call in doc.calls if call[0] == "SetVariable"]
async def test_com_sysvars_are_read_and_written_on_the_document(com_backend):
"""Regression: both methods called `GetVariable` / `SetVariable` on the
object `_acad_app()` returns, which has neither, so every
`drawing_settings` read and write on the live engine died with
`AttributeError: AutoCAD.Application.GetVariable`."""
backend, doc = com_backend
assert await backend.system_get_variable("LIMMAX") == (420.0, 297.0)
res = await backend.system_set_variable("GRIDMODE", "1")
assert res == {"ok": True, "variable": "GRIDMODE", "value": 1}
assert doc.store["GRIDMODE"] == 1
assert doc.calls == [
("GetVariable", "LIMMAX"),
("GetVariable", "GRIDMODE"), # the type probe
("SetVariable", "GRIDMODE", 1),
]
async def test_com_sysvars_need_an_open_drawing(monkeypatch):
"""With no document open there is nothing to read the variable from:
`_acad_doc()`'s clear refusal is what surfaces, not an ActiveX error."""
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
app = _FakeApp()
app.Documents = SimpleNamespace(Count=0)
monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
with pytest.raises(RuntimeError, match="No drawing is open"):
await backend.system_get_variable("LIMMAX")
with pytest.raises(RuntimeError, match="No drawing is open"):
await backend.system_set_variable("GRIDMODE", 1)