diff --git a/README.md b/README.md index dd53dd3..7da9f77 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **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. diff --git a/backends/com_backend.py b/backends/com_backend.py index f9737af..8955885 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 3428961..a621b59 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -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 diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 79b9574..51aa61e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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) diff --git a/discovery/aliases.py b/discovery/aliases.py index 79783fb..e67976e 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -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=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 4f90917..63962dc 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -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", diff --git a/engineering/standards/sysvars.py b/engineering/standards/sysvars.py new file mode 100644 index 0000000..026036d --- /dev/null +++ b/engineering/standards/sysvars.py @@ -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 diff --git a/server.py b/server.py index ecf6616..e8698c0 100644 --- a/server.py +++ b/server.py @@ -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= for one row or search= 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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 8646a5f..3033553 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -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 diff --git a/tests/test_com_sprint3.py b/tests/test_com_sprint3.py index a2cd458..6012f40 100644 --- a/tests/test_com_sprint3.py +++ b/tests/test_com_sprint3.py @@ -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 diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py new file mode 100644 index 0000000..b625c7c --- /dev/null +++ b/tests/test_drawing_properties.py @@ -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", "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")] diff --git a/tests/test_settings_facade.py b/tests/test_settings_facade.py new file mode 100644 index 0000000..fbb5508 --- /dev/null +++ b/tests/test_settings_facade.py @@ -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) diff --git a/tests/test_sysvar_catalog.py b/tests/test_sysvar_catalog.py new file mode 100644 index 0000000..e55cd2b --- /dev/null +++ b/tests/test_sysvar_catalog.py @@ -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 diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 6a25e04..5e0c77c 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -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