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