feat(settings): drawing_settings gains limits, grid/snap, ortho/polar, psltscale, annotation scale, unit formats and current styles

Headlessly, grid/snap are stored on the active VPORT, CANNOSCALE in
AcDbVariableDictionary (+ an ACAD_SCALELIST entry), and LIMMIN/LIMMAX on
the model-space layout as well as the header — measured: the header alone
is overwritten at save. Registry-saved variables (AUTOSNAP, POLARANG, …)
are refused with capability registry_sysvar instead of vanishing.

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-16 21:14:31 +03:00
co-authored by Claude Opus 5
parent 49fc6ac6a5
commit df39b21f2e
6 changed files with 944 additions and 22 deletions
+8
View File
@@ -814,6 +814,7 @@ class ComBackend(AutoCADBackend):
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
"registry_sysvar": FeatureCapability(True, "native"),
},
)
@@ -4010,6 +4011,13 @@ class ComBackend(AutoCADBackend):
coerced = int(float(value)) if isinstance(value, str) else int(value)
elif isinstance(current, float):
coerced = float(value)
elif isinstance(current, (tuple, list)):
# 2D/3D point variables (LIMMIN, GRIDUNIT, SNAPUNIT, ...) travel
# as VARIANT double arrays of the length AutoCAD itself reports;
# a bare Python tuple is marshalled as VT_VARIANT and rejected.
coords = [float(v) for v in value]
coords += [0.0] * (len(current) - len(coords))
coerced = _av(coords[: len(current)])
except Exception as exc:
log.debug("set_variable type probe for %s failed: %s", name, exc)
app.SetVariable(name, coerced)
+294 -13
View File
@@ -3,10 +3,19 @@
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
v1.6 (track E) grows the facade from 15 keys to 29: drawing limits, grid and
snap, ortho and polar tracking, PSLTSCALE, the annotation scale, linear and
angular unit formats, and the current dimension / text style. Every key still
goes through ``system_get_variable`` / ``system_set_variable`` so the facade
stays backend-agnostic; the *engines* are where a variable's real home is
known (header, VPORT, the variable dictionary, or — headlessly — nowhere).
"""
from __future__ import annotations
import math
import re
from typing import Any
@@ -20,46 +29,94 @@ class SettingsContract:
"""Read (no args) or apply (with args) common drawing settings.
With ``settings=None`` returns a snapshot of every known setting. With a
dict, applies each provided key and returns ``{applied, errors, current}``.
dict, applies each provided key and returns ``{applied, changed,
errors}`` — ``changed`` holds ``[old, new]`` only for keys whose value
actually moved (re-setting a value to itself is not a change).
Friendly keys: units, linear_precision, angular_precision, ltscale,
dimscale, dim_text_height, dim_arrow_size, dim_decimals,
decimal_separator, zero_suppression, text_size, point_mode, point_size,
osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the
INSUNITS code) and `decimal_separator` accepts "." or ",".
osmode, fillet_radius, limits, grid, grid_spacing, snap, snap_spacing,
ortho, polar, polar_angle, psltscale, annotation_scale, linear_units,
angular_units, dimstyle, textstyle.
Note that a no-argument snapshot is one ``system_get_variable`` per key.
Free headless; a COM round trip each on a live seat."""
Refusals (per key, in ``errors``; the other keys still apply): an
unknown key; a value outside ``_SETTING_RANGES``; a malformed
``limits`` / ``annotation_scale``; ``polar`` / ``polar_angle`` on the
headless engine (registry-saved — ``capability: registry_sysvar``);
``dimstyle`` / ``textstyle`` when the backend has no styles contract.
Note that a no-argument snapshot is one ``system_get_variable`` per key
(two for ``limits``). Free headless; a COM round trip each on a live
seat, and a write costs a read before and after it for ``changed``."""
if not settings:
snapshot: dict = {}
for key, (var, kind) in _SETTING_MAP.items():
for key, spec in _SETTING_MAP.items():
try:
raw = await self.system_get_variable(var)
snapshot[key] = await self._read_setting(key, spec)
except Exception as exc:
snapshot[key] = {"error": str(exc)}
continue
snapshot[key] = _decode_setting(key, kind, raw)
return {"ok": True, "settings": snapshot}
applied: dict = {}
changed: dict = {}
errors: dict = {}
for key, value in settings.items():
spec = _SETTING_MAP.get(key)
if spec is None:
errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
continue
var, kind = spec
try:
encoded = _encode_setting(key, kind, value)
await self.system_set_variable(var, encoded)
before = await self._read_setting(key, spec)
await self._write_setting(key, spec, value)
after = await self._read_setting(key, spec)
applied[key] = value
if after != before:
changed[key] = [before, after]
except Exception as exc:
errors[key] = str(exc)
result = {"ok": not errors, "applied": applied}
result = {"ok": not errors, "applied": applied, "changed": changed}
if errors:
result["errors"] = errors
return result
async def _read_setting(self, key: str, spec: tuple[str, str]) -> Any:
var, kind = spec
if kind == "limits":
low = await self.system_get_variable("LIMMIN")
high = await self.system_get_variable("LIMMAX")
if low is None or high is None:
return None
return [_point2(low), _point2(high)]
return _decode_setting(key, kind, await self.system_get_variable(var))
async def _write_setting(self, key: str, spec: tuple[str, str], value: Any) -> None:
var, kind = spec
if kind == "limits":
low, high = _encode_limits(key, value) # validated before either write
await self.system_set_variable("LIMMIN", low)
await self.system_set_variable("LIMMAX", high)
return
if kind == "name":
name = _encode_name(key, value)
setter = getattr(self, _STYLE_SETTERS[key], None)
if not callable(setter):
raise ValueError(
f"{key}: this backend has no {_STYLE_SETTERS[key]} (styles contract "
f"absent); the current {key} can be read but not changed here."
)
await setter(name)
return
if kind == "bit":
flag = _encode_bool(key, value)
raw = await self.system_get_variable(var)
current = int(raw) if raw is not None else 0
mask = _BIT_MASKS[key]
await self.system_set_variable(var, (current | mask) if flag else (current & ~mask))
return
await self.system_set_variable(var, _encode_setting(key, kind, value))
# ---------------------------------------------------------------------------
# Helpers
@@ -88,8 +145,43 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
"point_size": ("PDSIZE", "float"),
"osmode": ("OSMODE", "int"),
"fillet_radius": ("FILLETRAD", "float"),
# ── track E: the environment a drawing lives in ──────────────────────
# `limits` is one friendly key over two variables; `_read_setting` and
# `_write_setting` special-case the kind, the variable named here is the
# first of the pair.
"limits": ("LIMMIN", "limits"),
# GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT are not header variables in a
# DXF file — AutoCAD keeps them on the active VPORT — so the headless engine
# routes them there (see `EzdxfBackend.system_set_variable`).
"grid": ("GRIDMODE", "bool"),
"grid_spacing": ("GRIDUNIT", "spacing"),
"snap": ("SNAPMODE", "bool"),
"snap_spacing": ("SNAPUNIT", "spacing"),
"ortho": ("ORTHOMODE", "bool"),
# Polar tracking on/off is AUTOSNAP bit 8 (the F10 toggle), not POLARMODE
# — POLARMODE's bits choose *how* polar angles are measured. Both AUTOSNAP
# and POLARANG are registry-saved, so a headless file cannot hold them.
"polar": ("AUTOSNAP", "bit"),
"polar_angle": ("POLARANG", "degrees"),
"psltscale": ("PSLTSCALE", "bool"),
# CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary, not a header
# variable; CANNOSCALEVALUE is derived from it and read-only.
"annotation_scale": ("CANNOSCALE", "scale"),
"linear_units": ("LUNITS", "lunits"),
"angular_units": ("AUNITS", "aunits"),
# The current style names read through the header / GetVariable; a write
# delegates to the styles contract (`dimstyle_set_current` /
# `textstyle_set_current`) because DIMSTYLE is a read-only variable.
"dimstyle": ("DIMSTYLE", "name"),
"textstyle": ("TEXTSTYLE", "name"),
}
_STYLE_SETTERS = {"dimstyle": "dimstyle_set_current", "textstyle": "textstyle_set_current"}
#: AUTOSNAP bit 8 = polar tracking on. (1 marker, 2 magnet, 4 tooltip, 16
#: object snap tracking, 32 tracking tooltips, 64 dynamic input aperture.)
_BIT_MASKS = {"polar": 8}
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
_UNIT_TO_CODE: dict[str, int] = {
"unitless": 0,
@@ -111,6 +203,24 @@ _UNIT_TO_CODE: dict[str, int] = {
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
# LUNITS (linear unit format) and AUNITS (angular unit format) code tables.
_LUNITS_TO_CODE: dict[str, int] = {
"scientific": 1,
"decimal": 2,
"engineering": 3,
"architectural": 4,
"fractional": 5,
}
_CODE_TO_LUNITS: dict[int, str] = {code: name for name, code in _LUNITS_TO_CODE.items()}
_AUNITS_TO_CODE: dict[str, int] = {
"degrees": 0,
"dms": 1,
"grads": 2,
"radians": 3,
"surveyor": 4,
}
_CODE_TO_AUNITS: dict[int, str] = {code: name for name, code in _AUNITS_TO_CODE.items()}
#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
#: on a string in the header — so the friendly key is where the translation
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
@@ -118,6 +228,111 @@ _CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5
#: the middle of every number on the sheet, so it is refused rather than stored.
_DECIMAL_MARKERS = (".", ",")
#: "1:50", "2:1", "1:2.5" — paper units : drawing units, both strictly positive.
SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")
def parse_scale(text: Any) -> tuple[str, float, float]:
"""``"1:50"`` → ``("1:50", 1.0, 50.0)``; raises ``ValueError`` naming the input.
Returns the canonical name (whitespace stripped, integers without a
trailing ``.0``), the paper units and the drawing units. The annotation
scale value AutoCAD reports as CANNOSCALEVALUE is ``paper / drawing``.
"""
match = SCALE_RE.match(str(text)) if not isinstance(text, bool) else None
if match is None:
raise ValueError(
f"annotation_scale: {text!r} is not a scale — use 'paper:drawing' such as "
"'1:1', '1:50' or '2:1'."
)
paper, drawing = float(match.group(1)), float(match.group(2))
if paper <= 0 or drawing <= 0:
raise ValueError(f"annotation_scale: both sides of {text!r} must be greater than 0.")
return f"{_trim(paper)}:{_trim(drawing)}", paper, drawing
def scale_value(text: Any) -> float:
"""CANNOSCALEVALUE for a scale name: ``"1:50"`` → ``0.02``."""
_, paper, drawing = parse_scale(text)
return paper / drawing
def _trim(number: float) -> str:
return str(int(number)) if number == int(number) else f"{number:g}"
def _point2(raw: Any) -> list[float]:
"""A 2D point from whatever the engine returned (tuple, list, Vec2/Vec3)."""
try:
return [float(raw[0]), float(raw[1])]
except (TypeError, IndexError, ValueError) as exc:
raise ValueError(f"expected a point, got {raw!r}") from exc
def _finite(key: str, value: Any, what: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{key}: {what} must be a number, got {value!r}")
number = float(value)
if not math.isfinite(number):
raise ValueError(f"{key}: {what} must be finite")
return number
def _encode_limits(key: str, value: Any) -> tuple[tuple[float, float], tuple[float, float]]:
"""``[[xmin, ymin], [xmax, ymax]]`` → two 2D tuples; refused before any write."""
if (
not isinstance(value, (list, tuple))
or len(value) != 2
or any(not isinstance(corner, (list, tuple)) or len(corner) != 2 for corner in value)
):
raise ValueError(f"{key}: expected [[xmin, ymin], [xmax, ymax]], got {value!r}")
xmin = _finite(key, value[0][0], "xmin")
ymin = _finite(key, value[0][1], "ymin")
xmax = _finite(key, value[1][0], "xmax")
ymax = _finite(key, value[1][1], "ymax")
if xmax <= xmin or ymax <= ymin:
raise ValueError(
f"{key}: the upper-right corner must be above and right of the lower-left "
f"(got {[[xmin, ymin], [xmax, ymax]]})"
)
return (xmin, ymin), (xmax, ymax)
_TRUE_WORDS = {"1", "on", "true", "yes"}
_FALSE_WORDS = {"0", "off", "false", "no"}
def _encode_bool(key: str, value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and value in (0, 1):
return bool(value)
if isinstance(value, str):
word = value.strip().lower()
if word in _TRUE_WORDS:
return True
if word in _FALSE_WORDS:
return False
raise ValueError(f"{key}: expected true/false (or 1/0, on/off), got {value!r}")
def _encode_name(key: str, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{key}: expected a non-empty style name, got {value!r}")
return value.strip()
def _encode_enum(key: str, value: Any, table: dict[str, int]) -> int:
if isinstance(value, (int, float)) and not isinstance(value, bool):
code = int(value)
if code in table.values():
return code
raise ValueError(f"{key}: code {code} is not one of {sorted(table.values())}")
name = str(value).strip().lower()
if name in table:
return table[name]
raise ValueError(f"{key}: unknown value {value!r}. Use one of {sorted(table)}.")
def _encode_setting(key: str, kind: str, value: Any) -> Any:
"""Coerce a friendly value into the raw system-variable value."""
@@ -131,6 +346,27 @@ def _encode_setting(key: str, kind: str, value: Any) -> Any:
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
)
return code
if kind == "lunits":
return _encode_enum(key, value, _LUNITS_TO_CODE)
if kind == "aunits":
return _encode_enum(key, value, _AUNITS_TO_CODE)
if kind == "bool":
return 1 if _encode_bool(key, value) else 0
if kind == "scale":
name, _paper, _drawing = parse_scale(value)
return name
if kind == "spacing":
if isinstance(value, (list, tuple)):
if len(value) != 2:
raise ValueError(f"{key}: expected a number or [x, y], got {value!r}")
x = _in_range(key, _finite(key, value[0], "x"))
y = _in_range(key, _finite(key, value[1], "y"))
return (x, y)
step = _in_range(key, _finite(key, value, "spacing"))
return (step, step)
if kind == "degrees":
degrees = _in_range(key, _finite(key, value, "angle"))
return math.radians(degrees)
if kind == "char":
if isinstance(value, bool):
marker = ""
@@ -173,6 +409,10 @@ _SETTING_RANGES: dict[str, tuple[float, float]] = {
"ltscale": (1e-9, 1e6),
"point_size": (-100, 1e6), # negative is a percentage of the screen
"fillet_radius": (0, 1e6),
# track E
"grid_spacing": (1e-9, 1e9), # GRIDUNIT, strictly positive per axis
"snap_spacing": (1e-9, 1e9), # SNAPUNIT, strictly positive per axis
"polar_angle": (1e-9, 360), # POLARANG increment, degrees at the facade
}
@@ -200,6 +440,47 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
if kind == "lunits":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_LUNITS.get(code, "unknown")}
if kind == "aunits":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_AUNITS.get(code, "unknown")}
if kind == "bool":
try:
return bool(int(raw))
except (TypeError, ValueError):
return raw
if kind == "bit":
try:
return bool(int(raw) & _BIT_MASKS[key])
except (TypeError, ValueError):
return raw
if kind == "degrees":
try:
return round(math.degrees(float(raw)), 6)
except (TypeError, ValueError):
return raw
if kind == "spacing":
try:
x, y = _point2(raw)
except ValueError:
return raw
return x if x == y else [x, y]
if kind == "scale":
try:
name, paper, drawing = parse_scale(raw)
except ValueError:
return {"name": str(raw), "value": None}
return {"name": name, "value": paper / drawing}
if kind == "name":
return str(raw)
if kind == "char":
try:
code = int(raw)
+170 -2
View File
@@ -39,6 +39,7 @@ from .base import (
normalize_lineweight,
shoelace_area,
)
from .contracts.settings import parse_scale, scale_value
from .quarantine import (
AbandonedCall,
DocumentQuarantineError,
@@ -59,6 +60,41 @@ __all__ = [
#: document that has been abandoned on repeatedly rather than once.
_MAX_ABANDONED_RECORDS = 10
#: System variables that AutoCAD does not keep in the HEADER section. Grid and
#: snap belong to the active VPORT table entry (DXF codes 76 / 15 / 75 / 14);
#: measured 2026-09-16: `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError`.
_VPORT_SYSVARS: dict[str, tuple[str, str]] = {
"GRIDMODE": ("grid_on", "flag"),
"SNAPMODE": ("snap_on", "flag"),
"GRIDUNIT": ("grid_spacing", "point"),
"SNAPUNIT": ("snap_spacing", "point"),
}
#: 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.
_UNSAVED_SYSVARS: frozenset[str] = frozenset(
{
"AUTOSNAP",
"POLARANG",
"POLARMODE",
"ATTREQ",
"ATTDIA",
"SAVETIME",
"FILEDIA",
"XREFCTL",
"PROXYSHOW",
"CMDECHO",
"HIGHLIGHT",
"HPNAME",
"HPSCALE",
"HPANG",
"DWGNAME",
"DWGPREFIX",
}
)
log = logging.getLogger(__name__)
@@ -1058,6 +1094,9 @@ class EzdxfBackend(AutoCADBackend):
False, reason="acis_generation_requires_live_autocad"
),
"lisp": FeatureCapability(False, reason="live_com_only"),
"registry_sysvar": FeatureCapability(
False, reason="registry_saved_variables_have_no_home_in_a_file"
),
},
)
@@ -5523,14 +5562,69 @@ class EzdxfBackend(AutoCADBackend):
async def system_get_variable(self, name) -> Any:
def _sync():
doc = self._require_doc()
return doc.header.get(f"${name.upper()}", None)
key = str(name).upper()
if key in _VPORT_SYSVARS:
attr, shape = _VPORT_SYSVARS[key]
raw = getattr(self._active_vport(doc).dxf, attr)
if shape == "point":
return (float(raw[0]), float(raw[1]))
return int(raw)
if key == "CANNOSCALE":
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)
return await self._async(_sync)
async def system_set_variable(self, name, value) -> dict:
from ezdxf.lldxf.const import DXFKeyError
def _sync():
doc = self._require_doc()
doc.header[f"${name.upper()}"] = value
key = str(name).upper()
if key in _UNSAVED_SYSVARS:
raise UnsupportedCapabilityError(
"registry_sysvar",
f"system_set_variable: {key} is saved in the AutoCAD registry (or not "
"saved at all), never in the drawing, so the headless ezdxf backend has "
"nowhere to keep it. Set it on the live COM backend.",
)
if key in _VPORT_SYSVARS:
attr, shape = _VPORT_SYSVARS[key]
vport = self._active_vport(doc)
if shape == "point":
x, y = float(value[0]), float(value[1])
if x <= 0 or y <= 0:
raise ValueError(f"{key}: spacing must be greater than 0, got {value!r}")
vport.dxf.set(attr, (x, y))
else:
vport.dxf.set(attr, 1 if int(value) else 0)
elif key == "CANNOSCALE":
self._set_annotation_scale(doc, value)
elif key == "CANNOSCALEVALUE":
raise ValueError(
"CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead."
)
else:
try:
doc.header[f"${key}"] = value
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.
raise ValueError(
f"system_set_variable: {exc} ezdxf has no header slot for it, so the "
"value would be dropped at save; system_variable_describe(name) says "
"where the variable lives."
) from exc
if key in ("LIMMIN", "LIMMAX"):
# Measured: `Drawing.update_limits()` copies the model-space
# LAYOUT's limits into the header on every write(), so a
# header-only value is overwritten at save. The layout is
# the source of truth; keep both in step.
doc.modelspace().dxf_layout.dxf.set(
key.lower(), (float(value[0]), float(value[1]))
)
self._mark_dirty()
return {"ok": True, "variable": name, "value": value}
@@ -5808,3 +5902,77 @@ class EzdxfBackend(AutoCADBackend):
return _entity_info_dxf(xline)
return await self._async(_sync)
# ── settings (track E, group C) ───────────────────────────────────────────
#
# Where the variables the `drawing_settings` facade reaches actually live
# in a DXF file when they are not header variables.
@staticmethod
def _active_vport(doc):
"""The `*Active` VPORT entry — grid and snap state live on it.
Owned here (group C). Group V's named views use the same entry and
carry a worktree-only copy that the merge agent deletes; this
definition is the one that ships.
"""
entries = doc.viewports.get("*Active")
if not entries:
entries = [doc.viewports.new("*Active")]
return entries[0]
@staticmethod
def _variable_dictionary(doc, create: bool):
"""`AcDbVariableDictionary` under the root dictionary (CANNOSCALE lives there)."""
vardict = doc.rootdict.get("AcDbVariableDictionary")
if vardict is None and create:
vardict = doc.rootdict.add_new_dict("AcDbVariableDictionary")
return vardict
def _dictionary_variable(self, doc, key: str, default: str) -> str:
vardict = self._variable_dictionary(doc, create=False)
if vardict is None:
return default
entry = vardict.get(key)
return default if entry is None else str(entry.dxf.value)
@staticmethod
def _ensure_scale_entry(doc, name: str, paper: float, drawing: float) -> bool:
"""Add `name` to ACAD_SCALELIST unless present; True when added.
ezdxf has no SCALE entity class, so the object is authored as raw tags
and loaded through the factory (a `DXFTagStorage` that exports
verbatim). Measured: it round-trips, ezdxf registers the CLASS, and
`doc.audit()` is clean after reopen. AutoCAD only honours a CANNOSCALE
that names an entry of this list.
"""
from ezdxf.entities import factory
from ezdxf.lldxf.extendedtags import ExtendedTags
scales = doc.rootdict.get("ACAD_SCALELIST")
if scales is None:
scales = doc.rootdict.add_new_dict("ACAD_SCALELIST")
if scales.get(name) is not None:
return False
handle = doc.entitydb.next_handle()
unit = 1 if paper == drawing else 0
text = (
f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n"
f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{unit}\n"
)
entry = factory.load(ExtendedTags.from_text(text), doc)
doc.entitydb.add(entry)
doc.objects.add_object(entry)
scales.add(name, entry)
return True
def _set_annotation_scale(self, doc, value: Any) -> str:
name, paper, drawing = parse_scale(value) # refused before any write
self._ensure_scale_entry(doc, name, paper, drawing)
vardict = self._variable_dictionary(doc, create=True)
entry = vardict.get("CANNOSCALE")
if entry is None:
vardict.add_dict_var("CANNOSCALE", name)
else:
entry.dxf.value = name
return name
+24 -1
View File
@@ -498,7 +498,20 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
),
),
"drawing_settings": ToolAliases(
acad=("UNITS", "DDUNITS", "OSNAP", "DSETTINGS", "LTSCALE", "DIMSCALE"),
acad=(
"UNITS",
"DDUNITS",
"OSNAP",
"DSETTINGS",
"LTSCALE",
"DIMSCALE",
"LIMITS",
"GRID",
"SNAP",
"ORTHO",
"PSLTSCALE",
"CANNOSCALE",
),
synonyms=(
"drawing units",
"millimetres or inches",
@@ -506,6 +519,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"dimension scale",
"linetype scale",
"object snap settings",
"drawing limits",
"turn the grid on",
"grid spacing",
"snap spacing",
"ortho mode",
"polar tracking",
"annotation scale",
"architectural units",
"current dimension style",
"current text style",
),
),
"drawing_undo": ToolAliases(
+23 -6
View File
@@ -5050,8 +5050,13 @@ async def drawing_settings(
"units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, "
"dimscale, dim_text_height, dim_arrow_size, dim_decimals, "
'decimal_separator ("." or ","), zero_suppression, text_size, point_mode, '
"point_size, osmode, fillet_radius. "
'Example: {"units": "mm", "dimscale": 1.0, "dim_text_height": 3.5}.'
"point_size, osmode, fillet_radius, limits ([[xmin,ymin],[xmax,ymax]]), "
"grid (bool), grid_spacing, snap (bool), snap_spacing, ortho (bool), "
"polar (bool), polar_angle (degrees), psltscale (bool), annotation_scale "
'("1:50"), linear_units (decimal|engineering|architectural|fractional|'
"scientific), angular_units (degrees|dms|grads|radians|surveyor), "
"dimstyle, textstyle (current style names). "
'Example: {"units": "mm", "limits": [[0, 0], [420, 297]], "grid": true}.'
),
),
] = None,
@@ -5060,14 +5065,26 @@ async def drawing_settings(
"""Read or change common AutoCAD drawing settings by friendly name.
A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE,
DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, …) so
the user can say "set units to mm and dimension text to 3.5" without
memorising sysvar names. Call with no argument to get a full snapshot of the
current settings.
DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, LIMMIN/
LIMMAX, GRIDMODE/GRIDUNIT, SNAPMODE/SNAPUNIT, ORTHOMODE, AUTOSNAP/POLARANG,
PSLTSCALE, CANNOSCALE, LUNITS, AUNITS, DIMSTYLE, TEXTSTYLE) so the user can
say "set units to mm, limits to A3 and the grid on" without memorising
sysvar names. Call with no argument for a full snapshot; a write returns
`applied`, `changed` ({key: [old, new]} — only what moved) and `errors`.
`dim_text_height` / `dim_arrow_size` / `dim_decimals` / `decimal_separator`
/ `zero_suppression` shape the *dimension* — `text_size` is TEXTSIZE, the
height of a standalone TEXT entity, and does not touch dimensions.
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); the headless engine adds it.
Headlessly, grid/snap are stored on the active VPORT and the annotation
scale in the variable dictionary — the places AutoCAD reads them from.
"""
if settings:
await ctx.info(f"Applying drawing settings: {', '.join(settings)}")
+425
View File
@@ -0,0 +1,425 @@
"""drawing_settings, track E: limits, grid/snap, ortho/polar, PSLTSCALE, annotation
scale, unit formats and the current styles — on both engines.
Measured before writing (ezdxf 1.4.4, 2026-09-16):
* `doc.header["$GRIDMODE"]` (and GRIDUNIT / SNAPMODE / SNAPUNIT / POLARMODE /
POLARANG / CANNOSCALE / CANNOSCALEVALUE) raises `DXFKeyError: Invalid header
variable` — none of them is a DXF header variable. Grid and snap live on the
active VPORT; CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary; the
polar pair is registry-saved and has no home in a file at all.
* A `$LIMMAX` written to the header is overwritten at save by
`Drawing.update_limits()`, which copies the model-space LAYOUT's limits into
the header — so a header-only write reported success and vanished on reload.
"""
from __future__ import annotations
import math
import pytest
from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import _SETTING_MAP, parse_scale, scale_value
pytestmark = pytest.mark.asyncio
NEW_KEYS = (
"limits",
"grid",
"grid_spacing",
"snap",
"snap_spacing",
"ortho",
"polar",
"polar_angle",
"psltscale",
"annotation_scale",
"linear_units",
"angular_units",
"dimstyle",
"textstyle",
)
def _active_vport(backend):
return backend._doc.viewports.get("*Active")[0]
# ── pure helpers ────────────────────────────────────────────────────────────
def test_parse_scale_canonicalises_and_refuses():
assert parse_scale("1:50") == ("1:50", 1.0, 50.0)
assert parse_scale(" 2 : 1 ") == ("2:1", 2.0, 1.0)
assert parse_scale("1:2.5") == ("1:2.5", 1.0, 2.5)
assert scale_value("1:50") == pytest.approx(0.02)
for bad in ("fifty", "1:0", "0:1", "1-50", "", True):
with pytest.raises(ValueError, match="annotation_scale"):
parse_scale(bad)
def test_every_new_key_is_in_the_map():
for key in NEW_KEYS:
assert key in _SETTING_MAP, key
# ── headless engine ─────────────────────────────────────────────────────────
async def test_snapshot_reports_every_key_including_the_unavailable_ones(backend):
snap = (await backend.drawing_settings())["settings"]
for key in NEW_KEYS:
assert key in snap, key
assert snap["limits"] == [[0.0, 0.0], [420.0, 297.0]]
assert snap["grid"] is False and snap["snap"] is False and snap["ortho"] is False
assert snap["psltscale"] is True
assert snap["annotation_scale"] == {"name": "1:1", "value": 1.0}
assert snap["linear_units"] == {"code": 2, "name": "decimal"}
assert snap["angular_units"] == {"code": 0, "name": "degrees"}
assert snap["dimstyle"] == "ISO-25"
assert snap["textstyle"] == "Standard"
# registry-saved: a file holds no value, and None says so rather than a guess
assert snap["polar"] is None
assert snap["polar_angle"] is None
async def test_limits_survive_a_save_and_reopen(backend, tmp_path):
res = await backend.drawing_settings({"limits": [[0, 0], [500, 350]]})
assert res["ok"] is True, res.get("errors")
assert res["changed"]["limits"] == [
[[0.0, 0.0], [420.0, 297.0]],
[[0.0, 0.0], [500.0, 350.0]],
]
layout = backend._doc.modelspace().dxf_layout.dxf
assert tuple(layout.limmax)[:2] == (500.0, 350.0), "the layout is what survives write()"
path = str(tmp_path / "limits.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_settings())["settings"]["limits"] == [
[0.0, 0.0],
[500.0, 350.0],
]
async def test_grid_and_snap_live_on_the_active_vport(backend, tmp_path):
res = await backend.drawing_settings(
{"grid": True, "grid_spacing": 5, "snap": "on", "snap_spacing": [2.5, 5]}
)
assert res["ok"] is True, res.get("errors")
vport = _active_vport(backend).dxf
assert vport.grid_on == 1 and vport.snap_on == 1
assert tuple(vport.grid_spacing)[:2] == (5.0, 5.0)
assert tuple(vport.snap_spacing)[:2] == (2.5, 5.0)
snap = (await backend.drawing_settings())["settings"]
assert snap["grid"] is True and snap["grid_spacing"] == 5.0
assert snap["snap"] is True and snap["snap_spacing"] == [2.5, 5.0]
path = str(tmp_path / "grid.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
again = (await backend.drawing_settings())["settings"]
assert again["grid"] is True and again["grid_spacing"] == 5.0
assert again["snap_spacing"] == [2.5, 5.0]
async def test_ortho_and_psltscale_are_header_variables(backend):
res = await backend.drawing_settings({"ortho": True, "psltscale": False})
assert res["ok"] is True, res.get("errors")
assert backend._doc.header["$ORTHOMODE"] == 1
assert backend._doc.header["$PSLTSCALE"] == 0
snap = (await backend.drawing_settings())["settings"]
assert snap["ortho"] is True and snap["psltscale"] is False
async def test_polar_is_refused_headlessly_by_capability(backend):
res = await backend.drawing_settings({"polar": True, "polar_angle": 45, "ortho": True})
assert res["ok"] is False
assert res["applied"] == {"ortho": True}, "the key that has a home is still applied"
assert "registry" in res["errors"]["polar"]
assert "registry" in res["errors"]["polar_angle"]
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.system_set_variable("POLARANG", 0.5)
assert excinfo.value.capability == "registry_sysvar"
async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(backend, tmp_path):
res = await backend.drawing_settings({"annotation_scale": "1:50"})
assert res["ok"] is True, res.get("errors")
assert res["changed"]["annotation_scale"] == [
{"name": "1:1", "value": 1.0},
{"name": "1:50", "value": 0.02},
]
vardict = backend._doc.rootdict.get("AcDbVariableDictionary")
assert vardict["CANNOSCALE"].dxf.value == "1:50"
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 await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02)
# re-setting the same scale is not a change and adds no second list entry
again = await backend.drawing_settings({"annotation_scale": "1:50"})
assert again["changed"] == {}
assert len(list(scales.keys())) == 1
path = str(tmp_path / "scale.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_settings())["settings"]["annotation_scale"] == {
"name": "1:50",
"value": 0.02,
}
assert backend._doc.audit().has_errors is False
async def test_unit_formats_by_name_and_by_code(backend):
res = await backend.drawing_settings({"linear_units": "architectural", "angular_units": "dms"})
assert res["ok"] is True, res.get("errors")
assert backend._doc.header["$LUNITS"] == 4
assert backend._doc.header["$AUNITS"] == 1
snap = (await backend.drawing_settings())["settings"]
assert snap["linear_units"] == {"code": 4, "name": "architectural"}
assert snap["angular_units"] == {"code": 1, "name": "dms"}
by_code = await backend.drawing_settings({"linear_units": 1, "angular_units": 3})
assert by_code["ok"] is True
assert backend._doc.header["$LUNITS"] == 1 and backend._doc.header["$AUNITS"] == 3
bad = await backend.drawing_settings({"linear_units": "furlongs", "angular_units": 9})
assert "architectural" in bad["errors"]["linear_units"]
assert "angular_units" in bad["errors"]["angular_units"]
assert backend._doc.header["$LUNITS"] == 1, "a refused value writes nothing"
async def test_current_styles_delegate_to_the_styles_contract(backend, monkeypatch):
calls: list[tuple[str, str]] = []
async def _dimstyle(name):
calls.append(("dimstyle_set_current", name))
backend._doc.header["$DIMSTYLE"] = name
return {"ok": True, "current": name, "previous": "ISO-25"}
async def _textstyle(name):
calls.append(("textstyle_set_current", name))
backend._doc.header["$TEXTSTYLE"] = name
return {"ok": True, "current": name, "previous": "Standard"}
monkeypatch.setattr(backend, "dimstyle_set_current", _dimstyle, raising=False)
monkeypatch.setattr(backend, "textstyle_set_current", _textstyle, raising=False)
res = await backend.drawing_settings({"dimstyle": " ANSI ", "textstyle": "ISOCP"})
assert res["ok"] is True, res.get("errors")
assert calls == [("dimstyle_set_current", "ANSI"), ("textstyle_set_current", "ISOCP")]
assert res["changed"]["dimstyle"] == ["ISO-25", "ANSI"]
snap = (await backend.drawing_settings())["settings"]
assert snap["dimstyle"] == "ANSI" and snap["textstyle"] == "ISOCP"
async def test_current_styles_are_refused_when_no_styles_contract_exists(backend, monkeypatch):
# Group S may or may not have merged: force the "absent" branch either way.
monkeypatch.setattr(backend, "dimstyle_set_current", None, raising=False)
res = await backend.drawing_settings({"dimstyle": "ANSI"})
assert res["ok"] is False
assert "dimstyle_set_current" in res["errors"]["dimstyle"]
assert (await backend.drawing_settings())["settings"]["dimstyle"] == "ISO-25"
async def test_changed_reports_only_values_that_moved(backend):
same = await backend.drawing_settings({"psltscale": True, "ortho": False})
assert same["ok"] is True and same["changed"] == {}
moved = await backend.drawing_settings({"psltscale": False, "ortho": False})
assert moved["changed"] == {"psltscale": [True, False]}
async def test_malformed_values_are_refused_before_any_write(backend):
before_limmax = tuple(backend._doc.header["$LIMMAX"])
res = await backend.drawing_settings(
{
"limits": [[0, 0], [0, 5]],
"annotation_scale": "fifty",
"grid_spacing": 0,
"snap_spacing": [1, 2, 3],
"polar": "maybe",
"dimstyle": "",
}
)
assert res["ok"] is False and res["applied"] == {}
assert set(res["errors"]) == {
"limits",
"annotation_scale",
"grid_spacing",
"snap_spacing",
"polar",
"dimstyle",
}
assert "upper-right" in res["errors"]["limits"]
assert "paper:drawing" in res["errors"]["annotation_scale"]
assert "greater than 0" in res["errors"]["grid_spacing"]
assert tuple(backend._doc.header["$LIMMAX"]) == before_limmax
assert backend._doc.rootdict.get("AcDbVariableDictionary") is None
assert tuple(_active_vport(backend).dxf.grid_spacing)[:2] != (0.0, 0.0)
async def test_raw_gridmode_routes_to_the_vport_and_the_old_error_is_gone(backend):
assert await backend.system_get_variable("GRIDMODE") == 0
out = await backend.system_set_variable("GRIDMODE", 1)
assert out["ok"] is True
assert _active_vport(backend).dxf.grid_on == 1
assert await backend.system_get_variable("GRIDMODE") == 1
async def test_raw_unknown_header_name_is_a_named_value_error(backend):
with pytest.raises(ValueError, match=r"\$HPNAME2"):
await backend.system_set_variable("HPNAME2", "x")
async def test_cannoscalevalue_is_read_only(backend):
with pytest.raises(ValueError, match="CANNOSCALEVALUE is read-only"):
await backend.system_set_variable("CANNOSCALEVALUE", 0.5)
def test_both_capability_maps_declare_registry_sysvar():
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
ezdxf_features = EzdxfBackend().capabilities().to_dict()["features"]
com_features = ComBackend().capabilities().to_dict()["features"]
assert ezdxf_features["registry_sysvar"]["supported"] is False
assert com_features["registry_sysvar"]["supported"] is True
# ── server tool wiring ──────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *a, **k):
pass
async def test_server_tool_carries_the_new_keys(backend):
import server
ctx = _FakeCtx(backend)
write = await server.drawing_settings(
settings={"limits": [[0, 0], [297, 210]], "grid": True, "annotation_scale": "1:2"},
ctx=ctx,
)
assert write["ok"] is True, write.get("errors")
read = await server.drawing_settings(ctx=ctx)
assert read["settings"]["limits"] == [[0.0, 0.0], [297.0, 210.0]]
assert read["settings"]["annotation_scale"] == {"name": "1:2", "value": 0.5}
# ── live engine, against a fake ActiveX application ─────────────────────────
class _FakeApp:
"""Records GetVariable / SetVariable; stores what AutoCAD would store."""
def __init__(self):
self.store = {
"INSUNITS": 4,
"LIMMIN": (0.0, 0.0),
"LIMMAX": (420.0, 297.0),
"GRIDMODE": 0,
"GRIDUNIT": (10.0, 10.0),
"SNAPMODE": 0,
"SNAPUNIT": (10.0, 10.0),
"ORTHOMODE": 0,
"AUTOSNAP": 63,
"POLARANG": math.pi / 2,
"PSLTSCALE": 1,
"CANNOSCALE": "1:1",
"LUNITS": 2,
"AUNITS": 0,
"DIMSTYLE": "ISO-25",
"TEXTSTYLE": "Standard",
"DIMSCALE": 1.0,
}
self.calls: list[tuple] = []
def GetVariable(self, name):
self.calls.append(("GetVariable", name))
return self.store[name]
def SetVariable(self, name, value):
stored = getattr(value, "value", value) # a VARIANT array → its Python sequence
if isinstance(stored, list):
stored = tuple(stored)
self.calls.append(("SetVariable", name, stored))
self.store[name] = stored
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
app = _FakeApp()
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)
return backend, app
async def test_com_facade_sends_autocad_shaped_values(com_backend):
backend, app = com_backend
res = await backend.drawing_settings(
{
"limits": [[0, 0], [500, 350]],
"grid": True,
"grid_spacing": 5,
"polar": False,
"polar_angle": 45,
"annotation_scale": "1:50",
"linear_units": "architectural",
"psltscale": 0,
}
)
assert res["ok"] is True, res.get("errors")
sets = [call[1:] for call in app.calls if call[0] == "SetVariable"]
assert sets == [
("LIMMIN", (0.0, 0.0)),
("LIMMAX", (500.0, 350.0)),
("GRIDMODE", 1),
("GRIDUNIT", (5.0, 5.0)),
("AUTOSNAP", 55), # 63 with bit 8 cleared: polar off, everything else kept
("POLARANG", pytest.approx(math.radians(45))),
("CANNOSCALE", "1:50"),
("LUNITS", 4),
("PSLTSCALE", 0),
]
assert res["changed"]["polar"] == [True, False]
assert res["changed"]["polar_angle"] == [90.0, 45.0]
async def test_com_point_variables_travel_as_variant_double_arrays(com_backend):
import pythoncom
backend, app = com_backend
seen: dict = {}
real_set = app.SetVariable
def _capture(name, value):
seen[name] = value
real_set(name, value)
app.SetVariable = _capture
await backend.system_set_variable("LIMMAX", (300, 200))
variant = seen["LIMMAX"]
assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8
assert tuple(variant.value) == (300.0, 200.0)
async def test_com_refuses_before_touching_activex(com_backend):
backend, app = com_backend
res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"})
assert res["ok"] is False
assert not [call for call in app.calls if call[0] == "SetVariable"]