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