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:
co-authored by
Claude Opus 5
parent
49fc6ac6a5
commit
df39b21f2e
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user