merge: track-e group C
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 176 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> **v1.5 release snapshot:** 179 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
|
||||
+126
-5
@@ -37,6 +37,7 @@ from .base import (
|
||||
rad2deg,
|
||||
shoelace_area,
|
||||
)
|
||||
from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -260,6 +261,30 @@ def _require_block_defined(doc, name):
|
||||
|
||||
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
|
||||
|
||||
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
|
||||
_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS}
|
||||
|
||||
#: `IAcadSummaryInfo` failure codes, as the `scode` in a `com_error`'s
|
||||
#: excepinfo (measured on AutoCAD 2026; the description next to them is
|
||||
#: English while the HRESULT text is localised, so the code is matched first).
|
||||
_SUMMARYINFO_DUPLICATE_KEY = -2145386475 # AddCustomInfo: 'Duplicate key'
|
||||
_SUMMARYINFO_KEY_NOT_FOUND = -2145386476 # Set/RemoveCustomByKey: 'Key not found'
|
||||
|
||||
|
||||
def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> bool:
|
||||
"""True when `exc` is the `IAcadSummaryInfo` failure `scode` / `description`.
|
||||
|
||||
`com_error.args[2]` is the excepinfo tuple ``(wCode, source, description,
|
||||
helpfile, helpcontext, scode)``; a missing or foreign excepinfo is never a
|
||||
match, so an unrelated COM failure keeps propagating.
|
||||
"""
|
||||
info = exc.args[2] if len(exc.args) > 2 else None
|
||||
if not isinstance(info, tuple):
|
||||
return False
|
||||
code = info[5] if len(info) > 5 else None
|
||||
desc = str(info[2] or "") if len(info) > 2 else ""
|
||||
return code == scode or desc.strip().casefold() == description.casefold()
|
||||
|
||||
|
||||
def _ensure_linetype_loaded(name: str) -> None:
|
||||
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
|
||||
@@ -1327,6 +1352,8 @@ class ComBackend(AutoCADBackend):
|
||||
"viewport_render": FeatureCapability(True, "native"),
|
||||
"solid_3d": _solid_3d_capability(),
|
||||
"lisp": FeatureCapability(True, "sanitized"),
|
||||
"registry_sysvar": FeatureCapability(True, "native"),
|
||||
"dwgprops": FeatureCapability(True, "native"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4942,30 +4969,47 @@ class ComBackend(AutoCADBackend):
|
||||
return await self._run(_sync)
|
||||
|
||||
async def system_get_variable(self, name) -> Any:
|
||||
# GetVariable / SetVariable are members of AcadDocument, not of
|
||||
# AcadApplication: `AutoCAD.Application.GetVariable` raises
|
||||
# AttributeError on a live seat before anything reaches AutoCAD.
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
return app.GetVariable(name)
|
||||
doc = _acad_doc()
|
||||
return doc.GetVariable(name)
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def system_set_variable(self, name, value) -> dict:
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
doc = _acad_doc() # the sysvar host is the document (see system_get_variable)
|
||||
# Coerce to the sysvar's actual type (NEW-com-set-variable-coercion):
|
||||
# AutoCAD rejects e.g. a string "0" for the integer OSMODE. Probe the
|
||||
# current value's type and convert to match; pass through on failure.
|
||||
coerced = value
|
||||
# `payload` is what SetVariable receives. It differs from `coerced`
|
||||
# only for point variables, whose VARIANT wrapper must never reach
|
||||
# the returned dict: pydantic cannot serialise
|
||||
# win32com.client.VARIANT, so the tool would report failure *after*
|
||||
# AutoCAD had already applied the write.
|
||||
payload = value
|
||||
try:
|
||||
current = app.GetVariable(name)
|
||||
current = doc.GetVariable(name)
|
||||
if isinstance(current, bool):
|
||||
coerced = bool(int(value)) if isinstance(value, str) else bool(value)
|
||||
elif isinstance(current, int):
|
||||
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 = coords[: len(current)]
|
||||
payload = _av(coerced) if isinstance(current, (tuple, list)) else coerced
|
||||
except Exception as exc:
|
||||
log.debug("set_variable type probe for %s failed: %s", name, exc)
|
||||
app.SetVariable(name, coerced)
|
||||
doc.SetVariable(name, payload)
|
||||
return {"ok": True, "variable": name, "value": coerced}
|
||||
|
||||
return await self._run(_sync)
|
||||
@@ -5514,3 +5558,80 @@ class ComBackend(AutoCADBackend):
|
||||
return _entity_info(xline)
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
# ── settings (track E, group C) ───────────────────────────────────────────
|
||||
|
||||
async def drawing_properties_get(self) -> dict:
|
||||
def _sync():
|
||||
info = _acad_doc().SummaryInfo
|
||||
summary = {
|
||||
field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items()
|
||||
}
|
||||
custom: dict[str, str] = {}
|
||||
for index in range(int(info.NumCustomInfo())):
|
||||
key, value = info.GetCustomByIndex(index)
|
||||
custom[str(key)] = str(value)
|
||||
return {
|
||||
"summary": summary,
|
||||
"summary_available": True,
|
||||
"custom": custom,
|
||||
"backend": "com",
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def drawing_properties_set(
|
||||
self, summary: dict | None = None, custom: dict | None = None
|
||||
) -> dict:
|
||||
# Validated before any ActiveX call: a bad field or value raises here
|
||||
# and SummaryInfo is never touched.
|
||||
written, to_write, to_delete = validate_drawing_properties(summary, custom)
|
||||
|
||||
def _sync():
|
||||
info = _acad_doc().SummaryInfo
|
||||
# AutoCAD matches custom keys by its own simple per-character case
|
||||
# compare (measured on 2026: AddCustomInfo("PROJECT") over an
|
||||
# existing "Project" is 'Duplicate key', yet "Straße" and
|
||||
# "STRASSE" are two keys -- Python's casefold() merges them, and a
|
||||
# routing built on it sent "STRASSE" to SetCustomByKey, which
|
||||
# failed 'Key not found' after the summary fields and the earlier
|
||||
# keys were already applied). So only an exact spelling is routed
|
||||
# from here; for everything else AutoCAD decides: AddCustomInfo's
|
||||
# 'Duplicate key' means the key is there under another spelling
|
||||
# and it is set instead, RemoveCustomByKey's 'Key not found' means
|
||||
# it is not there and the delete is reported as not done. No
|
||||
# routing guess can then half-write.
|
||||
existing: set[str] = set()
|
||||
for index in range(int(info.NumCustomInfo())):
|
||||
key, _value = info.GetCustomByIndex(index)
|
||||
existing.add(str(key))
|
||||
for field, value in written.items():
|
||||
setattr(info, _SUMMARY_ATTRS[field], value)
|
||||
for key, value in to_write.items():
|
||||
if key in existing:
|
||||
info.SetCustomByKey(key, value)
|
||||
continue
|
||||
try:
|
||||
info.AddCustomInfo(key, value)
|
||||
except _COM_ERROR as exc:
|
||||
if not _summaryinfo_error_is(exc, _SUMMARYINFO_DUPLICATE_KEY, "Duplicate key"):
|
||||
raise
|
||||
info.SetCustomByKey(key, value)
|
||||
deleted = []
|
||||
for key in to_delete:
|
||||
try:
|
||||
info.RemoveCustomByKey(key)
|
||||
except _COM_ERROR as exc:
|
||||
if not _summaryinfo_error_is(exc, _SUMMARYINFO_KEY_NOT_FOUND, "Key not found"):
|
||||
raise
|
||||
continue
|
||||
deleted.append(key)
|
||||
return {
|
||||
"ok": True,
|
||||
"summary_written": sorted(written),
|
||||
"custom_written": sorted(to_write),
|
||||
"custom_deleted": deleted,
|
||||
"backend": "com",
|
||||
}
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
+476
-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,97 @@ 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``; ``annotation_scale`` on an R12 file
|
||||
headlessly (no OBJECTS section, so the value would vanish at save —
|
||||
save as R2000 or newer first); ``osmode`` / ``polar`` / ``polar_angle``
|
||||
on the headless engine (registry-saved — ``capability: registry_sysvar``;
|
||||
a snapshot reports them as ``None`` there, a file holds no value);
|
||||
``dimstyle`` / ``textstyle`` when the backend has no styles contract.
|
||||
|
||||
Note that a no-argument snapshot is one ``system_get_variable`` per key
|
||||
(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
|
||||
@@ -86,10 +146,47 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
|
||||
"text_size": ("TEXTSIZE", "float"),
|
||||
"point_mode": ("PDMODE", "int"),
|
||||
"point_size": ("PDSIZE", "float"),
|
||||
# OSMODE is registry-saved (a DXF R2000+ file has no $OSMODE), so the
|
||||
# headless engine refuses it like the polar pair below.
|
||||
"osmode": ("OSMODE", "int"),
|
||||
"fillet_radius": ("FILLETRAD", "float"),
|
||||
# ── track E: the environment a drawing lives in ──────────────────────
|
||||
# `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 +208,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 +233,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 +351,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 +414,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 +445,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)
|
||||
@@ -219,3 +505,180 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Document properties (DWGPROPS) — validation shared by both engines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab.
|
||||
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
|
||||
|
||||
|
||||
#: The printable characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects
|
||||
#: anywhere inside a custom key with ``Invalid key``. Measured on AutoCAD 2026
|
||||
#: by sweeping every character 0x20-0x7E through ``AddCustomInfo`` on a scratch
|
||||
#: document: exactly these thirteen are refused; every other printable ASCII
|
||||
#: character, every control character (0x01-0x1F, including tab), internal
|
||||
#: spaces, a no-break space and other unicode are accepted. Leading or
|
||||
#: trailing whitespace (space, tab or no-break space) is refused separately.
|
||||
_CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|')
|
||||
|
||||
|
||||
def custom_key_fold(key: str) -> str:
|
||||
"""Fold a custom-property key the way AutoCAD compares them.
|
||||
|
||||
AutoCAD's ``AddCustomInfo`` / ``SetCustomByKey`` / ``RemoveCustomByKey``
|
||||
match keys by a *simple* one-to-one case compare, not Unicode full case
|
||||
folding. Measured on AutoCAD 2026 (``AddCustomInfo`` of the second
|
||||
spelling over the first): ``Project``/``PROJECT``, ``Grün``/``GRÜN``,
|
||||
``é``/``É``, ``ÿ``/``Ÿ``, ``ł``/``Ł``, ``σ``/``Σ``, ``я``/``Я`` and
|
||||
``dž``/``DŽ`` are the same key (``Duplicate key``), while ``Straße``/
|
||||
``STRASSE``, ``ẞ``/``ß``, ``kelvin``/``Kelvin`` (Kelvin sign), ``µ``/``Μ``,
|
||||
``σς``/``ΣΣ`` and ``Dž``/``DŽ`` are distinct (both stored) — every one of
|
||||
which Python's ``str.casefold()`` calls equal. So this folds each character
|
||||
to its lowercase only when the mapping is a single character that
|
||||
round-trips (``lower().upper() == upper()``, ``upper().lower() == lower()``
|
||||
and the character is one of the two); anything else — ``ß`` (uppercases
|
||||
to ``SS``), the final sigma ``ς``, the micro sign ``µ``, the Kelvin sign,
|
||||
the title-case digraph ``Dž``, dotless ``ı`` and dotted ``İ`` — is kept as
|
||||
is. The result never changes length. This is what the headless engine and
|
||||
the validator match on; the live engine lets AutoCAD decide (see
|
||||
``ComBackend.drawing_properties_set``).
|
||||
"""
|
||||
out: list[str] = []
|
||||
for c in key:
|
||||
lo, up = c.lower(), c.upper()
|
||||
if (
|
||||
len(lo) == 1
|
||||
and len(up) == 1
|
||||
and lo.upper() == up
|
||||
and up.lower() == lo
|
||||
and c in (lo, up)
|
||||
):
|
||||
out.append(lo)
|
||||
else:
|
||||
out.append(c)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _check_custom_text(key: str, text: str, what: str) -> None:
|
||||
"""A newline in a custom key or value corrupts the DXF on save.
|
||||
|
||||
ezdxf writes custom properties verbatim as ``9 / $CUSTOMPROPERTYTAG / 1 /
|
||||
{key}`` and ``9 / $CUSTOMPROPERTY / 1 / {value}`` header lines — a line
|
||||
break inside either text starts a new (invalid) group and the reopened
|
||||
file fails with ``DXFStructureError``. Refused here so neither engine
|
||||
writes it.
|
||||
"""
|
||||
if "\n" in text or "\r" in text:
|
||||
raise ValueError(
|
||||
f"custom[{key!r}]: the {what} contains a line break, which corrupts the "
|
||||
"DXF on save (the whole drawing would fail to reopen, not just the "
|
||||
"property). Custom properties are single-line."
|
||||
)
|
||||
|
||||
|
||||
def _check_custom_key_for_write(key: str) -> None:
|
||||
"""Mirror AutoCAD's ``AddCustomInfo`` key syntax so the live engine cannot
|
||||
refuse mid-write. Measured on AutoCAD 2026: ``Invalid key`` for a key with
|
||||
leading/trailing whitespace (space, tab, no-break space) or any of
|
||||
``_CUSTOM_KEY_FORBIDDEN`` anywhere in it — raised after the summary fields
|
||||
and the earlier keys were already applied, while the headless engine
|
||||
writes the same key without complaint. Only a *write* goes through
|
||||
``AddCustomInfo``; ``RemoveCustomByKey`` does not validate syntax (measured:
|
||||
``Key not found``, never ``Invalid key``), so a delete is not held to these
|
||||
rules — a key that reached the drawing by other means (a DXF written by
|
||||
ezdxf, say) must stay removable."""
|
||||
if key != key.strip():
|
||||
raise ValueError(
|
||||
f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's "
|
||||
"AddCustomInfo rejects it as 'Invalid key'. Strip the key."
|
||||
)
|
||||
bad = sorted(set(key) & _CUSTOM_KEY_FORBIDDEN)
|
||||
if bad:
|
||||
shown = " ".join(repr(c) for c in bad)
|
||||
raise ValueError(
|
||||
f"custom: key {key!r} contains {shown} — AutoCAD's AddCustomInfo rejects "
|
||||
f"it as 'Invalid key' (refused characters: {''.join(sorted(_CUSTOM_KEY_FORBIDDEN))}). "
|
||||
"Use another separator."
|
||||
)
|
||||
|
||||
|
||||
def validate_drawing_properties(
|
||||
summary: dict | None, custom: dict | None
|
||||
) -> tuple[dict[str, str], dict[str, str], list[str]]:
|
||||
"""→ (summary fields to write, custom keys to write, custom keys to delete).
|
||||
|
||||
Refuses the whole request by name before either engine writes anything:
|
||||
an unknown summary field is a ``ValueError``; a non-string summary value,
|
||||
a non-string or empty custom key, or a custom value that is neither a
|
||||
string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never
|
||||
coerced with ``str()`` — a number the caller meant as text is theirs to
|
||||
format.
|
||||
|
||||
Custom keys and values are also held to the rules the *other* engine would
|
||||
enforce or the file format would break on, so the same call cannot succeed
|
||||
on one engine and half-apply on the other (``ValueError`` naming the key):
|
||||
|
||||
* a key to *write* with leading or trailing whitespace, or containing any
|
||||
of the thirteen characters ``" * , / : ; < = > ? \\ ` |`` — AutoCAD's
|
||||
``AddCustomInfo`` rejects exactly these as ``Invalid key`` mid-write
|
||||
(measured on AutoCAD 2026 over every printable ASCII character), after
|
||||
the summary fields and the earlier keys were already applied; the
|
||||
headless engine writes them without complaint, and AutoCAD then loads
|
||||
them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey``
|
||||
never validates syntax, and such keys do reach drawings by other routes,
|
||||
so they must stay removable;
|
||||
* two keys in one request that AutoCAD would treat as the same key — its
|
||||
key compare is a simple one-to-one case compare (``custom_key_fold``;
|
||||
measured: ``AddCustomInfo("PROJECT")`` over an existing ``Project``
|
||||
raises ``Duplicate key``, while ``Straße`` and ``STRASSE`` are two
|
||||
keys), so the second would fail mid-write on the live engine and write
|
||||
a second tag headlessly;
|
||||
* a key or value containing a line break (LF or CR) — ezdxf emits custom
|
||||
properties unescaped, one header line per text, so the saved DXF is
|
||||
corrupt and the whole drawing fails to reopen (``DXFStructureError``),
|
||||
not just the property.
|
||||
"""
|
||||
if summary is not None and not isinstance(summary, dict):
|
||||
raise TypeError("summary: must be an object of {field: text}")
|
||||
written: dict[str, str] = {}
|
||||
for field, value in (summary or {}).items():
|
||||
if field not in SUMMARY_FIELDS:
|
||||
raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})")
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}")
|
||||
written[field] = value
|
||||
if custom is not None and not isinstance(custom, dict):
|
||||
raise TypeError("custom: must be an object of {key: text | null}")
|
||||
to_write: dict[str, str] = {}
|
||||
to_delete: list[str] = []
|
||||
seen: dict[str, str] = {} # folded key (AutoCAD's rule) -> the spelling seen first
|
||||
for key, value in (custom or {}).items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
raise TypeError(f"custom: key {key!r} must be a non-empty string")
|
||||
_check_custom_text(key, key, "key")
|
||||
folded = custom_key_fold(key)
|
||||
if folded in seen:
|
||||
raise ValueError(
|
||||
f"custom: keys {seen[folded]!r} and {key!r} differ only by case — "
|
||||
"AutoCAD compares custom keys with a simple per-character case "
|
||||
"compare and calls these the same key (AddCustomInfo raises "
|
||||
"'Duplicate key'). Mention the key once."
|
||||
)
|
||||
seen[folded] = key
|
||||
if value is None:
|
||||
to_delete.append(key)
|
||||
elif isinstance(value, str):
|
||||
_check_custom_key_for_write(key)
|
||||
_check_custom_text(key, value, "value")
|
||||
to_write[key] = value
|
||||
else:
|
||||
raise TypeError(
|
||||
f"custom[{key!r}]: must be a string or null (null deletes the key), "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
return written, to_write, to_delete
|
||||
|
||||
+371
-2
@@ -39,6 +39,13 @@ from .base import (
|
||||
normalize_lineweight,
|
||||
shoelace_area,
|
||||
)
|
||||
from .contracts.settings import (
|
||||
SUMMARY_FIELDS,
|
||||
custom_key_fold,
|
||||
parse_scale,
|
||||
scale_value,
|
||||
validate_drawing_properties,
|
||||
)
|
||||
from .quarantine import (
|
||||
AbandonedCall,
|
||||
DocumentQuarantineError,
|
||||
@@ -59,6 +66,54 @@ __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, and reads report `None` rather than the value ezdxf's document
|
||||
#: template happens to hold in memory; `engineering.standards.sysvars` pins this
|
||||
#: set to its catalogue. OSMODE / ATTREQ / ATTDIA are the trap: ezdxf accepts
|
||||
#: `doc.header["$OSMODE"]` (an R12 header variable, `maxdxf="AC1009"` in
|
||||
#: `HEADER_VAR_MAP`) but never exports it for R2000+ - measured 2026-09-17: a
|
||||
#: write of 4134 to a fresh AC1024 document read back `None` after save + open.
|
||||
_UNSAVED_SYSVARS: frozenset[str] = frozenset(
|
||||
{
|
||||
"AUTOSNAP",
|
||||
"POLARANG",
|
||||
"POLARMODE",
|
||||
"OSMODE",
|
||||
"ATTREQ",
|
||||
"ATTDIA",
|
||||
"SAVETIME",
|
||||
"FILEDIA",
|
||||
"XREFCTL",
|
||||
"PROXYSHOW",
|
||||
"CMDECHO",
|
||||
"HIGHLIGHT",
|
||||
"HPNAME",
|
||||
"HPSCALE",
|
||||
"HPANG",
|
||||
"DWGNAME",
|
||||
"DWGPREFIX",
|
||||
}
|
||||
)
|
||||
|
||||
#: Header variables the DXF file stores in degrees (group code 50) while the
|
||||
#: sysvar boundary speaks radians - the unit ActiveX `GetVariable` and AutoLISP
|
||||
#: `getvar` use, so both engines report the same number. Measured on AutoCAD
|
||||
#: 2026 (2026-09-17): `SETVAR ANGBASE 90` reads back 1.5707963267948966 over
|
||||
#: COM and the DXF AutoCAD saves carries `$ANGBASE = 90.0`.
|
||||
_HEADER_DEGREE_SYSVARS: frozenset[str] = frozenset({"ANGBASE"})
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1361,6 +1416,13 @@ 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"
|
||||
),
|
||||
"dwgprops": FeatureCapability(
|
||||
False,
|
||||
reason="summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6247,14 +6309,77 @@ 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"))
|
||||
if key in _UNSAVED_SYSVARS:
|
||||
return None # a file holds no value; the template's memory is not one
|
||||
raw = doc.header.get(f"${key}", None)
|
||||
if key in _HEADER_DEGREE_SYSVARS and raw is not None:
|
||||
return math.radians(float(raw))
|
||||
return raw
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
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:
|
||||
stored = value
|
||||
if key in _HEADER_DEGREE_SYSVARS:
|
||||
stored = math.degrees(float(value))
|
||||
try:
|
||||
doc.header[f"${key}"] = stored
|
||||
except DXFKeyError as exc:
|
||||
# Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py)
|
||||
# while saying why the write is refused rather than dropped.
|
||||
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}
|
||||
|
||||
@@ -6788,3 +6913,247 @@ 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 _scale_entry_name(entry) -> str | None:
|
||||
"""The name a SCALE object carries in its AcDbScale group 300.
|
||||
|
||||
The dictionary *key* is not the name: AutoCAD saves ACAD_SCALELIST
|
||||
keyed `A0`, `A1`, … and only group 300 says `1:50`. ezdxf loads SCALE
|
||||
as `DXFTagStorage`, so the tag is read from the stored subclass.
|
||||
"""
|
||||
xtags = getattr(entry, "xtags", None)
|
||||
if xtags is None:
|
||||
return None
|
||||
for subclass in xtags.subclasses:
|
||||
if subclass and subclass[0].code == 100 and subclass[0].value == "AcDbScale":
|
||||
for tag in subclass:
|
||||
if tag.code == 300:
|
||||
return str(tag.value)
|
||||
return None
|
||||
|
||||
# AutoCAD's default metric ACAD_SCALELIST, in its own order (measured:
|
||||
# AutoCAD 2026, MEASUREMENT 1, `Documents.Add()` saved as DXF — 17 SCALE
|
||||
# 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",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _scale_list_key(index: int) -> str:
|
||||
"""The dictionary key AutoCAD gives the ``index``-th SCALE: A0 … A9, B0 …"""
|
||||
return f"{chr(ord('A') + index // 10)}{index % 10}"
|
||||
|
||||
@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
|
||||
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.
|
||||
"""
|
||||
from ezdxf.entities import factory
|
||||
from ezdxf.lldxf.extendedtags import ExtendedTags
|
||||
|
||||
existing_keys = set(scales.keys())
|
||||
index = 0
|
||||
while cls._scale_list_key(index) in existing_keys:
|
||||
index += 1
|
||||
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(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
|
||||
|
||||
def _set_annotation_scale(self, doc, value: Any) -> str:
|
||||
name, paper, drawing = parse_scale(value) # refused before any write
|
||||
if self._is_r12():
|
||||
# Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf
|
||||
# exports neither AcDbVariableDictionary/DICTIONARYVAR nor the
|
||||
# SCALE objects. The in-memory write "succeeded" and reloaded as
|
||||
# 1:1 -- the header-only-write-that-vanishes class again. Refused
|
||||
# here, before either dictionary is created, so a refused call
|
||||
# leaves the root dictionary as it found it.
|
||||
raise ValueError(
|
||||
"CANNOSCALE: R12 has no OBJECTS section, so the annotation scale "
|
||||
"(AcDbVariableDictionary and ACAD_SCALELIST) would be lost on save. "
|
||||
"Save as R2000 or newer first."
|
||||
)
|
||||
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
|
||||
|
||||
async def drawing_properties_get(self) -> dict:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
return {
|
||||
"summary": {field: None for field in SUMMARY_FIELDS},
|
||||
"summary_available": False,
|
||||
"custom": {tag: value for tag, value in doc.header.custom_vars},
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def drawing_properties_set(
|
||||
self, summary: dict | None = None, custom: dict | None = None
|
||||
) -> dict:
|
||||
written, to_write, to_delete = validate_drawing_properties(summary, custom)
|
||||
if written:
|
||||
# The five summary fields live in the DWG SummaryInfo stream, which
|
||||
# a DXF has no slot for; refuse before the custom keys are touched.
|
||||
raise UnsupportedCapabilityError(
|
||||
"dwgprops",
|
||||
"drawing_properties_set: the summary fields (title, subject, author, keywords, "
|
||||
"comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot "
|
||||
f"write (asked for {sorted(written)}). Custom properties work on both engines; "
|
||||
"set the summary on the live COM backend.",
|
||||
)
|
||||
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
if (to_write or to_delete) and doc.dxfversion < "AC1018":
|
||||
# Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only
|
||||
# after $LASTSAVEDBY, a header variable that does not exist
|
||||
# before R2004 (its own CustomVars docstring says so). On an
|
||||
# R12 or R2000 document the in-memory write "succeeded",
|
||||
# drawing_properties_get echoed it back, and drawing_save_as
|
||||
# (version kept) wrote nothing -- the header-only write that
|
||||
# vanishes on save, the class f3ff703 removed for CANNOSCALE.
|
||||
# Refused here, before custom_vars is touched.
|
||||
raise ValueError(
|
||||
f"custom properties: this document is DXF {doc.dxfversion} "
|
||||
f"({doc.acad_release}), and $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY "
|
||||
"header pairs are only written for R2004 (AC1018) or newer, so "
|
||||
f"{sorted(to_write) + sorted(to_delete)} would be lost on save. "
|
||||
"Save as R2004 or newer first."
|
||||
)
|
||||
custom_vars = doc.header.custom_vars
|
||||
# AutoCAD compares custom keys with a simple per-character case
|
||||
# compare (measured on 2026: SetCustomByKey("PROJECT") updates an
|
||||
# existing "Project" and keeps that spelling, a second AddCustomInfo
|
||||
# is 'Duplicate key' -- but 'Straße' and 'STRASSE' are two keys,
|
||||
# which str.casefold() would merge), and ezdxf's CustomVars are
|
||||
# plain case-sensitive tuples, so the match is done here with
|
||||
# custom_key_fold: an existing tag is updated in place under its
|
||||
# stored spelling, never duplicated under a second one.
|
||||
props = custom_vars.properties
|
||||
for key, value in to_write.items():
|
||||
folded = custom_key_fold(key)
|
||||
hits = [i for i, (tag, _v) in enumerate(props) if custom_key_fold(tag) == folded]
|
||||
if hits:
|
||||
props[hits[0]] = (props[hits[0]][0], value)
|
||||
else:
|
||||
custom_vars.append(key, value)
|
||||
deleted = []
|
||||
for key in to_delete:
|
||||
folded = custom_key_fold(key)
|
||||
kept = [(tag, v) for tag, v in props if custom_key_fold(tag) != folded]
|
||||
if len(kept) != len(props):
|
||||
props[:] = kept
|
||||
deleted.append(key)
|
||||
if to_write or deleted:
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"summary_written": [],
|
||||
"custom_written": sorted(to_write),
|
||||
"custom_deleted": deleted,
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
+61
-1
@@ -76,6 +76,8 @@ class ToolAliases:
|
||||
# list / create / modify / set-current; this server splits each into
|
||||
# discrete style tools (track E), so the command is a correct
|
||||
# destination for every one of them.
|
||||
# DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and
|
||||
# Custom (drawing_properties_get / drawing_properties_set).
|
||||
#
|
||||
# Every other AutoCAD command must map to exactly one tool.
|
||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
@@ -83,6 +85,7 @@ SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
"ARRAY",
|
||||
"BLOCK",
|
||||
"DIMSTYLE",
|
||||
"DWGPROPS",
|
||||
"ERASE",
|
||||
"INSERT",
|
||||
"LAYER",
|
||||
@@ -462,6 +465,28 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"drawing intent",
|
||||
),
|
||||
),
|
||||
"drawing_properties_get": ToolAliases(
|
||||
acad=("DWGPROPS",),
|
||||
synonyms=(
|
||||
"drawing properties",
|
||||
"document properties",
|
||||
"summary info",
|
||||
"title subject author keywords",
|
||||
"custom properties",
|
||||
"who made this drawing",
|
||||
),
|
||||
),
|
||||
"drawing_properties_set": ToolAliases(
|
||||
acad=("DWGPROPS",),
|
||||
synonyms=(
|
||||
"set the drawing title",
|
||||
"set author and keywords",
|
||||
"add a custom property",
|
||||
"project number in the file properties",
|
||||
"edit summary info",
|
||||
"delete a custom property",
|
||||
),
|
||||
),
|
||||
"drawing_preflight": ToolAliases(
|
||||
synonyms=(
|
||||
"preflight",
|
||||
@@ -507,7 +532,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",
|
||||
@@ -515,6 +553,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(
|
||||
@@ -1299,6 +1347,18 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"health check",
|
||||
),
|
||||
),
|
||||
"system_variable_describe": ToolAliases(
|
||||
acad=("SETVAR", "SYSVARMONITOR", "SYSVDLG"),
|
||||
synonyms=(
|
||||
"sysvar",
|
||||
"what does ltscale do",
|
||||
"explain a system variable",
|
||||
"system variable reference",
|
||||
"which variables are saved in the drawing",
|
||||
"valid range of a system variable",
|
||||
"list system variables",
|
||||
),
|
||||
),
|
||||
# ── Transactions ────────────────────────────────────────────────────────
|
||||
"transaction_begin": ToolAliases(
|
||||
synonyms=(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 176,
|
||||
"tools": 179,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 21
|
||||
@@ -14,7 +14,7 @@
|
||||
"blocks": 9,
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"drawing": 13,
|
||||
"engineering": 10,
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
@@ -25,7 +25,7 @@
|
||||
"premium": 12,
|
||||
"solids": 5,
|
||||
"styles": 10,
|
||||
"system": 7,
|
||||
"system": 8,
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
@@ -702,6 +702,34 @@
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_properties_get",
|
||||
"group": "drawing",
|
||||
"cost": "read",
|
||||
"summary": "Read the drawing's title, subject, author, keywords, comments and custom properties.",
|
||||
"tags": [
|
||||
"drawing",
|
||||
"settings"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Drawing Properties (read)",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_properties_set",
|
||||
"group": "drawing",
|
||||
"cost": "safe",
|
||||
"summary": "Set the drawing's summary fields and add, change or delete custom properties.",
|
||||
"tags": [
|
||||
"drawing",
|
||||
"settings"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Drawing Properties (write)",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_purge",
|
||||
"group": "drawing",
|
||||
@@ -2198,6 +2226,20 @@
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "system_variable_describe",
|
||||
"group": "system",
|
||||
"cost": "read",
|
||||
"summary": "Explain a system variable: type, range, default, where it is saved, which engine honours it.",
|
||||
"tags": [
|
||||
"settings",
|
||||
"system"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Describe System Variable",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "template_apply_layers",
|
||||
"group": "templates",
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
"""AutoCAD system-variable catalogue: what each one is, where it lives, who honours it.
|
||||
|
||||
Authored data, pinned by ``tests/test_sysvar_catalog.py``. Every entry says
|
||||
its type, range or enum, AutoCAD's default, a one-line meaning, where AutoCAD
|
||||
saves it (``drawing`` / ``registry`` / ``not_saved``), whether each engine can
|
||||
read *and write* it (``engines``), and the ``drawing_settings`` key that wraps
|
||||
it when one exists (``friendly_key``).
|
||||
|
||||
``engines["ezdxf"]`` is a measured claim, not a hope: it is ``True`` only when
|
||||
the headless backend has a real home for the value in the DXF file - a header
|
||||
variable ezdxf will export, the active VPORT (grid/snap), the variable
|
||||
dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved
|
||||
and never-saved variables are ``False`` because a file has nowhere to keep
|
||||
them, and ``EzdxfBackend.system_set_variable`` refuses them with
|
||||
``capability: registry_sysvar`` (OSMODE is one of them: ``$OSMODE`` is an R12
|
||||
header variable that ezdxf never exports for R2000+, so a headless write
|
||||
looked accepted and vanished at save until it joined the refused set). A
|
||||
handful of drawing-saved variables are also ``False`` because ezdxf 1.4 has no
|
||||
header slot for them (CTAB, VIEWCTR, VIEWSIZE, ISOLINES, FACETRES); that
|
||||
refusal is a plain ``ValueError``.
|
||||
|
||||
Angles are **radians** at the ``system_get_variable`` / ``system_set_variable``
|
||||
boundary on both engines - the unit ActiveX ``GetVariable`` / ``SetVariable``
|
||||
and AutoLISP ``getvar`` use (measured on AutoCAD 2026: ``SETVAR ANGBASE 90``
|
||||
reads back 1.5707963267948966, and so does HPANG after ``SETVAR HPANG 45``).
|
||||
The DXF header stores ``$ANGBASE`` in degrees (group code 50; the same
|
||||
document saved by AutoCAD carries ``$ANGBASE = 90.0``), so the headless engine
|
||||
translates that one variable at its boundary and the two engines report the
|
||||
same number. ``drawing_settings`` speaks degrees for its friendly keys.
|
||||
|
||||
Sources: the AutoCAD 2026 System Variables reference (type / saved-in /
|
||||
initial value columns), the DXF Reference HEADER and VPORT sections, and
|
||||
``ezdxf.sections.headervars.HEADER_VAR_MAP`` for what the headless engine
|
||||
exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import math
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"SAVED_IN",
|
||||
"SYSVAR_CATALOG",
|
||||
"SYSVAR_TYPES",
|
||||
"SysVar",
|
||||
"check_sysvar_value",
|
||||
"describe_sysvar",
|
||||
"search_sysvars",
|
||||
]
|
||||
|
||||
SYSVAR_TYPES = ("int", "float", "str", "bool", "point", "enum")
|
||||
SAVED_IN = ("drawing", "registry", "not_saved")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SysVar:
|
||||
name: str
|
||||
type: str
|
||||
range: tuple[float, float] | None
|
||||
enum: dict[int, str] | None
|
||||
default: Any
|
||||
meaning: str
|
||||
saved_in: str
|
||||
engines: dict[str, bool] = field(default_factory=lambda: {"ezdxf": True, "com": True})
|
||||
friendly_key: str | None = None
|
||||
read_only: bool = False
|
||||
|
||||
|
||||
_BOTH = {"ezdxf": True, "com": True}
|
||||
_COM_ONLY = {"ezdxf": False, "com": True}
|
||||
|
||||
|
||||
def _v(
|
||||
name: str,
|
||||
type_: str,
|
||||
meaning: str,
|
||||
*,
|
||||
range_: tuple[float, float] | None = None,
|
||||
enum: dict[int, str] | None = None,
|
||||
default: Any = None,
|
||||
saved_in: str = "drawing",
|
||||
engines: dict[str, bool] | None = None,
|
||||
friendly_key: str | None = None,
|
||||
read_only: bool = False,
|
||||
) -> SysVar:
|
||||
if engines is None:
|
||||
engines = _BOTH if saved_in == "drawing" else _COM_ONLY
|
||||
return SysVar(
|
||||
name=name,
|
||||
type=type_,
|
||||
range=range_,
|
||||
enum=enum,
|
||||
default=default,
|
||||
meaning=meaning,
|
||||
saved_in=saved_in,
|
||||
engines=dict(engines),
|
||||
friendly_key=friendly_key,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
|
||||
_POSITIVE = (1e-9, 1e9)
|
||||
_NON_NEGATIVE = (0.0, 1e9)
|
||||
_ACI = (0, 256)
|
||||
_LINEWEIGHT = (-3, 211)
|
||||
|
||||
_ENTRIES: tuple[SysVar, ...] = (
|
||||
# ── dimension variables (the dimstyle whitelist) ────────────────────────
|
||||
_v(
|
||||
"DIMTXT",
|
||||
"float",
|
||||
"dimension text height",
|
||||
range_=_POSITIVE,
|
||||
default=2.5,
|
||||
friendly_key="dim_text_height",
|
||||
),
|
||||
_v(
|
||||
"DIMASZ",
|
||||
"float",
|
||||
"arrowhead size",
|
||||
range_=_POSITIVE,
|
||||
default=2.5,
|
||||
friendly_key="dim_arrow_size",
|
||||
),
|
||||
_v(
|
||||
"DIMEXE",
|
||||
"float",
|
||||
"extension line extension beyond the dimension line",
|
||||
range_=_POSITIVE,
|
||||
default=1.25,
|
||||
),
|
||||
_v(
|
||||
"DIMEXO",
|
||||
"float",
|
||||
"extension line offset from the origin points",
|
||||
range_=_NON_NEGATIVE,
|
||||
default=0.625,
|
||||
),
|
||||
_v(
|
||||
"DIMGAP",
|
||||
"float",
|
||||
"gap between dimension line and text; a negative value draws a box around the text",
|
||||
default=0.625,
|
||||
),
|
||||
_v(
|
||||
"DIMTAD",
|
||||
"enum",
|
||||
"vertical text placement relative to the dimension line",
|
||||
enum={0: "centered", 1: "above", 2: "outside", 3: "JIS", 4: "below"},
|
||||
default=1,
|
||||
),
|
||||
_v(
|
||||
"DIMTIH",
|
||||
"bool",
|
||||
"text inside the extension lines is horizontal (1) or aligned with the line (0)",
|
||||
default=0,
|
||||
),
|
||||
_v(
|
||||
"DIMTOH",
|
||||
"bool",
|
||||
"text outside the extension lines is horizontal (1) or aligned with the line (0)",
|
||||
default=0,
|
||||
),
|
||||
_v(
|
||||
"DIMDEC",
|
||||
"int",
|
||||
"decimal places of the primary dimension value",
|
||||
range_=(0, 8),
|
||||
default=2,
|
||||
friendly_key="dim_decimals",
|
||||
),
|
||||
_v(
|
||||
"DIMDSEP",
|
||||
"str",
|
||||
"decimal separator character for decimal dimensions (the headless engine stores its character code: 44 is ',')",
|
||||
default=",",
|
||||
friendly_key="decimal_separator",
|
||||
),
|
||||
_v(
|
||||
"DIMLUNIT",
|
||||
"enum",
|
||||
"linear unit format for dimensions",
|
||||
enum={
|
||||
1: "scientific",
|
||||
2: "decimal",
|
||||
3: "engineering",
|
||||
4: "architectural",
|
||||
5: "fractional",
|
||||
6: "windows desktop",
|
||||
},
|
||||
default=2,
|
||||
),
|
||||
_v(
|
||||
"DIMZIN",
|
||||
"int",
|
||||
"zero suppression bitmask: 1 feet, 2 inches, 4 leading, 8 trailing",
|
||||
range_=(0, 15),
|
||||
default=8,
|
||||
friendly_key="zero_suppression",
|
||||
),
|
||||
_v("DIMBLK", "str", "arrowhead block name; empty means closed filled", default=""),
|
||||
_v("DIMTXSTY", "str", "text style used by dimension text", default="Standard"),
|
||||
_v(
|
||||
"DIMLWD",
|
||||
"int",
|
||||
"dimension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)",
|
||||
range_=_LINEWEIGHT,
|
||||
default=-2,
|
||||
),
|
||||
_v(
|
||||
"DIMLWE",
|
||||
"int",
|
||||
"extension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)",
|
||||
range_=_LINEWEIGHT,
|
||||
default=-2,
|
||||
),
|
||||
_v(
|
||||
"DIMSCALE",
|
||||
"float",
|
||||
"overall scale factor applied to dimension sizes; 0 scales to the viewport",
|
||||
range_=(0.0, 1e6),
|
||||
default=1.0,
|
||||
friendly_key="dimscale",
|
||||
),
|
||||
_v("DIMTOL", "bool", "append tolerances to dimension text", default=0),
|
||||
_v("DIMTP", "float", "upper tolerance limit", default=0.0),
|
||||
_v("DIMTM", "float", "lower tolerance limit", default=0.0),
|
||||
_v(
|
||||
"DIMTOLJ",
|
||||
"enum",
|
||||
"vertical justification of tolerance text",
|
||||
enum={0: "bottom", 1: "middle", 2: "top"},
|
||||
default=1,
|
||||
),
|
||||
_v(
|
||||
"DIMTFAC",
|
||||
"float",
|
||||
"tolerance text height as a factor of DIMTXT",
|
||||
range_=_POSITIVE,
|
||||
default=1.0,
|
||||
),
|
||||
_v(
|
||||
"DIMLFAC",
|
||||
"float",
|
||||
"linear measurement scale factor; negative applies in paper space only",
|
||||
default=1.0,
|
||||
),
|
||||
_v(
|
||||
"DIMRND",
|
||||
"float",
|
||||
"rounding increment for linear dimensions; 0 rounds nothing",
|
||||
range_=_NON_NEGATIVE,
|
||||
default=0.0,
|
||||
),
|
||||
_v(
|
||||
"DIMATFIT",
|
||||
"enum",
|
||||
"what moves outside when text and arrows do not fit",
|
||||
enum={0: "text and arrows", 1: "arrows first", 2: "text first", 3: "best fit"},
|
||||
default=3,
|
||||
),
|
||||
_v(
|
||||
"DIMTMOVE",
|
||||
"enum",
|
||||
"how the dimension line behaves when text is moved",
|
||||
enum={0: "move line with text", 1: "add a leader", 2: "text moves freely"},
|
||||
default=0,
|
||||
),
|
||||
_v(
|
||||
"DIMCLRD",
|
||||
"int",
|
||||
"dimension line colour (ACI: 0 ByBlock, 256 ByLayer)",
|
||||
range_=_ACI,
|
||||
default=0,
|
||||
),
|
||||
_v(
|
||||
"DIMCLRE",
|
||||
"int",
|
||||
"extension line colour (ACI: 0 ByBlock, 256 ByLayer)",
|
||||
range_=_ACI,
|
||||
default=0,
|
||||
),
|
||||
_v(
|
||||
"DIMCLRT",
|
||||
"int",
|
||||
"dimension text colour (ACI: 0 ByBlock, 256 ByLayer)",
|
||||
range_=_ACI,
|
||||
default=0,
|
||||
),
|
||||
_v("DIMSAH", "bool", "use DIMBLK1 / DIMBLK2 as separate arrowheads", default=0),
|
||||
_v("DIMBLK1", "str", "first arrowhead block when DIMSAH is on", default=""),
|
||||
_v("DIMBLK2", "str", "second arrowhead block when DIMSAH is on", default=""),
|
||||
_v(
|
||||
"DIMCEN",
|
||||
"float",
|
||||
"centre mark size for radial dimensions; 0 none, negative draws centre lines",
|
||||
default=2.5,
|
||||
),
|
||||
# ── scales, units, precision ─────────────────────────────────────────────
|
||||
_v(
|
||||
"LTSCALE",
|
||||
"float",
|
||||
"global linetype scale factor",
|
||||
range_=_POSITIVE,
|
||||
default=1.0,
|
||||
friendly_key="ltscale",
|
||||
),
|
||||
_v(
|
||||
"PSLTSCALE",
|
||||
"bool",
|
||||
"scale linetypes by the viewport scale in paper space",
|
||||
default=1,
|
||||
friendly_key="psltscale",
|
||||
),
|
||||
_v(
|
||||
"INSUNITS",
|
||||
"enum",
|
||||
"drawing units for inserted content and scaling",
|
||||
enum={
|
||||
0: "unitless",
|
||||
1: "inches",
|
||||
2: "feet",
|
||||
3: "miles",
|
||||
4: "millimeters",
|
||||
5: "centimeters",
|
||||
6: "meters",
|
||||
7: "kilometers",
|
||||
8: "microinches",
|
||||
9: "mils",
|
||||
10: "yards",
|
||||
11: "angstroms",
|
||||
12: "nanometers",
|
||||
13: "microns",
|
||||
14: "decimeters",
|
||||
15: "decameters",
|
||||
16: "hectometers",
|
||||
17: "gigameters",
|
||||
18: "astronomical units",
|
||||
19: "light years",
|
||||
20: "parsecs",
|
||||
},
|
||||
default=4,
|
||||
friendly_key="units",
|
||||
),
|
||||
_v(
|
||||
"LUNITS",
|
||||
"enum",
|
||||
"linear unit display format",
|
||||
enum={1: "scientific", 2: "decimal", 3: "engineering", 4: "architectural", 5: "fractional"},
|
||||
default=2,
|
||||
friendly_key="linear_units",
|
||||
),
|
||||
_v(
|
||||
"LUPREC",
|
||||
"int",
|
||||
"linear unit display precision (decimal places)",
|
||||
range_=(0, 8),
|
||||
default=4,
|
||||
friendly_key="linear_precision",
|
||||
),
|
||||
_v(
|
||||
"AUNITS",
|
||||
"enum",
|
||||
"angular unit display format",
|
||||
enum={
|
||||
0: "decimal degrees",
|
||||
1: "degrees/minutes/seconds",
|
||||
2: "grads",
|
||||
3: "radians",
|
||||
4: "surveyor",
|
||||
},
|
||||
default=0,
|
||||
friendly_key="angular_units",
|
||||
),
|
||||
_v(
|
||||
"AUPREC",
|
||||
"int",
|
||||
"angular unit display precision (decimal places)",
|
||||
range_=(0, 8),
|
||||
default=0,
|
||||
friendly_key="angular_precision",
|
||||
),
|
||||
_v(
|
||||
"CANNOSCALE",
|
||||
"str",
|
||||
"current annotation scale name, e.g. '1:50' (a DICTIONARYVAR in AcDbVariableDictionary, not a header variable)",
|
||||
default="1:1",
|
||||
friendly_key="annotation_scale",
|
||||
),
|
||||
_v(
|
||||
"CANNOSCALEVALUE",
|
||||
"float",
|
||||
"current annotation scale as a number (paper / drawing), derived from CANNOSCALE",
|
||||
default=1.0,
|
||||
read_only=True,
|
||||
),
|
||||
# ── limits, grid, snap, tracking ─────────────────────────────────────────
|
||||
_v(
|
||||
"LIMMIN",
|
||||
"point",
|
||||
"lower-left corner of the drawing limits (LIMITS)",
|
||||
default=(0.0, 0.0),
|
||||
friendly_key="limits",
|
||||
),
|
||||
_v(
|
||||
"LIMMAX",
|
||||
"point",
|
||||
"upper-right corner of the drawing limits (LIMITS)",
|
||||
default=(420.0, 297.0),
|
||||
friendly_key="limits",
|
||||
),
|
||||
_v(
|
||||
"GRIDMODE",
|
||||
"bool",
|
||||
"grid display on/off (stored on the active VPORT, not in the header)",
|
||||
default=0,
|
||||
friendly_key="grid",
|
||||
),
|
||||
_v(
|
||||
"GRIDUNIT",
|
||||
"point",
|
||||
"grid spacing [x, y] (stored on the active VPORT)",
|
||||
default=(10.0, 10.0),
|
||||
friendly_key="grid_spacing",
|
||||
),
|
||||
_v(
|
||||
"SNAPMODE",
|
||||
"bool",
|
||||
"snap on/off (stored on the active VPORT)",
|
||||
default=0,
|
||||
friendly_key="snap",
|
||||
),
|
||||
_v(
|
||||
"SNAPUNIT",
|
||||
"point",
|
||||
"snap spacing [x, y] (stored on the active VPORT)",
|
||||
default=(10.0, 10.0),
|
||||
friendly_key="snap_spacing",
|
||||
),
|
||||
_v(
|
||||
"ORTHOMODE",
|
||||
"bool",
|
||||
"ortho mode: cursor movement constrained to horizontal/vertical",
|
||||
default=0,
|
||||
friendly_key="ortho",
|
||||
),
|
||||
_v(
|
||||
"POLARMODE",
|
||||
"int",
|
||||
"polar tracking bitmask: 1 relative angles, 2 polar in osnap tracking, 4 additional angles, 8 tooltips (polar on/off itself is AUTOSNAP bit 8)",
|
||||
range_=(0, 15),
|
||||
default=0,
|
||||
saved_in="registry",
|
||||
),
|
||||
_v(
|
||||
"POLARANG",
|
||||
"float",
|
||||
"polar angle increment in radians (90 degrees = 1.5708)",
|
||||
range_=(1e-9, 2 * math.pi),
|
||||
default=math.pi / 2,
|
||||
saved_in="registry",
|
||||
friendly_key="polar_angle",
|
||||
),
|
||||
_v(
|
||||
"AUTOSNAP",
|
||||
"int",
|
||||
"AutoSnap bitmask: 1 marker, 2 magnet, 4 tooltip, 8 polar tracking on, 16 object snap tracking, 32 tracking tooltips, 64 dynamic input aperture",
|
||||
range_=(0, 127),
|
||||
default=63,
|
||||
saved_in="registry",
|
||||
friendly_key="polar",
|
||||
),
|
||||
_v(
|
||||
"OSMODE",
|
||||
"int",
|
||||
"running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); registry-saved, and a DXF R2000+ file carries no $OSMODE, so the headless engine refuses it",
|
||||
range_=(0, 32767),
|
||||
default=4133,
|
||||
saved_in="registry",
|
||||
friendly_key="osmode",
|
||||
),
|
||||
# ── current properties ───────────────────────────────────────────────────
|
||||
_v(
|
||||
"TEXTSIZE",
|
||||
"float",
|
||||
"default height for new TEXT entities (not dimension text)",
|
||||
range_=_POSITIVE,
|
||||
default=2.5,
|
||||
friendly_key="text_size",
|
||||
),
|
||||
_v("TEXTSTYLE", "str", "current text style name", default="Standard", friendly_key="textstyle"),
|
||||
_v(
|
||||
"DIMSTYLE",
|
||||
"str",
|
||||
"current dimension style name (read-only: change it with dimstyle_set_current or drawing_settings {'dimstyle': ...})",
|
||||
default="ISO-25",
|
||||
friendly_key="dimstyle",
|
||||
read_only=True,
|
||||
),
|
||||
_v("CLAYER", "str", "current layer name", default="0"),
|
||||
_v(
|
||||
"CELTYPE",
|
||||
"str",
|
||||
"current entity linetype (ByLayer, ByBlock or a loaded linetype)",
|
||||
default="ByLayer",
|
||||
),
|
||||
_v(
|
||||
"CECOLOR",
|
||||
"str",
|
||||
"current entity colour ('BYLAYER', 'BYBLOCK' or an ACI number; the headless engine stores the ACI code, 256 is ByLayer)",
|
||||
default="BYLAYER",
|
||||
),
|
||||
_v(
|
||||
"CELWEIGHT",
|
||||
"int",
|
||||
"current entity lineweight code (-1 ByLayer, -2 ByBlock, -3 default, else hundredths of mm)",
|
||||
range_=_LINEWEIGHT,
|
||||
default=-1,
|
||||
),
|
||||
_v(
|
||||
"PDMODE",
|
||||
"int",
|
||||
"point display mode (0-4, or those plus 32 / 64 / 96)",
|
||||
range_=(0, 100),
|
||||
default=0,
|
||||
friendly_key="point_mode",
|
||||
),
|
||||
_v(
|
||||
"PDSIZE",
|
||||
"float",
|
||||
"point display size; 0 is 5 percent of the view, negative is a percentage of the viewport",
|
||||
default=0.0,
|
||||
friendly_key="point_size",
|
||||
),
|
||||
_v(
|
||||
"FILLETRAD",
|
||||
"float",
|
||||
"current fillet radius",
|
||||
range_=_NON_NEGATIVE,
|
||||
default=0.0,
|
||||
friendly_key="fillet_radius",
|
||||
),
|
||||
_v("MIRRTEXT", "bool", "mirror text with MIRROR (1) or keep it readable (0)", default=0),
|
||||
_v("ATTREQ", "bool", "prompt for attribute values on INSERT", default=1, saved_in="registry"),
|
||||
_v(
|
||||
"ATTDIA",
|
||||
"bool",
|
||||
"use a dialog for attribute values on INSERT",
|
||||
default=0,
|
||||
saved_in="registry",
|
||||
),
|
||||
_v("PLINEWID", "float", "default width for new polylines", range_=_NON_NEGATIVE, default=0.0),
|
||||
_v(
|
||||
"ANGBASE",
|
||||
"float",
|
||||
"direction of angle 0 relative to the current UCS, in radians on both engines (90 degrees = 1.5708; the DXF header stores degrees and the headless engine translates)",
|
||||
default=0.0,
|
||||
),
|
||||
_v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0),
|
||||
# ── layouts, views, UCS ──────────────────────────────────────────────────
|
||||
_v("TILEMODE", "bool", "1 model tab active, 0 a layout tab active", default=1),
|
||||
_v(
|
||||
"CTAB",
|
||||
"str",
|
||||
"name of the current tab (Model or a layout); headlessly use layout_list / layout_set_current",
|
||||
default="Model",
|
||||
engines=_COM_ONLY,
|
||||
),
|
||||
_v(
|
||||
"UCSNAME",
|
||||
"str",
|
||||
"name of the current UCS; empty when unnamed or world",
|
||||
default="",
|
||||
read_only=True,
|
||||
),
|
||||
_v(
|
||||
"VIEWCTR",
|
||||
"point",
|
||||
"centre of the current view (read-only; VPORT centre in a file)",
|
||||
default=(0.0, 0.0),
|
||||
engines=_COM_ONLY,
|
||||
read_only=True,
|
||||
),
|
||||
_v(
|
||||
"VIEWSIZE",
|
||||
"float",
|
||||
"height of the current view in drawing units (read-only)",
|
||||
default=100.0,
|
||||
engines=_COM_ONLY,
|
||||
read_only=True,
|
||||
),
|
||||
_v("REGENMODE", "bool", "automatic regeneration on", default=1),
|
||||
_v("LWDISPLAY", "bool", "display lineweights on screen", default=0),
|
||||
# ── file, session ────────────────────────────────────────────────────────
|
||||
_v(
|
||||
"DWGNAME",
|
||||
"str",
|
||||
"name of the current drawing file (read-only)",
|
||||
default="Drawing1.dwg",
|
||||
saved_in="not_saved",
|
||||
read_only=True,
|
||||
),
|
||||
_v(
|
||||
"DWGPREFIX",
|
||||
"str",
|
||||
"folder of the current drawing file (read-only)",
|
||||
default="",
|
||||
saved_in="not_saved",
|
||||
read_only=True,
|
||||
),
|
||||
_v(
|
||||
"SAVETIME",
|
||||
"int",
|
||||
"automatic save interval in minutes; 0 disables",
|
||||
range_=(0, 600),
|
||||
default=10,
|
||||
saved_in="registry",
|
||||
),
|
||||
_v(
|
||||
"FILEDIA",
|
||||
"bool",
|
||||
"show file dialogs (1) or take file names from the command line (0)",
|
||||
default=1,
|
||||
saved_in="registry",
|
||||
),
|
||||
_v(
|
||||
"CMDECHO",
|
||||
"bool",
|
||||
"echo prompts and input during AutoLISP command execution",
|
||||
default=1,
|
||||
saved_in="not_saved",
|
||||
),
|
||||
_v("HIGHLIGHT", "bool", "highlight selected objects", default=1, saved_in="not_saved"),
|
||||
_v(
|
||||
"XREFCTL",
|
||||
"bool",
|
||||
"write external reference log (.xlg) files",
|
||||
default=0,
|
||||
saved_in="registry",
|
||||
),
|
||||
_v(
|
||||
"PROXYSHOW",
|
||||
"enum",
|
||||
"how proxy objects are displayed",
|
||||
enum={0: "not shown", 1: "shown", 2: "bounding box"},
|
||||
default=1,
|
||||
saved_in="registry",
|
||||
),
|
||||
# ── hatch ────────────────────────────────────────────────────────────────
|
||||
_v(
|
||||
"HPNAME",
|
||||
"str",
|
||||
"default hatch pattern name for HATCH",
|
||||
default="ANSI31",
|
||||
saved_in="not_saved",
|
||||
),
|
||||
_v(
|
||||
"HPSCALE",
|
||||
"float",
|
||||
"default hatch pattern scale",
|
||||
range_=_POSITIVE,
|
||||
default=1.0,
|
||||
saved_in="not_saved",
|
||||
),
|
||||
_v(
|
||||
"HPANG",
|
||||
"float",
|
||||
"default hatch pattern angle in radians, as ActiveX and AutoLISP hold it (45 degrees = 0.7854)",
|
||||
default=0.0,
|
||||
saved_in="not_saved",
|
||||
),
|
||||
# ── 3D display ───────────────────────────────────────────────────────────
|
||||
_v(
|
||||
"ISOLINES",
|
||||
"int",
|
||||
"contour lines per surface on 3D solids",
|
||||
range_=(0, 2047),
|
||||
default=4,
|
||||
engines=_COM_ONLY,
|
||||
),
|
||||
_v(
|
||||
"FACETRES",
|
||||
"float",
|
||||
"smoothness of shaded and rendered curved solids",
|
||||
range_=(0.01, 10.0),
|
||||
default=0.5,
|
||||
engines=_COM_ONLY,
|
||||
),
|
||||
_v("DISPSILH", "bool", "display silhouette edges of 3D solids in wireframe", default=0),
|
||||
)
|
||||
|
||||
SYSVAR_CATALOG: dict[str, SysVar] = {entry.name: entry for entry in _ENTRIES}
|
||||
|
||||
|
||||
def _as_dict(var: SysVar) -> dict:
|
||||
out = asdict(var)
|
||||
out["known"] = True
|
||||
out["range"] = list(var.range) if var.range is not None else None
|
||||
out["default"] = list(var.default) if isinstance(var.default, tuple) else var.default
|
||||
return out
|
||||
|
||||
|
||||
def describe_sysvar(name: str) -> dict:
|
||||
"""The catalogue row for ``name`` (case-insensitive), or ``known: False`` plus the nearest names."""
|
||||
key = str(name).strip().upper().lstrip("$")
|
||||
var = SYSVAR_CATALOG.get(key)
|
||||
if var is not None:
|
||||
return _as_dict(var)
|
||||
names = sorted(SYSVAR_CATALOG)
|
||||
nearest = difflib.get_close_matches(key, names, n=5, cutoff=0.6)
|
||||
for candidate in names:
|
||||
if len(nearest) >= 5:
|
||||
break
|
||||
if key and candidate.startswith(key[:3]) and candidate not in nearest:
|
||||
nearest.append(candidate)
|
||||
return {"known": False, "name": key, "nearest": nearest}
|
||||
|
||||
|
||||
def search_sysvars(text: str) -> list[dict]:
|
||||
"""Catalogue rows whose name, meaning or friendly key contains every word of ``text``."""
|
||||
words = [word for word in str(text).lower().split() if word]
|
||||
if not words:
|
||||
return []
|
||||
hits = []
|
||||
for var in SYSVAR_CATALOG.values():
|
||||
haystack = " ".join((var.name, var.meaning, var.friendly_key or "")).lower()
|
||||
if all(word in haystack for word in words):
|
||||
hits.append(_as_dict(var))
|
||||
return sorted(hits, key=lambda row: row["name"])
|
||||
|
||||
|
||||
_TRUTHY = {"0", "1", "on", "off", "true", "false"}
|
||||
|
||||
|
||||
def check_sysvar_value(name: str, value: Any) -> str | None:
|
||||
"""A refusal message when ``value`` cannot be ``name``'s value; ``None`` when fine or unknown.
|
||||
|
||||
Soft by design: a variable the catalogue has not heard of is passed
|
||||
through untouched, so ``system_set_variable`` keeps working for the
|
||||
hundreds of variables nobody has authored a row for.
|
||||
"""
|
||||
var = SYSVAR_CATALOG.get(str(name).strip().upper().lstrip("$"))
|
||||
if var is None:
|
||||
return None
|
||||
if var.read_only:
|
||||
return f"{var.name} is read-only: {var.meaning}"
|
||||
if var.type in ("int", "float"):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
return f"{var.name}: expected a number, got {value!r}"
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
return f"{var.name}: expected a number, got {value!r}"
|
||||
if not math.isfinite(number):
|
||||
return f"{var.name}: must be a finite number"
|
||||
if var.type == "int" and number != int(number):
|
||||
return f"{var.name}: expected an integer, got {value!r}"
|
||||
if var.range is not None:
|
||||
low, high = var.range
|
||||
if not low <= number <= high:
|
||||
return f"{var.name}: {number:g} is out of range {low:g}..{high:g} - {var.meaning}"
|
||||
return None
|
||||
if var.type == "bool":
|
||||
if value in (0, 1) or str(value).strip().lower() in _TRUTHY:
|
||||
return None
|
||||
return f"{var.name}: expected 0 or 1 (on/off), got {value!r}"
|
||||
if var.type == "enum":
|
||||
try:
|
||||
code = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return f"{var.name}: expected one of {sorted(var.enum)}, got {value!r}"
|
||||
if code not in var.enum:
|
||||
choices = ", ".join(f"{k}={v}" for k, v in var.enum.items())
|
||||
return f"{var.name}: {code} is not a valid code ({choices})"
|
||||
return None
|
||||
if var.type == "point":
|
||||
try:
|
||||
float(value[0]), float(value[1])
|
||||
except (TypeError, IndexError, ValueError):
|
||||
return f"{var.name}: expected an [x, y] point, got {value!r}"
|
||||
return None
|
||||
return None
|
||||
@@ -5096,7 +5096,22 @@ async def system_set_variable(
|
||||
value: Annotated[Any, "New variable value"],
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT)."""
|
||||
"""Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT).
|
||||
|
||||
Refusals, before anything is written: a value outside the catalogued
|
||||
range or enum of a known variable, or a write to a read-only variable
|
||||
(DIMSTYLE, CANNOSCALEVALUE, DWGNAME, …), is refused with the catalogue's
|
||||
message — `system_variable_describe(name)` shows the range. A variable the
|
||||
catalogue does not know is passed through unchanged. The headless engine
|
||||
additionally refuses registry-saved variables (`capability:
|
||||
registry_sysvar`) because a file has nowhere to keep them, and names that
|
||||
ezdxf has no header slot for (a `ValueError` naming the variable).
|
||||
"""
|
||||
from engineering.standards.sysvars import check_sysvar_value
|
||||
|
||||
message = check_sysvar_value(name, value)
|
||||
if message:
|
||||
raise ToolError(f"system_set_variable refused: {message}")
|
||||
return await _backend(ctx).system_set_variable(name, value)
|
||||
|
||||
|
||||
@@ -5122,8 +5137,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,
|
||||
@@ -5132,14 +5152,31 @@ 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`; `osmode` / `polar` / `polar_angle`
|
||||
on the headless engine (`capability: registry_sysvar` — AutoCAD keeps them in
|
||||
the registry, a file cannot, and a headless snapshot reports them as `None`);
|
||||
`dimstyle` / `textstyle` when the backend has no styles contract. On the
|
||||
live engine `annotation_scale` must name a scale in the drawing's scale list
|
||||
(SCALELISTEDIT) and AutoCAD refuses the write on a
|
||||
paper-space layout with no active viewport; the headless engine adds the
|
||||
scale, seeding AutoCAD's default list first when the drawing has none,
|
||||
and refuses it on an R12 file (no OBJECTS section, so the value would
|
||||
vanish at save — save as R2000 or newer first; limits/grid/ortho survive).
|
||||
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)}")
|
||||
@@ -7193,6 +7230,147 @@ async def drawing_apply_standard(
|
||||
return await apply_standard(_backend(ctx), standard, layers, units)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 20: Environment (3 tools) ───────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group C (settings) opens this section with `system_variable_describe` and,
|
||||
# in Task 16, the two `drawing_properties_*` tools; group V's merge extends the
|
||||
# header count to the full environment surface (documents, layer states,
|
||||
# named views, UCS, launch, preferences, interactive prompts).
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Explain a system variable: type, range, default, where it is saved, which engine honours it.",
|
||||
cost="read",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Describe System Variable", "readOnlyHint": True},
|
||||
tags={"system", "settings"},
|
||||
)
|
||||
async def system_variable_describe(
|
||||
name: Annotated[
|
||||
str | None, "System variable name, e.g. LTSCALE (case-insensitive, '$' optional)"
|
||||
] = None,
|
||||
search: Annotated[
|
||||
str | None, "Free text matched against names and meanings, e.g. 'decimal separator'"
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""What an AutoCAD system variable means, from an authored catalogue of 90.
|
||||
|
||||
With `name`: `{known, name, type, range|enum, default, meaning, saved_in
|
||||
(drawing|registry|not_saved), engines: {ezdxf, com}, friendly_key,
|
||||
read_only}` plus `current` when the active backend can read it (omitted,
|
||||
with `current_unavailable`, when it cannot — no document open, or a
|
||||
registry variable headlessly). An unknown name is never an error: `known:
|
||||
false` and the nearest catalogue names come back. With `search`: every row
|
||||
whose name or meaning contains all the words. With neither: the index of
|
||||
names. `friendly_key` names the `drawing_settings` key that wraps the
|
||||
variable, which is the preferred way to set it; `engines.ezdxf: false`
|
||||
means the headless engine refuses a write (registry-saved, or no header
|
||||
slot in ezdxf).
|
||||
"""
|
||||
from engineering.standards.sysvars import SYSVAR_CATALOG, describe_sysvar, search_sysvars
|
||||
|
||||
if name:
|
||||
row = describe_sysvar(name)
|
||||
backend = ctx.lifespan_context.get("backend") if ctx is not None else None
|
||||
if row["known"] and backend is not None:
|
||||
try:
|
||||
row["current"] = await backend.system_get_variable(row["name"])
|
||||
except Exception as exc: # no document, or the engine cannot hold it
|
||||
row["current_unavailable"] = str(exc)
|
||||
return row
|
||||
if search:
|
||||
return {"query": search, "matches": search_sysvars(search)}
|
||||
return {
|
||||
"count": len(SYSVAR_CATALOG),
|
||||
"names": sorted(SYSVAR_CATALOG),
|
||||
"hint": "pass name=<VARIABLE> for one row or search=<words> to filter",
|
||||
}
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Read the drawing's title, subject, author, keywords, comments and custom properties.",
|
||||
cost="read",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Drawing Properties (read)", "readOnlyHint": True},
|
||||
tags={"drawing", "settings"},
|
||||
)
|
||||
async def drawing_properties_get(ctx: Context = None) -> dict:
|
||||
"""The DWGPROPS dialog as data: `summary` (title, subject, author, keywords,
|
||||
comments), `summary_available`, and `custom` ({key: value}).
|
||||
|
||||
On the live engine the summary comes from the document's SummaryInfo. The
|
||||
headless engine cannot read that stream, so the five summary fields are
|
||||
`null` with `summary_available: false` — not empty strings, which would
|
||||
claim the drawing has no title. Custom properties (`$CUSTOMPROPERTYTAG` /
|
||||
`$CUSTOMPROPERTY` header pairs) are read on both engines.
|
||||
"""
|
||||
return await _backend(ctx).drawing_properties_get()
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Set the drawing's summary fields and add, change or delete custom properties.",
|
||||
cost="safe",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Drawing Properties (write)", "readOnlyHint": False},
|
||||
tags={"drawing", "settings"},
|
||||
)
|
||||
async def drawing_properties_set(
|
||||
title: Annotated[str | None, "SummaryInfo Title"] = None,
|
||||
subject: Annotated[str | None, "SummaryInfo Subject"] = None,
|
||||
author: Annotated[str | None, "SummaryInfo Author"] = None,
|
||||
keywords: Annotated[str | None, "SummaryInfo Keywords"] = None,
|
||||
comments: Annotated[str | None, "SummaryInfo Comments"] = None,
|
||||
custom: Annotated[
|
||||
dict | None,
|
||||
"Custom properties to write: {key: text}; a null value deletes the key. "
|
||||
"Keys not mentioned are left alone.",
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Write DWGPROPS fields. Only the arguments given are touched; returns
|
||||
`summary_written`, `custom_written` and `custom_deleted` (a key that was
|
||||
never there is not reported deleted).
|
||||
|
||||
Refusals, before anything is written: a summary field on the headless
|
||||
engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has
|
||||
no slot for it; custom properties still work headlessly), a non-string
|
||||
value (`TypeError` naming the field or key — nothing is coerced with
|
||||
`str()`), an empty custom key, a custom key to *write* that AutoCAD's
|
||||
AddCustomInfo would reject mid-write (measured on AutoCAD 2026: leading or
|
||||
trailing whitespace, or any of the thirteen characters
|
||||
`" * , / : ; < = > ? \\ ` |` anywhere in the key; internal spaces, tabs
|
||||
and unicode are fine — `ValueError` naming the key, on both engines; a
|
||||
delete is exempt because RemoveCustomByKey never validates syntax, so a
|
||||
key that reached the drawing another way stays removable), two keys in
|
||||
one request that AutoCAD would call the same key (its key compare is a
|
||||
simple per-character case compare: `Project`/`PROJECT` and `Grün`/`GRÜN`
|
||||
are one key, `Straße`/`STRASSE` are two), a line break in a custom key or
|
||||
value (it corrupts the DXF on save), and headlessly a document older than
|
||||
R2004 (`ValueError` — ezdxf only writes the custom-property header pairs
|
||||
for AC1018+, so they would vanish at save; save as R2004 or newer first).
|
||||
A key is matched to the drawing by that same rule on both engines and
|
||||
keeps its stored spelling when updated. On the live engine an exact
|
||||
spelling is changed with SetCustomByKey; otherwise AutoCAD decides —
|
||||
AddCustomInfo, and on its 'Duplicate key' SetCustomByKey; a delete is
|
||||
RemoveCustomByKey, and its 'Key not found' is reported as not deleted.
|
||||
"""
|
||||
summary = {
|
||||
"title": title,
|
||||
"subject": subject,
|
||||
"author": author,
|
||||
"keywords": keywords,
|
||||
"comments": comments,
|
||||
}
|
||||
touched = [k for k, v in summary.items() if v is not None] + sorted(custom or {})
|
||||
await ctx.info(f"Setting drawing properties: {', '.join(touched) or 'nothing'}")
|
||||
return await _backend(ctx).drawing_properties_set(summary, custom)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -317,6 +317,19 @@ async def test_com_backend_connects():
|
||||
# number it already has?
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_acad_doc():
|
||||
"""`_measure_backend` swaps the module-level `_acad_doc`; put it back after
|
||||
every test here. Left in place, the MagicMock document leaked into every
|
||||
later test of the session that resolves the document itself (the settings
|
||||
facade's COM tests read sysvars through `_acad_doc()`)."""
|
||||
import backends.com_backend as cb
|
||||
|
||||
original = cb._acad_doc
|
||||
yield
|
||||
cb._acad_doc = original
|
||||
|
||||
|
||||
def _measure_backend(entity):
|
||||
"""A ComBackend whose HandleToObject returns exactly this entity."""
|
||||
import backends.com_backend as cb
|
||||
@@ -329,7 +342,7 @@ def _measure_backend(entity):
|
||||
backend._run = _run
|
||||
doc = MagicMock()
|
||||
doc.HandleToObject.return_value = entity
|
||||
cb._acad_doc = lambda: doc
|
||||
cb._acad_doc = lambda: doc # restored by `_restore_acad_doc`
|
||||
return backend
|
||||
|
||||
|
||||
|
||||
@@ -55,14 +55,16 @@ class _Doc:
|
||||
|
||||
|
||||
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
|
||||
app = MagicMock()
|
||||
app.GetVariable.return_value = 4159 # OSMODE current value is an int
|
||||
monkeypatch.setattr(cb, "_acad_app", lambda: app)
|
||||
# GetVariable / SetVariable are AcadDocument members (the Application has
|
||||
# neither), so the fake is the document that `_acad_doc()` returns.
|
||||
doc = MagicMock()
|
||||
doc.GetVariable.return_value = 4159 # OSMODE current value is an int
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
|
||||
b = _backend_no_executor()
|
||||
out = await b.system_set_variable("OSMODE", "0")
|
||||
|
||||
name, val = app.SetVariable.call_args[0]
|
||||
name, val = doc.SetVariable.call_args[0]
|
||||
assert name == "OSMODE"
|
||||
assert val == 0 and isinstance(val, int) # coerced from "0" to int 0
|
||||
assert out["value"] == 0
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
"""drawing_properties_get / set: DWG summary and custom properties on both engines.
|
||||
|
||||
Headlessly the five summary fields live in the DWG SummaryInfo stream, which
|
||||
ezdxf cannot write, so any non-null summary field is refused with
|
||||
`capability: dwgprops` before anything is touched; custom properties are
|
||||
`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both
|
||||
engines (measured: they survive save + reopen on R2004 and newer; ezdxf
|
||||
only emits the pairs for AC1018+, so an older document is refused headlessly
|
||||
instead of losing the write at save).
|
||||
|
||||
Custom keys and values are validated once, by name, against what would break
|
||||
*either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as
|
||||
`Invalid key` (measured on AutoCAD 2026 by sweeping every printable ASCII
|
||||
character through a scratch document: leading/trailing whitespace and exactly
|
||||
the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key --
|
||||
internal spaces, tabs, control characters, a no-break space and unicode are
|
||||
accepted), two keys AutoCAD would call one key (its key compare is a simple
|
||||
per-character case compare -- `Project`/`PROJECT` collide with `Duplicate
|
||||
key`, `Stra\u00dfe`/`STRASSE` are both stored; `custom_key_fold` pins the
|
||||
measured table), and line breaks, which ezdxf writes unescaped and which
|
||||
corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules
|
||||
(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.contracts.settings import (
|
||||
_CUSTOM_KEY_FORBIDDEN,
|
||||
SUMMARY_FIELDS,
|
||||
custom_key_fold,
|
||||
validate_drawing_properties,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
EMPTY_SUMMARY = {field: None for field in SUMMARY_FIELDS}
|
||||
|
||||
|
||||
# ── shared validation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_validation_splits_writes_and_deletes():
|
||||
written, to_write, to_delete = validate_drawing_properties(
|
||||
{"title": "Gearbox", "author": None}, {"PROJECT": "X-1", "OLD": None}
|
||||
)
|
||||
assert written == {"title": "Gearbox"}
|
||||
assert to_write == {"PROJECT": "X-1"} and to_delete == ["OLD"]
|
||||
assert validate_drawing_properties(None, None) == ({}, {}, [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"summary, custom, exc, fragment",
|
||||
[
|
||||
({"edition": "1"}, None, ValueError, "edition"),
|
||||
({"title": 3}, None, TypeError, "summary.title"),
|
||||
(None, {"N": 3}, TypeError, "custom['N']"),
|
||||
(None, {"": "x"}, TypeError, "non-empty"),
|
||||
("Gearbox", None, TypeError, "summary"),
|
||||
(None, ["PROJECT"], TypeError, "custom"),
|
||||
# Keys AutoCAD's AddCustomInfo rejects as 'Invalid key' (measured on
|
||||
# AutoCAD 2026) -- refused here so the live engine never fails
|
||||
# mid-write after the summary and earlier keys were already applied.
|
||||
(None, {" BAD ": "x"}, ValueError, "' BAD '"),
|
||||
(None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"),
|
||||
(None, {"\tTAB": "x"}, ValueError, "leading or trailing whitespace"),
|
||||
(None, {"NB\xa0": "x"}, ValueError, "leading or trailing whitespace"),
|
||||
(None, {"A=B": "x"}, ValueError, "'A=B' contains '='"),
|
||||
(None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"),
|
||||
(None, {"A:B/C": "x"}, ValueError, "'A:B/C' contains '/' ':'"),
|
||||
# Case-variant duplicates: AutoCAD raises 'Duplicate key' on the second.
|
||||
(None, {"Project": "a", "PROJECT": "b"}, ValueError, "differ only by case"),
|
||||
(None, {"Project": "a", "PROJECT": None}, ValueError, "differ only by case"),
|
||||
(None, {"Gr\u00fcn": "a", "GR\u00dcN": "b"}, ValueError, "differ only by case"),
|
||||
# A line break is written unescaped by ezdxf and corrupts the DXF.
|
||||
(None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"),
|
||||
(None, {"NOTE": "line1\rline2"}, ValueError, "line break"),
|
||||
(None, {"K\nEY": "x"}, ValueError, "the key contains a line break"),
|
||||
(None, {"K\nEY": None}, ValueError, "the key contains a line break"),
|
||||
],
|
||||
)
|
||||
def test_validation_refuses_by_name(summary, custom, exc, fragment):
|
||||
with pytest.raises(exc) as excinfo:
|
||||
validate_drawing_properties(summary, custom)
|
||||
assert fragment in str(excinfo.value)
|
||||
|
||||
|
||||
INVALID_KEY_CHARS = '"*,/:;<=>?\\`|'
|
||||
|
||||
|
||||
def test_forbidden_set_is_exactly_the_thirteen_measured_characters():
|
||||
assert _CUSTOM_KEY_FORBIDDEN == frozenset(INVALID_KEY_CHARS)
|
||||
assert len(_CUSTOM_KEY_FORBIDDEN) == 13
|
||||
|
||||
|
||||
@pytest.mark.parametrize("char", sorted(INVALID_KEY_CHARS))
|
||||
def test_validation_refuses_every_addcustominfo_invalid_key_character(char):
|
||||
"""Measured on AutoCAD 2026 (`AddCustomInfo(f"A{c}B", "v")` for every
|
||||
c in 0x20-0x7E on a scratch document): exactly these thirteen raise
|
||||
`Invalid key`. The earlier fix mirrored only `=` and `;`, so 'A:B',
|
||||
'REV/2', 'SIZE, mm', 'A"B' ... still half-applied on the live engine."""
|
||||
key = f"A{char}B"
|
||||
with pytest.raises(ValueError, match="Invalid key") as excinfo:
|
||||
validate_drawing_properties(None, {key: "x"})
|
||||
assert repr(key) in str(excinfo.value) and repr(char) in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"IN SP",
|
||||
"T\tAB",
|
||||
"\u00dcn\u00efcode",
|
||||
"X" * 300,
|
||||
"NB\xa0SP",
|
||||
"A\x01B",
|
||||
"A\x1fB",
|
||||
"A!#$%&'()+-.@[]^_{}~B",
|
||||
"\u4e2d\u2014\u200b",
|
||||
],
|
||||
)
|
||||
def test_validation_accepts_keys_autocad_accepts(key):
|
||||
"""Measured: internal spaces, tabs, control characters, a no-break space,
|
||||
unicode, every other printable ASCII character and 300-char keys pass
|
||||
AddCustomInfo; the validator must not be stricter than AutoCAD."""
|
||||
assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["A=B", " PAD ", "A:B", "NB\xa0", 'A"B'])
|
||||
def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key):
|
||||
"""A delete never calls AddCustomInfo, and RemoveCustomByKey does not
|
||||
validate syntax (measured: 'Key not found', never 'Invalid key'), so a key
|
||||
a DXF script wrote must stay removable. The line-break rule still holds
|
||||
for both directions (a delete of such a key is pinned refused above)."""
|
||||
assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key])
|
||||
|
||||
|
||||
# Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the
|
||||
# first on a scratch Documents.Add() document, Close(False)): SAME is
|
||||
# 'Duplicate key', DISTINCT is both stored. Every DISTINCT pair is equal under
|
||||
# Python's str.casefold(), which is why a casefold() routing sent 'STRASSE' to
|
||||
# SetCustomByKey over 'Stra\u00dfe' and failed 'Key not found' mid-write.
|
||||
SAME_KEY_PAIRS = [
|
||||
("Project", "PROJECT"),
|
||||
("Gr\u00fcn", "GR\u00dcN"),
|
||||
("\u00e9", "\u00c9"), # é / É
|
||||
("\u00ff", "\u0178"), # ÿ / Ÿ
|
||||
("\u0142", "\u0141"), # ł / Ł
|
||||
("\u03c3", "\u03a3"), # σ / Σ
|
||||
("\u044f", "\u042f"), # я / Я
|
||||
("\u01c6", "\u01c4"), # dž / DŽ
|
||||
("i", "I"),
|
||||
]
|
||||
# The six pairs str.casefold() merges (measured DISTINCT in AutoCAD).
|
||||
CASEFOLD_MERGED_PAIRS = [
|
||||
("Stra\u00dfe", "STRASSE"), # ß uppercases to SS
|
||||
("\u1e9e", "\u00df"), # ẞ / ß
|
||||
("kelvin", "\u212aelvin"), # k / Kelvin sign
|
||||
("\u00b5", "\u039c"), # micro sign / Greek capital mu
|
||||
("\u03c3\u03c2", "\u03a3\u03a3"), # σς / ΣΣ (final sigma)
|
||||
("\u01c5", "\u01c4"), # Dž / DŽ (title-case digraph)
|
||||
]
|
||||
DISTINCT_KEY_PAIRS = CASEFOLD_MERGED_PAIRS + [
|
||||
("\u0131", "I"), # dotless ı / I (the fold is not Turkish-aware either)
|
||||
("\u0130", "i"), # dotted İ / i (lower() is two code points)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a, b", SAME_KEY_PAIRS)
|
||||
def test_custom_key_fold_merges_what_autocad_calls_one_key(a, b):
|
||||
assert custom_key_fold(a) == custom_key_fold(b)
|
||||
assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS)
|
||||
def test_custom_key_fold_keeps_what_autocad_stores_twice(a, b):
|
||||
assert custom_key_fold(a) != custom_key_fold(b)
|
||||
assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a, b", CASEFOLD_MERGED_PAIRS)
|
||||
def test_casefold_is_the_wrong_rule_for_these(a, b):
|
||||
"""Why 4fe2d96 regressed: every one of these is casefold()-equal."""
|
||||
assert a.casefold() == b.casefold()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS)
|
||||
def test_validation_accepts_two_keys_autocad_stores_side_by_side(a, b):
|
||||
"""Measured: AddCustomInfo('Stra\u00dfe') then AddCustomInfo('STRASSE')
|
||||
both succeed, so refusing them as 'differ only by case' was a false
|
||||
refusal with a false reason. German \u00df keys are realistic title-block
|
||||
metadata."""
|
||||
assert validate_drawing_properties(None, {a: "1", b: "2"}) == ({}, {a: "1", b: "2"}, [])
|
||||
|
||||
|
||||
# ── headless engine ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_get_reports_no_summary_and_empty_custom(backend):
|
||||
assert await backend.drawing_properties_get() == {
|
||||
"summary": EMPTY_SUMMARY,
|
||||
"summary_available": False,
|
||||
"custom": {},
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
|
||||
|
||||
async def test_custom_properties_write_replace_delete_and_survive_reopen(backend, tmp_path):
|
||||
first = await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
|
||||
assert first == {
|
||||
"ok": True,
|
||||
"summary_written": [],
|
||||
"custom_written": ["PROJECT", "REV"],
|
||||
"custom_deleted": [],
|
||||
"backend": "ezdxf",
|
||||
}
|
||||
second = await backend.drawing_properties_set(
|
||||
custom={"REV": None, "NOPE": None, "PROJECT": "X-2"}
|
||||
)
|
||||
assert second["custom_written"] == ["PROJECT"]
|
||||
assert second["custom_deleted"] == ["REV"], "a key that was never there is not 'deleted'"
|
||||
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
|
||||
assert [t for t, _ in backend._doc.header.custom_vars] == ["PROJECT"], "no duplicate tags"
|
||||
|
||||
path = str(tmp_path / "props.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
|
||||
|
||||
|
||||
async def test_line_break_is_refused_before_any_write_and_the_file_still_opens(backend, tmp_path):
|
||||
"""Measured: `custom={'NOTE': 'line1\\nline2', 'AFTER': 'ok'}` reported
|
||||
`ok: True`, and the saved DXF failed to reopen with
|
||||
`DXFStructureError: Invalid group code "line2"` -- the whole drawing lost,
|
||||
reported as success. The request is refused as a whole, so AFTER is not
|
||||
written either, and the file saved afterwards reopens."""
|
||||
with pytest.raises(ValueError, match=r"custom\['NOTE'\]: the value contains a line break"):
|
||||
await backend.drawing_properties_set(custom={"NOTE": "line1\nline2", "AFTER": "ok"})
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
path = str(tmp_path / "clean.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
|
||||
|
||||
async def test_autocad_invalid_keys_are_refused_headlessly_too(backend):
|
||||
"""The headless engine used to write ' BAD ', 'A=B' and 'A;B' without
|
||||
complaint (and AutoCAD loaded them from the DXF) while the live engine
|
||||
half-applied the same call; both now refuse before writing."""
|
||||
with pytest.raises(ValueError, match="' BAD '"):
|
||||
await backend.drawing_properties_set(custom={"AAA": "first", " BAD ": "x", "ZZZ": "n"})
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
|
||||
|
||||
async def test_legacy_invalid_syntax_keys_stay_deletable_headlessly(tmp_path):
|
||||
"""Regression of 2b9791f: `_check_custom_key` ran before the `value is
|
||||
None` branch, so `{'A=B': None}` on a document carrying 'A=B' (any ezdxf
|
||||
script writes such keys; AutoCAD loads them from the DXF) was refused with
|
||||
a reason that only applies to AddCustomInfo -- the tool showed a key it
|
||||
could never remove."""
|
||||
import ezdxf
|
||||
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
path = tmp_path / "legacy.dxf"
|
||||
doc = ezdxf.new("R2010")
|
||||
for tag in ("A=B", "NB\xa0SP", " PAD ", "A:B"):
|
||||
doc.header.custom_vars.append(tag, "legacy")
|
||||
doc.saveas(str(path))
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
try:
|
||||
await backend.drawing_open(str(path))
|
||||
assert (await backend.drawing_properties_get())["custom"] == {
|
||||
"A=B": "legacy",
|
||||
"NB\xa0SP": "legacy",
|
||||
" PAD ": "legacy",
|
||||
"A:B": "legacy",
|
||||
}
|
||||
res = await backend.drawing_properties_set(custom={"A=B": None, " PAD ": None})
|
||||
assert res["custom_deleted"] == ["A=B", " PAD "]
|
||||
assert (await backend.drawing_properties_get())["custom"] == {
|
||||
"NB\xa0SP": "legacy",
|
||||
"A:B": "legacy",
|
||||
}
|
||||
# ...but writing such a key is still refused, before anything is touched.
|
||||
with pytest.raises(ValueError, match="'A:B' contains ':'"):
|
||||
await backend.drawing_properties_set(custom={"A:B": "new", "OK": "x"})
|
||||
assert (await backend.drawing_properties_get())["custom"] == {
|
||||
"NB\xa0SP": "legacy",
|
||||
"A:B": "legacy",
|
||||
}
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
async def test_custom_keys_match_case_insensitively_headlessly(backend):
|
||||
"""Mirrors AutoCAD (measured on 2026): a write to 'PROJECT' over an
|
||||
existing 'Project' updates it under the stored spelling instead of adding
|
||||
a second tag AutoCAD would call a duplicate; a delete matches either
|
||||
spelling."""
|
||||
await backend.drawing_properties_set(custom={"Project": "X-1", "Rev": "A"})
|
||||
res = await backend.drawing_properties_set(custom={"PROJECT": "X-2", "rev": None})
|
||||
assert res["custom_written"] == ["PROJECT"] and res["custom_deleted"] == ["rev"]
|
||||
assert list(backend._doc.header.custom_vars) == [("Project", "X-2")]
|
||||
assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS)
|
||||
async def test_headless_keeps_keys_autocad_keeps_apart(backend, a, b):
|
||||
"""Measured: `custom={'Stra\u00dfe': 's1'}` then `{'STRASSE': 's2'}` gave
|
||||
custom={'Stra\u00dfe': 's2'} headlessly -- the first value overwritten and
|
||||
no 'STRASSE' tag -- where AutoCAD holds both. Same for the Kelvin sign."""
|
||||
await backend.drawing_properties_set(custom={a: "s1"})
|
||||
res = await backend.drawing_properties_set(custom={b: "s2"})
|
||||
assert res["custom_written"] == [b]
|
||||
assert list(backend._doc.header.custom_vars) == [(a, "s1"), (b, "s2")]
|
||||
res = await backend.drawing_properties_set(custom={b: None})
|
||||
assert res["custom_deleted"] == [b]
|
||||
assert list(backend._doc.header.custom_vars) == [(a, "s1")]
|
||||
|
||||
|
||||
async def _backend_for_version(tmp_path, version: str):
|
||||
import ezdxf
|
||||
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
path = tmp_path / f"{version}.dxf"
|
||||
ezdxf.new(version).saveas(str(path))
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
await backend.drawing_open(str(path))
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version, dxfversion", [("R12", "AC1009"), ("R2000", "AC1015")])
|
||||
async def test_custom_properties_before_r2004_are_refused_rather_than_lost(
|
||||
tmp_path, version, dxfversion
|
||||
):
|
||||
"""Measured: on an R12 or R2000 document `drawing_properties_set` reported
|
||||
`ok: True, custom_written: ['PROJECT']`, `drawing_properties_get` echoed
|
||||
it from memory, and `drawing_save_as` (version kept) wrote nothing --
|
||||
ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after $LASTSAVEDBY,
|
||||
which does not exist before R2004. The header-only write that vanishes on
|
||||
save, the class f3ff703 removed for CANNOSCALE. Refused up front, before
|
||||
custom_vars is touched; the reopened file is read back to prove it."""
|
||||
backend = await _backend_for_version(tmp_path, version)
|
||||
try:
|
||||
assert backend._doc.dxfversion == dxfversion
|
||||
with pytest.raises(ValueError, match=dxfversion) as excinfo:
|
||||
await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
|
||||
assert "R2004 or newer" in str(excinfo.value)
|
||||
assert "PROJECT" in str(excinfo.value)
|
||||
assert list(backend._doc.header.custom_vars) == []
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
# A delete on the same document is refused for the same reason: it
|
||||
# would report a key removed that the file could never have held.
|
||||
with pytest.raises(ValueError, match="R2004 or newer"):
|
||||
await backend.drawing_properties_set(custom={"PROJECT": None})
|
||||
# An empty request touches nothing and is not a write to refuse.
|
||||
res = await backend.drawing_properties_set(custom={})
|
||||
assert res["ok"] is True and res["custom_written"] == []
|
||||
|
||||
out = tmp_path / f"{version}_out.dxf"
|
||||
await backend.drawing_save_as(str(out))
|
||||
await backend.drawing_open(str(out))
|
||||
assert backend._doc.dxfversion == dxfversion
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
async def test_custom_properties_on_r2004_survive_save_and_reopen(tmp_path):
|
||||
"""The oldest version that carries the pairs -- the boundary the refusal
|
||||
sits on -- round-trips."""
|
||||
backend = await _backend_for_version(tmp_path, "R2004")
|
||||
try:
|
||||
res = await backend.drawing_properties_set(custom={"PROJECT": "X-1"})
|
||||
assert res["custom_written"] == ["PROJECT"]
|
||||
out = tmp_path / "r2004_out.dxf"
|
||||
await backend.drawing_save_as(str(out))
|
||||
await backend.drawing_open(str(out))
|
||||
assert backend._doc.dxfversion == "AC1018"
|
||||
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-1"}
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
async def test_summary_is_refused_headlessly_before_any_write(backend):
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"})
|
||||
assert excinfo.value.capability == "dwgprops"
|
||||
assert "title" in str(excinfo.value)
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
|
||||
|
||||
async def test_all_null_summary_is_not_a_summary_write(backend):
|
||||
res = await backend.drawing_properties_set(summary={"title": None, "author": None})
|
||||
assert res["ok"] is True and res["summary_written"] == []
|
||||
|
||||
|
||||
async def test_malformed_custom_is_refused_by_name_before_any_write(backend):
|
||||
with pytest.raises(TypeError, match=r"custom\['N'\]"):
|
||||
await backend.drawing_properties_set(custom={"OK": "1", "N": 3})
|
||||
assert (await backend.drawing_properties_get())["custom"] == {}
|
||||
|
||||
|
||||
def test_both_capability_maps_declare_dwgprops():
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
assert EzdxfBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is False
|
||||
assert ComBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is True
|
||||
|
||||
|
||||
# ── server tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, backend):
|
||||
self.lifespan_context = {"backend": backend}
|
||||
|
||||
async def info(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
async def test_server_tools_round_trip_custom_and_refuse_summary(backend):
|
||||
import server
|
||||
|
||||
ctx = _FakeCtx(backend)
|
||||
res = await server.drawing_properties_set(custom={"PROJECT": "X-9"}, ctx=ctx)
|
||||
assert res["custom_written"] == ["PROJECT"]
|
||||
got = await server.drawing_properties_get(ctx=ctx)
|
||||
assert got["custom"] == {"PROJECT": "X-9"} and got["summary_available"] is False
|
||||
with pytest.raises(UnsupportedCapabilityError):
|
||||
await server.drawing_properties_set(title="Gearbox", ctx=ctx)
|
||||
|
||||
|
||||
# ── live engine, against a fake SummaryInfo ─────────────────────────────────
|
||||
|
||||
|
||||
class _FakeSummaryInfo:
|
||||
def __init__(self):
|
||||
self.Title = "Old title"
|
||||
self.Subject = ""
|
||||
self.Author = ""
|
||||
self.Keywords = "gear"
|
||||
self.Comments = ""
|
||||
self._custom: list[tuple[str, str]] = [("PROJECT", "X-1"), ("OLD", "1")]
|
||||
self.calls: list[tuple] = []
|
||||
|
||||
def NumCustomInfo(self):
|
||||
self.calls.append(("NumCustomInfo",))
|
||||
return len(self._custom)
|
||||
|
||||
def GetCustomByIndex(self, index):
|
||||
self.calls.append(("GetCustomByIndex", index))
|
||||
return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple
|
||||
|
||||
# Measured on AutoCAD 2026: the three mutators match keys by AutoCAD's
|
||||
# simple per-character case compare (`custom_key_fold` pins the table --
|
||||
# 'PROJECT' finds 'Project', 'STRASSE' does not find 'Stra\u00dfe'), an
|
||||
# existing key keeps its stored spelling, a second Add is 'Duplicate key'
|
||||
# (scode -2145386475), a Set/Remove of an absent key is 'Key not found'
|
||||
# (scode -2145386476), and a key AddCustomInfo rejects is 'Invalid key'
|
||||
# (scode -2145386465). The com_error shape is the one pywin32 raised live:
|
||||
# a localised HRESULT text and the English description in the excepinfo.
|
||||
|
||||
def _index(self, key):
|
||||
for i, (k, _v) in enumerate(self._custom):
|
||||
if custom_key_fold(k) == custom_key_fold(key):
|
||||
return i
|
||||
return None
|
||||
|
||||
def AddCustomInfo(self, key, value):
|
||||
self.calls.append(("AddCustomInfo", key, value))
|
||||
if set(key) & _CUSTOM_KEY_FORBIDDEN:
|
||||
raise _com_error("Invalid key", -2145386465)
|
||||
if self._index(key) is not None:
|
||||
raise _com_error("Duplicate key", -2145386475)
|
||||
self._custom.append((key, value))
|
||||
|
||||
def SetCustomByKey(self, key, value):
|
||||
self.calls.append(("SetCustomByKey", key, value))
|
||||
i = self._index(key)
|
||||
if i is None:
|
||||
raise _com_error("Key not found", -2145386476)
|
||||
self._custom[i] = (self._custom[i][0], value)
|
||||
|
||||
def RemoveCustomByKey(self, key):
|
||||
self.calls.append(("RemoveCustomByKey", key))
|
||||
i = self._index(key)
|
||||
if i is None:
|
||||
raise _com_error("Key not found", -2145386476)
|
||||
del self._custom[i]
|
||||
|
||||
|
||||
def _com_error(description, scode):
|
||||
import pywintypes
|
||||
|
||||
return pywintypes.com_error(
|
||||
-2147352567,
|
||||
"\u00d6zel durum olu\u015ftu.",
|
||||
(0, "AutoCAD.Application", description, "OLE_ERR.CHM", scode, scode),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from backends import com_backend as module
|
||||
|
||||
info = _FakeSummaryInfo()
|
||||
document = types.SimpleNamespace(SummaryInfo=info)
|
||||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, info
|
||||
|
||||
|
||||
async def test_com_get_reads_summary_and_custom(com_backend):
|
||||
backend, info = com_backend
|
||||
assert await backend.drawing_properties_get() == {
|
||||
"summary": {
|
||||
"title": "Old title",
|
||||
"subject": "",
|
||||
"author": "",
|
||||
"keywords": "gear",
|
||||
"comments": "",
|
||||
},
|
||||
"summary_available": True,
|
||||
"custom": {"PROJECT": "X-1", "OLD": "1"},
|
||||
"backend": "com",
|
||||
}
|
||||
|
||||
|
||||
async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend):
|
||||
backend, info = com_backend
|
||||
res = await backend.drawing_properties_set(
|
||||
summary={"title": "Gearbox", "author": "U. Can", "subject": None},
|
||||
custom={"PROJECT": "X-2", "REV": "B", "OLD": None, "NOPE": None},
|
||||
)
|
||||
assert res == {
|
||||
"ok": True,
|
||||
"summary_written": ["author", "title"],
|
||||
"custom_written": ["PROJECT", "REV"],
|
||||
"custom_deleted": ["OLD"],
|
||||
"backend": "com",
|
||||
}
|
||||
assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear"
|
||||
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
|
||||
assert mutations == [
|
||||
("SetCustomByKey", "PROJECT", "X-2"), # exact spelling present → set
|
||||
("AddCustomInfo", "REV", "B"), # new → add
|
||||
("RemoveCustomByKey", "OLD"), # removed
|
||||
("RemoveCustomByKey", "NOPE"), # 'Key not found' → not reported deleted
|
||||
]
|
||||
assert info._custom == [("PROJECT", "X-2"), ("REV", "B")]
|
||||
|
||||
|
||||
async def test_com_validates_before_touching_activex(com_backend):
|
||||
backend, info = com_backend
|
||||
with pytest.raises(TypeError):
|
||||
await backend.drawing_properties_set(summary={"title": "T"}, custom={"N": 3})
|
||||
with pytest.raises(ValueError):
|
||||
await backend.drawing_properties_set(summary={"edition": "1"})
|
||||
assert info.calls == [] and info.Title == "Old title"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key", ["A:B", "REV/2", "SIZE, mm", 'A"B', "A\\B", "A<B>", "A|B", "A*B", "A?B"]
|
||||
)
|
||||
async def test_com_refuses_every_addcustominfo_invalid_key_before_any_write(com_backend, key):
|
||||
"""Re-measured on AutoCAD 2026 after 2b9791f: `summary={'subject':
|
||||
'PARTIAL?'}, custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}` still
|
||||
raised from AddCustomInfo('A:B') and left subject='PARTIAL?',
|
||||
custom={'AAA': 'first'} -- the same half-write for every character the
|
||||
first fix did not mirror. Nothing reaches SummaryInfo now."""
|
||||
backend, info = com_backend
|
||||
with pytest.raises(ValueError, match="Invalid key"):
|
||||
await backend.drawing_properties_set(
|
||||
summary={"subject": "PARTIAL?"},
|
||||
custom={"AAA": "first", key: "x", "ZZZ": "never"},
|
||||
)
|
||||
assert info.calls == [] and info.Subject == ""
|
||||
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
|
||||
|
||||
|
||||
async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend):
|
||||
"""RemoveCustomByKey does not validate syntax (measured: 'Key not found'
|
||||
for an absent 'A=B', never 'Invalid key'), so a key AutoCAD loaded from a
|
||||
DXF is removed, not refused."""
|
||||
backend, info = com_backend
|
||||
info._custom.append(("A=B", "legacy"))
|
||||
res = await backend.drawing_properties_set(custom={"A=B": None})
|
||||
assert res["custom_deleted"] == ["A=B"]
|
||||
assert ("RemoveCustomByKey", "A=B") in info.calls
|
||||
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
|
||||
|
||||
|
||||
async def test_com_lets_autocad_route_a_case_variant_key(com_backend):
|
||||
"""Measured on AutoCAD 2026: AddCustomInfo('project') over an existing
|
||||
'PROJECT' raises 'Duplicate key' -- a case-sensitive existence check
|
||||
routed it there and failed mid-write; a casefold() check then routed
|
||||
'STRASSE' over 'Stra\u00dfe' to SetCustomByKey and failed the same way.
|
||||
Now only an exact spelling is routed here; otherwise AutoCAD's own
|
||||
compare decides: 'Duplicate key' falls back to SetCustomByKey."""
|
||||
backend, info = com_backend
|
||||
res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"})
|
||||
assert res["custom_written"] == ["New", "project"] and res["custom_deleted"] == ["old"]
|
||||
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
|
||||
assert mutations == [
|
||||
("AddCustomInfo", "project", "X-2"), # AutoCAD: 'Duplicate key'
|
||||
("SetCustomByKey", "project", "X-2"), # → set under the stored spelling
|
||||
("AddCustomInfo", "New", "n"),
|
||||
("RemoveCustomByKey", "old"),
|
||||
]
|
||||
assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stored, written", DISTINCT_KEY_PAIRS)
|
||||
async def test_com_writes_a_key_autocad_keeps_apart_without_half_writing(
|
||||
com_backend, stored, written
|
||||
):
|
||||
"""Measured live: with 'Stra\u00dfe' present, `summary={'subject': 'HALF?'},
|
||||
custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': 'never?'}` routed
|
||||
'STRASSE' to SetCustomByKey on the casefold() match, AutoCAD answered
|
||||
'Key not found', and the request raised with subject and BEFORE applied
|
||||
and AFTER never written. AddCustomInfo of both spellings succeeds in
|
||||
AutoCAD; the route must not be guessed from a Python fold."""
|
||||
backend, info = com_backend
|
||||
info._custom.append((stored, "s1"))
|
||||
# A delete of the other spelling is 'Key not found' in AutoCAD (measured:
|
||||
# RuntimeError after the request's writes were applied): reported as not
|
||||
# deleted, no error, and the rest of the request still lands.
|
||||
res = await backend.drawing_properties_set(custom={written: None, "OLD": None})
|
||||
assert res["custom_deleted"] == ["OLD"] and res["ok"] is True
|
||||
assert info._custom == [("PROJECT", "X-1"), (stored, "s1")]
|
||||
res = await backend.drawing_properties_set(
|
||||
summary={"subject": "HALF?"},
|
||||
custom={"BEFORE": "b", written: "s2", "AFTER": "never?"},
|
||||
)
|
||||
assert res["custom_written"] == sorted(["BEFORE", written, "AFTER"])
|
||||
assert info.Subject == "HALF?"
|
||||
assert info._custom == [
|
||||
("PROJECT", "X-1"),
|
||||
(stored, "s1"),
|
||||
("BEFORE", "b"),
|
||||
(written, "s2"),
|
||||
("AFTER", "never?"),
|
||||
]
|
||||
assert ("SetCustomByKey", written, "s2") not in info.calls
|
||||
# Each spelling is now its own key: deleting one keeps the other.
|
||||
res = await backend.drawing_properties_set(custom={written: None})
|
||||
assert res["custom_deleted"] == [written]
|
||||
assert (written, "s2") not in info._custom and (stored, "s1") in info._custom
|
||||
|
||||
|
||||
async def test_com_propagates_an_unrelated_summaryinfo_failure(com_backend):
|
||||
"""Only 'Duplicate key' is answered with SetCustomByKey and only 'Key not
|
||||
found' is swallowed on a delete; any other COM failure keeps raising (the
|
||||
fixture runs the COM callable inline, so the raw com_error is what
|
||||
surfaces here; `_run` wraps it in RuntimeError on the real path)."""
|
||||
import pywintypes
|
||||
|
||||
backend, info = com_backend
|
||||
|
||||
def _boom(key, value):
|
||||
info.calls.append(("AddCustomInfo", key, value))
|
||||
raise _com_error("Some other failure", -2145386400)
|
||||
|
||||
info.AddCustomInfo = _boom
|
||||
with pytest.raises(pywintypes.com_error, match="Some other failure"):
|
||||
await backend.drawing_properties_set(custom={"NEW": "x"})
|
||||
assert ("SetCustomByKey", "NEW", "x") not in info.calls
|
||||
|
||||
def _boom_remove(key):
|
||||
info.calls.append(("RemoveCustomByKey", key))
|
||||
raise _com_error("Some other failure", -2145386400)
|
||||
|
||||
info.RemoveCustomByKey = _boom_remove
|
||||
with pytest.raises(pywintypes.com_error, match="Some other failure"):
|
||||
await backend.drawing_properties_set(custom={"OLD": None})
|
||||
|
||||
|
||||
async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend):
|
||||
"""Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'},
|
||||
custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed
|
||||
COM error from AddCustomInfo(' BAD ') and left subject and AAA written,
|
||||
ZZZ absent -- a half-applied write reported as an error. The shared
|
||||
validator now names the key and nothing reaches SummaryInfo."""
|
||||
backend, info = com_backend
|
||||
with pytest.raises(ValueError, match="' BAD '"):
|
||||
await backend.drawing_properties_set(
|
||||
summary={"subject": "PARTIAL?"},
|
||||
custom={"AAA": "first", " BAD ": "x", "ZZZ": "never"},
|
||||
)
|
||||
assert info.calls == [] and info.Subject == ""
|
||||
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
|
||||
@@ -0,0 +1,678 @@
|
||||
"""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 json
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 "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)
|
||||
|
||||
# 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
|
||||
again = await backend.drawing_settings({"annotation_scale": "1:50"})
|
||||
assert again["changed"] == {}
|
||||
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")
|
||||
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,
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""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
|
||||
carries 1:1, 1:2, 1:5, 1:10, 1:20, 1:50, 1:100, 2:1 ...)."""
|
||||
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")
|
||||
for index, name in enumerate(names):
|
||||
paper, drawing = (float(part) for part in name.split(":"))
|
||||
handle = doc.entitydb.next_handle()
|
||||
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{int(paper == drawing)}\n"
|
||||
)
|
||||
entry = factory.load(ExtendedTags.from_text(text), doc)
|
||||
doc.entitydb.add(entry)
|
||||
doc.objects.add_object(entry)
|
||||
scales.add(f"A{index}", entry)
|
||||
return scales
|
||||
|
||||
|
||||
def _scale_names(scales):
|
||||
return [
|
||||
tag.value
|
||||
for _key, entry in scales.items()
|
||||
for subclass in entry.xtags.subclasses
|
||||
for tag in subclass
|
||||
if tag.code == 300
|
||||
]
|
||||
|
||||
|
||||
async def test_annotation_scale_matches_an_autocad_keyed_scale_list_by_name(backend, tmp_path):
|
||||
"""Regression: the presence check used to look the scale up by dictionary
|
||||
*key*, so on any AutoCAD-authored drawing (keys A0, A1, ...) every write
|
||||
appended a second SCALE object with the same name."""
|
||||
scales = _seed_autocad_style_scale_list(backend._doc, ["1:1", "1:2", "1:50"])
|
||||
assert list(scales.keys()) == ["A0", "A1", "A2"]
|
||||
|
||||
res = await backend.drawing_settings({"annotation_scale": "1:50"})
|
||||
assert res["ok"] is True, res.get("errors")
|
||||
assert res["changed"]["annotation_scale"][1] == {"name": "1:50", "value": 0.02}
|
||||
assert list(scales.keys()) == ["A0", "A1", "A2"], "the existing entry is reused, not duplicated"
|
||||
assert _scale_names(scales).count("1:50") == 1
|
||||
assert backend._doc.rootdict.get("AcDbVariableDictionary")["CANNOSCALE"].dxf.value == "1:50"
|
||||
|
||||
# a scale the list does not carry is still appended, exactly once, under
|
||||
# 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"})
|
||||
assert res["ok"] is True, res.get("errors")
|
||||
assert _scale_names(scales).count("1:25") == 1
|
||||
assert list(scales.keys()) == ["A0", "A1", "A2", "A3"]
|
||||
assert len(list(scales.keys())) == 4
|
||||
await backend.drawing_settings({"annotation_scale": "1:25"})
|
||||
assert len(list(scales.keys())) == 4
|
||||
|
||||
path = str(tmp_path / "autocad_keyed.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
reopened = backend._doc.rootdict.get("ACAD_SCALELIST")
|
||||
assert sorted(_scale_names(reopened)) == ["1:1", "1:2", "1:25", "1:50"]
|
||||
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)
|
||||
|
||||
|
||||
async def _r12_backend(tmp_path):
|
||||
import ezdxf
|
||||
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
path = tmp_path / "r12.dxf"
|
||||
ezdxf.new("R12").saveas(str(path))
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
await backend.drawing_open(str(path))
|
||||
return backend
|
||||
|
||||
|
||||
async def test_annotation_scale_on_r12_is_refused_rather_than_lost(tmp_path):
|
||||
"""Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf exports
|
||||
neither AcDbVariableDictionary nor ACAD_SCALELIST — a CANNOSCALE written to
|
||||
the in-memory document reported `changed 1:1 -> 1:50` and reloaded as 1:1.
|
||||
The write is refused up front, before any dictionary is created, and the
|
||||
other settings on the same call still apply (limits survive R12)."""
|
||||
backend = await _r12_backend(tmp_path)
|
||||
try:
|
||||
# ezdxf seeds an *empty* ACAD_SCALELIST at new(); the refusal must not
|
||||
# populate it (the default-list seeding) nor create the variable dict.
|
||||
assert len(backend._doc.rootdict.get("ACAD_SCALELIST")) == 0
|
||||
with pytest.raises(ValueError, match="R12") as excinfo:
|
||||
await backend.system_set_variable("CANNOSCALE", "1:50")
|
||||
assert "R2000 or newer" in str(excinfo.value)
|
||||
assert backend._doc.rootdict.get("AcDbVariableDictionary") is None
|
||||
assert len(backend._doc.rootdict.get("ACAD_SCALELIST")) == 0
|
||||
|
||||
res = await backend.drawing_settings(
|
||||
{"annotation_scale": "1:50", "limits": [[0, 0], [420, 297]]}
|
||||
)
|
||||
assert res["ok"] is False
|
||||
assert "R12" in res["errors"]["annotation_scale"]
|
||||
assert "annotation_scale" not in res["applied"]
|
||||
assert res["applied"]["limits"] == [[0, 0], [420, 297]]
|
||||
|
||||
out = tmp_path / "r12_out.dxf"
|
||||
await backend.drawing_save_as(str(out))
|
||||
await backend.drawing_open(str(out))
|
||||
assert backend._doc.dxfversion == "AC1009"
|
||||
read = (await backend.drawing_settings())["settings"]
|
||||
assert read["annotation_scale"] == {"name": "1:1", "value": 1.0}
|
||||
assert read["limits"] == [[0.0, 0.0], [420.0, 297.0]]
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
async def test_annotation_scale_on_r12_refuses_a_malformed_value_by_the_value(tmp_path):
|
||||
"""A malformed scale is the caller's mistake whatever the file version, so
|
||||
it is named as such — the R12 refusal is not allowed to mask it."""
|
||||
backend = await _r12_backend(tmp_path)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="paper:drawing"):
|
||||
await backend.system_set_variable("CANNOSCALE", "fifty")
|
||||
finally:
|
||||
await backend.disconnect()
|
||||
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
|
||||
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()
|
||||
# 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)
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, app.ActiveDocument
|
||||
|
||||
|
||||
async def test_com_facade_sends_autocad_shaped_values(com_backend):
|
||||
backend, doc = 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 doc.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, doc = com_backend
|
||||
seen: dict = {}
|
||||
real_set = doc.SetVariable
|
||||
|
||||
def _capture(name, value):
|
||||
seen[name] = value
|
||||
real_set(name, value)
|
||||
|
||||
doc.SetVariable = _capture
|
||||
res = 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)
|
||||
# The VARIANT is an ActiveX argument only. The reply carries the plain
|
||||
# Python list -- a VARIANT in the dict cannot be serialised by pydantic,
|
||||
# and the tool would then report failure after AutoCAD applied the write.
|
||||
assert res == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]}
|
||||
json.dumps(res)
|
||||
|
||||
|
||||
async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backend, monkeypatch):
|
||||
"""Through the real tool: `system_set_variable` returns its dict verbatim
|
||||
under `output_schema={"type": "object"}`, so anything pydantic cannot
|
||||
serialise turns into `ToolError: outputSchema defined but no structured
|
||||
output returned` *after* `SetVariable` has landed -- a retry/undo trap."""
|
||||
from fastmcp import Client
|
||||
|
||||
import server
|
||||
|
||||
backend, doc = com_backend
|
||||
|
||||
async def _fake_make_backend():
|
||||
return backend
|
||||
|
||||
monkeypatch.setattr(server, "_make_backend", _fake_make_backend)
|
||||
async with Client(server.mcp) as client:
|
||||
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 doc.store["LIMMAX"] == (300.0, 200.0)
|
||||
|
||||
|
||||
async def test_com_refuses_before_touching_activex(com_backend):
|
||||
backend, doc = 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 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)
|
||||
@@ -0,0 +1,415 @@
|
||||
"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines.
|
||||
|
||||
Every `engines["ezdxf"]` claim is checked against the real headless backend
|
||||
(read, and - where the variable is writable - a write of a value that differs
|
||||
from the fresh document's, followed by a save and a reopen, so a header slot
|
||||
ezdxf holds in memory but never exports cannot pass); every `saved_in` that is
|
||||
not "drawing" is checked to be exactly the set the backend refuses with
|
||||
`capability: registry_sysvar`. The catalogue cannot say a thing the backend
|
||||
does not do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.contracts.settings import _SETTING_MAP
|
||||
from backends.ezdxf_backend import _UNSAVED_SYSVARS
|
||||
from engineering.standards.sysvars import (
|
||||
SAVED_IN,
|
||||
SYSVAR_CATALOG,
|
||||
SYSVAR_TYPES,
|
||||
SysVar,
|
||||
check_sysvar_value,
|
||||
describe_sysvar,
|
||||
search_sysvars,
|
||||
)
|
||||
|
||||
asyncio_test = pytest.mark.asyncio
|
||||
|
||||
DIM_WHITELIST = (
|
||||
"DIMTXT",
|
||||
"DIMASZ",
|
||||
"DIMEXE",
|
||||
"DIMEXO",
|
||||
"DIMGAP",
|
||||
"DIMTAD",
|
||||
"DIMTIH",
|
||||
"DIMTOH",
|
||||
"DIMDEC",
|
||||
"DIMDSEP",
|
||||
"DIMLUNIT",
|
||||
"DIMZIN",
|
||||
"DIMBLK",
|
||||
"DIMTXSTY",
|
||||
"DIMLWD",
|
||||
"DIMLWE",
|
||||
"DIMSCALE",
|
||||
"DIMTOL",
|
||||
"DIMTP",
|
||||
"DIMTM",
|
||||
"DIMTOLJ",
|
||||
"DIMTFAC",
|
||||
"DIMLFAC",
|
||||
"DIMRND",
|
||||
"DIMATFIT",
|
||||
"DIMTMOVE",
|
||||
"DIMCLRD",
|
||||
"DIMCLRE",
|
||||
"DIMCLRT",
|
||||
"DIMSAH",
|
||||
"DIMBLK1",
|
||||
"DIMBLK2",
|
||||
"DIMCEN",
|
||||
)
|
||||
|
||||
SPEC_NAMES = (
|
||||
"LTSCALE PSLTSCALE INSUNITS LUNITS LUPREC AUNITS AUPREC LIMMIN LIMMAX GRIDMODE GRIDUNIT "
|
||||
"SNAPMODE SNAPUNIT ORTHOMODE POLARMODE POLARANG OSMODE CANNOSCALE TEXTSIZE TEXTSTYLE "
|
||||
"DIMSTYLE CLAYER CELTYPE CECOLOR CELWEIGHT PDMODE PDSIZE FILLETRAD MIRRTEXT ATTREQ ATTDIA "
|
||||
"TILEMODE CTAB DWGNAME DWGPREFIX SAVETIME FILEDIA CMDECHO HIGHLIGHT REGENMODE UCSNAME "
|
||||
"VIEWCTR VIEWSIZE ANGBASE ANGDIR PLINEWID HPNAME HPSCALE HPANG LWDISPLAY XREFCTL PROXYSHOW "
|
||||
"ISOLINES FACETRES DISPSILH"
|
||||
).split()
|
||||
|
||||
#: A value that is not the fresh document's, for every writable headless entry
|
||||
#: the generic rule below cannot pick one for: the two engines represent DIMDSEP
|
||||
#: and CECOLOR differently (a character vs. its code; a colour name vs. its ACI
|
||||
#: number), so the headless form is written; the name-valued ones must name
|
||||
#: something the drawing has (`drawing_new` bootstraps GEOMETRY and CENTER;
|
||||
#: `_set_annotation_scale` seeds AutoCAD's default scale list).
|
||||
_PROBE_VALUES: dict[str, object] = {
|
||||
"DIMDSEP": 44, # ',' as the character code the header holds (a fresh document has 46)
|
||||
"CECOLOR": 1, # ACI red; the header holds the number
|
||||
"CLAYER": "GEOMETRY",
|
||||
"CELTYPE": "CENTER",
|
||||
"CANNOSCALE": "1:2",
|
||||
}
|
||||
|
||||
|
||||
def _probe_value(var: SysVar, fresh):
|
||||
"""A legal value for ``var`` that differs from ``fresh``, so the write is not a no-op."""
|
||||
if var.name in _PROBE_VALUES:
|
||||
return _PROBE_VALUES[var.name]
|
||||
if var.type == "bool":
|
||||
return 0 if int(fresh) else 1
|
||||
if var.type == "enum":
|
||||
return next(code for code in var.enum if code != int(fresh))
|
||||
if var.type in ("int", "float"):
|
||||
step = 1 if var.type == "int" else 1.0
|
||||
low, high = var.range if var.range is not None else (-math.inf, math.inf)
|
||||
candidate = fresh + step
|
||||
return candidate if candidate <= high else fresh - step
|
||||
if var.type == "point":
|
||||
return (float(fresh[0]) + 1.0, float(fresh[1]) + 2.0)
|
||||
return f"{fresh}_PROBE"
|
||||
|
||||
|
||||
def _same(var: SysVar, back, expected) -> bool:
|
||||
if var.type == "point":
|
||||
return tuple(float(v) for v in back[:2]) == tuple(float(v) for v in expected[:2])
|
||||
if var.type in ("bool", "int", "enum"):
|
||||
return int(back) == int(expected)
|
||||
if var.type == "float":
|
||||
return float(back) == pytest.approx(float(expected))
|
||||
return back == expected
|
||||
|
||||
|
||||
# ── the data is complete ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_catalogue_is_authored_not_sparse():
|
||||
assert len(SYSVAR_CATALOG) >= 60
|
||||
|
||||
|
||||
def test_every_dim_whitelist_variable_and_every_spec_name_is_present():
|
||||
missing = [n for n in (*DIM_WHITELIST, *SPEC_NAMES) if n not in SYSVAR_CATALOG]
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_every_entry_is_complete_and_well_typed():
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
assert isinstance(var, SysVar) and var.name == name
|
||||
assert var.type in SYSVAR_TYPES, name
|
||||
assert var.meaning.strip(), name
|
||||
assert var.saved_in in SAVED_IN, name
|
||||
assert set(var.engines) == {"ezdxf", "com"}, name
|
||||
assert var.friendly_key is None or var.friendly_key in _SETTING_MAP, name
|
||||
if var.type == "enum":
|
||||
assert var.enum and all(isinstance(k, int) for k in var.enum), name
|
||||
assert var.default in var.enum, name
|
||||
else:
|
||||
assert var.enum is None, name
|
||||
if var.range is not None:
|
||||
low, high = var.range
|
||||
assert low < high, name
|
||||
|
||||
|
||||
def test_friendly_keys_cover_the_whole_facade():
|
||||
covered = {var.friendly_key for var in SYSVAR_CATALOG.values() if var.friendly_key}
|
||||
assert covered == set(_SETTING_MAP)
|
||||
for key, (variable, _kind) in _SETTING_MAP.items():
|
||||
assert SYSVAR_CATALOG[variable].friendly_key == key, (key, variable)
|
||||
|
||||
|
||||
def test_non_drawing_variables_are_exactly_the_ones_the_headless_engine_refuses():
|
||||
unsaved = {n for n, v in SYSVAR_CATALOG.items() if v.saved_in != "drawing"}
|
||||
assert unsaved == set(_UNSAVED_SYSVARS)
|
||||
for name in unsaved:
|
||||
assert SYSVAR_CATALOG[name].engines["ezdxf"] is False, name
|
||||
|
||||
|
||||
# ── describe / search / check ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_describe_is_case_insensitive_and_complete():
|
||||
row = describe_sysvar("ltscale")
|
||||
assert row["known"] is True and row["name"] == "LTSCALE"
|
||||
assert row["type"] == "float" and row["range"][0] > 0
|
||||
assert row["saved_in"] == "drawing"
|
||||
assert row["engines"] == {"ezdxf": True, "com": True}
|
||||
assert row["friendly_key"] == "ltscale"
|
||||
assert describe_sysvar("$LIMMAX")["default"] == [420.0, 297.0]
|
||||
|
||||
|
||||
def test_describe_unknown_names_the_nearest_without_raising():
|
||||
row = describe_sysvar("LTSCAL")
|
||||
assert row["known"] is False and row["name"] == "LTSCAL"
|
||||
assert "LTSCALE" in row["nearest"]
|
||||
assert describe_sysvar("")["known"] is False
|
||||
|
||||
|
||||
def test_search_matches_names_and_meanings():
|
||||
names = [row["name"] for row in search_sysvars("scale")]
|
||||
for expected in ("LTSCALE", "DIMSCALE", "CANNOSCALE", "PSLTSCALE", "HPSCALE"):
|
||||
assert expected in names
|
||||
assert [row["name"] for row in search_sysvars("decimal separator")] == ["DIMDSEP"]
|
||||
assert search_sysvars(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, value, fragment",
|
||||
[
|
||||
("DIMDEC", 9, "0..8"),
|
||||
("dimdec", -1, "0..8"),
|
||||
("DIMDEC", 2.5, "integer"),
|
||||
("DIMTXT", "big", "number"),
|
||||
("DIMTXT", 0, "out of range"),
|
||||
("LUNITS", 7, "architectural"),
|
||||
("ORTHOMODE", 2, "0 or 1"),
|
||||
("LIMMAX", 5, "[x, y]"),
|
||||
("DIMSTYLE", "ANSI", "read-only"),
|
||||
("CANNOSCALEVALUE", 0.5, "read-only"),
|
||||
("POLARANG", 0, "out of range"),
|
||||
],
|
||||
)
|
||||
def test_check_refuses_with_a_message_naming_the_variable(name, value, fragment):
|
||||
message = check_sysvar_value(name, value)
|
||||
assert message is not None
|
||||
assert name.upper() in message and fragment in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, value",
|
||||
[
|
||||
("DIMDEC", 3),
|
||||
("DIMTXT", "3.5"),
|
||||
("LUNITS", 4),
|
||||
("ORTHOMODE", "on"),
|
||||
("LIMMAX", [297, 210]),
|
||||
("DIMDSEP", ","),
|
||||
("NOSUCHVARIABLE", 12345),
|
||||
],
|
||||
)
|
||||
def test_check_passes_good_values_and_unknown_names(name, value):
|
||||
assert check_sysvar_value(name, value) is None
|
||||
|
||||
|
||||
# ── the engine claims are true ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend):
|
||||
unreadable = []
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
if var.engines["ezdxf"] and await backend.system_get_variable(name) is None:
|
||||
unreadable.append(name)
|
||||
assert unreadable == []
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_every_writable_ezdxf_true_entry_survives_a_save_and_reopen(backend, tmp_path):
|
||||
"""The write must land in the file, not only in memory.
|
||||
|
||||
The earlier form of this gate wrote each entry's *default* and read it back
|
||||
without saving: 56 of its 66 writes were no-ops (default == fresh value)
|
||||
and the rest could not tell a header slot ezdxf exports from one it holds
|
||||
in memory and drops at save (`$OSMODE`, R12-only). Every write here moves
|
||||
the value, and every read-back is from a reopened file.
|
||||
"""
|
||||
writable = {
|
||||
name: var
|
||||
for name, var in SYSVAR_CATALOG.items()
|
||||
if var.engines["ezdxf"] and not var.read_only
|
||||
}
|
||||
probes: dict[str, object] = {}
|
||||
for name, var in writable.items():
|
||||
fresh = await backend.system_get_variable(name)
|
||||
probe = _probe_value(var, fresh)
|
||||
assert not _same(var, fresh, probe), f"{name}: the probe is a no-op write"
|
||||
out = await backend.system_set_variable(name, probe)
|
||||
assert out["ok"] is True, name
|
||||
probes[name] = probe
|
||||
|
||||
path = str(tmp_path / "sysvars.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
|
||||
lost = {}
|
||||
for name, probe in probes.items():
|
||||
back = await backend.system_get_variable(name)
|
||||
if back is None or not _same(writable[name], back, probe):
|
||||
lost[name] = (probe, back)
|
||||
assert lost == {}, f"engines.ezdxf is True but the value did not survive the file: {lost}"
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_osmode_is_registry_saved_and_the_headless_engine_says_so(backend):
|
||||
"""`$OSMODE` is an R12 header variable; ezdxf keeps it in memory and never
|
||||
exports it for R2000+, so a write used to report `ok: True` and vanish at
|
||||
save. It is registry-saved in AutoCAD and refused headlessly like POLARANG;
|
||||
the read reports `None` instead of the template's memory."""
|
||||
row = describe_sysvar("OSMODE")
|
||||
assert row["saved_in"] == "registry" and row["engines"] == {"ezdxf": False, "com": True}
|
||||
assert await backend.system_get_variable("OSMODE") is None
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.system_set_variable("OSMODE", 4134)
|
||||
assert excinfo.value.capability == "registry_sysvar"
|
||||
# the facade follows: no guess in the snapshot, a per-key refusal on write
|
||||
assert (await backend.drawing_settings())["settings"]["osmode"] is None
|
||||
res = await backend.drawing_settings({"osmode": 4134})
|
||||
assert "registry" in res["errors"]["osmode"]
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_angbase_is_radians_at_the_boundary_and_degrees_in_the_file(backend, tmp_path):
|
||||
"""ActiveX `GetVariable("ANGBASE")` and AutoLISP `getvar` hold radians
|
||||
(measured: `SETVAR ANGBASE 90` reads back 1.5707963267948966), while the
|
||||
DXF header stores `$ANGBASE` in degrees (the same drawing saved by AutoCAD
|
||||
carries 90.0). The headless engine translates, so both engines report the
|
||||
same number and the catalogue's "radians" is true on both."""
|
||||
assert "radians" in describe_sysvar("ANGBASE")["meaning"]
|
||||
assert "radians" in describe_sysvar("HPANG")["meaning"]
|
||||
await backend.system_set_variable("ANGBASE", math.pi / 2)
|
||||
assert backend._doc.header["$ANGBASE"] == pytest.approx(90.0)
|
||||
assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2)
|
||||
|
||||
path = str(tmp_path / "angbase.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
text = open(path, encoding="utf-8").read()
|
||||
start = text.index("$ANGBASE")
|
||||
assert "90.0" in text[start : start + 40], text[start : start + 40]
|
||||
await backend.drawing_open(path)
|
||||
assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2)
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_every_ezdxf_false_entry_is_refused_before_any_write(backend):
|
||||
for name, var in SYSVAR_CATALOG.items():
|
||||
if var.engines["ezdxf"]:
|
||||
continue
|
||||
if var.saved_in == "drawing":
|
||||
with pytest.raises(ValueError, match=name): # no header slot in ezdxf
|
||||
await backend.system_set_variable(name, var.default)
|
||||
else:
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.system_set_variable(name, var.default)
|
||||
assert excinfo.value.capability == "registry_sysvar", name
|
||||
|
||||
|
||||
# ── server tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, backend):
|
||||
self.lifespan_context = {"backend": backend}
|
||||
|
||||
async def info(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_set_variable_tool_refuses_out_of_range_before_the_backend_sees_it(backend):
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import server
|
||||
|
||||
ctx = _FakeCtx(backend)
|
||||
with pytest.raises(ToolError, match="DIMDEC"):
|
||||
await server.system_set_variable(name="DIMDEC", value=99, ctx=ctx)
|
||||
assert await backend.system_get_variable("DIMDEC") == 2
|
||||
with pytest.raises(ToolError, match="read-only"):
|
||||
await server.system_set_variable(name="DIMSTYLE", value="ANSI", ctx=ctx)
|
||||
# a name the catalogue does not know still passes straight through
|
||||
out = await server.system_set_variable(name="MEASUREMENT", value=0, ctx=ctx)
|
||||
assert out["ok"] is True
|
||||
assert await backend.system_get_variable("MEASUREMENT") == 0
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
import server
|
||||
from backends import com_backend as module
|
||||
|
||||
calls: list[tuple] = []
|
||||
|
||||
class _App:
|
||||
def GetVariable(self, name):
|
||||
calls.append(("GetVariable", name))
|
||||
return 2
|
||||
|
||||
def SetVariable(self, name, value):
|
||||
calls.append(("SetVariable", name, value))
|
||||
|
||||
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(ToolError, match="DIMDEC"):
|
||||
await server.system_set_variable(name="DIMDEC", value=40, ctx=_FakeCtx(backend))
|
||||
assert calls == []
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_describe_tool_returns_the_row_with_the_current_value(backend):
|
||||
import server
|
||||
|
||||
ctx = _FakeCtx(backend)
|
||||
row = await server.system_variable_describe(name="dimdec", ctx=ctx)
|
||||
assert row["known"] is True and row["name"] == "DIMDEC" and row["current"] == 2
|
||||
unknown = await server.system_variable_describe(name="DIMDEK", ctx=ctx)
|
||||
assert unknown["known"] is False and "DIMDEC" in unknown["nearest"]
|
||||
found = await server.system_variable_describe(search="decimal separator", ctx=ctx)
|
||||
assert [row["name"] for row in found["matches"]] == ["DIMDSEP"]
|
||||
index = await server.system_variable_describe(ctx=ctx)
|
||||
assert index["count"] == len(SYSVAR_CATALOG) and "LTSCALE" in index["names"]
|
||||
|
||||
|
||||
@asyncio_test
|
||||
async def test_describe_tool_works_without_a_backend():
|
||||
import server
|
||||
|
||||
class _NoBackendCtx:
|
||||
lifespan_context = {"backend": None}
|
||||
|
||||
row = await server.system_variable_describe(name="LTSCALE", ctx=_NoBackendCtx())
|
||||
assert row["known"] is True and "current" not in row
|
||||
@@ -523,8 +523,13 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
`drawing_apply_standard` (ISO or ANSI styles, units and layers in one
|
||||
call) joined; the `style` tag sits ahead of `layer` and `drawing` in
|
||||
`_GROUP_TAG_PRIORITY` so they never file under their secondary
|
||||
`query` / `create` / `modify` / `drawing` tags. Every other number here has been
|
||||
unchanged since the snapshot was taken.
|
||||
`query` / `create` / `modify` / `drawing` tags. `system`
|
||||
moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue
|
||||
as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when
|
||||
v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on
|
||||
both engines) joined SECTION 20 — tagged `drawing` and `settings`, and
|
||||
`settings` is not a group tag, so they file under drawing. Every other
|
||||
number here has been unchanged since the snapshot was taken.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
@@ -533,7 +538,7 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"blocks": 9,
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"drawing": 13,
|
||||
"engineering": 10,
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
@@ -544,10 +549,10 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"premium": 12,
|
||||
"solids": 5,
|
||||
"styles": 10,
|
||||
"system": 7,
|
||||
"system": 8,
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 176
|
||||
assert sum(sizes.values()) == 179
|
||||
|
||||
Reference in New Issue
Block a user