feat(styles): dimension, text and multileader styles on both engines; $DIMSTYLE/$TEXTSTYLE reach new entities headlessly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-17 09:39:16 +03:00
co-authored by Claude Opus 5
parent 2e2cd2773d
commit cc3bd4876b
6 changed files with 1633 additions and 7 deletions
+368
View File
@@ -254,6 +254,115 @@ def _regen():
log.debug("Regen failed: %s", exc)
# ── styles (track E): module helpers ─────────────────────────────────────────
#
# The ActiveX object model has no per-style getters or setters for dimension
# variables: `AcadDimStyle` exposes `Name` and `CopyFrom` and nothing else. A
# style's values are therefore written the way AutoCAD's own DIMSTYLE command
# writes them — set the DIM* system variables on the document (which makes
# them overrides of the *current* style) and `CopyFrom(document)` to save
# those settings into the target style. Assigning `ActiveDimStyle` restores
# that style's saved settings and discards unsaved overrides (AutoCAD's
# `-DIMSTYLE Restore` rule; the fake in tests/test_styles.py models exactly
# this, and scripts/smoke_settings_com.py confirms it live). Reading is
# one-sided: `GetVariable` reads the *current* style only, so `dimstyle_list`
# reports values for the current style and `values_available: False` for the
# rest rather than cycling the active style behind the operator's back.
def _com_named(collection, name: str):
"""A collection member by name, case-insensitive (AutoCAD's rule), or None."""
wanted = name.strip().lower()
for index in range(collection.Count):
item = collection.Item(index)
if str(item.Name).lower() == wanted:
return item
return None
def _com_dimvar_for_write(var: str, value: Any) -> Any:
"""ActiveX takes DIMDSEP and the arrowhead/text-style names as strings."""
if var in ("DIMDSEP", "DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"):
return str(value)
if isinstance(value, float):
return float(value)
return int(value)
def _com_dimvar_for_report(var: str, raw: Any) -> Any:
if var == "DIMDSEP":
if isinstance(raw, int):
return "," if raw == 0 else chr(raw)
text = str(raw)
return text[:1] if text else ","
if var in ("DIMBLK", "DIMBLK1", "DIMBLK2"):
# AutoCAD reports the block name (``_OBLIQUE``, ``""`` for closed
# filled) — already canonical; the same read-side rule as ezdxf keeps
# the two engines' rows spelled identically.
from engineering.standards.dimstyles import reported_arrowhead
return reported_arrowhead(raw)
if var == "DIMTXSTY":
return str(raw)
return raw
def _same_dimvar(old: Any, new: Any) -> bool:
if isinstance(old, (int, float)) and isinstance(new, (int, float)):
return abs(float(old) - float(new)) < 1e-9
return str(old) == str(new)
def _com_dimensions_using(doc, style_name: str) -> list[str]:
"""Handles of every dimension, in every layout, whose StyleName is ``style_name``."""
wanted = style_name.lower()
handles: list[str] = []
for index in range(doc.Layouts.Count):
block = doc.Layouts.Item(index).Block
for position in range(block.Count):
obj = block.Item(position)
try:
if "Dimension" in str(obj.ObjectName) and str(obj.StyleName).lower() == wanted:
handles.append(str(obj.Handle))
except Exception:
continue
return handles
def _com_font_on_support_path(font_file: str) -> bool:
"""Whether AutoCAD's support path (Preferences.Files.SupportPath) holds the file."""
path = Path(font_file)
if path.is_absolute():
return path.is_file()
try:
support = str(_acad_app().Preferences.Files.SupportPath)
except Exception:
return False
return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder)
def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]:
"""``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused."""
from engineering.standards.textstyles import TEXT_PRESETS
existing = _com_named(doc.TextStyles, name)
if existing is not None:
return str(existing.Name), False
preset = TEXT_PRESETS.get(name.upper())
if preset is None:
raise ValueError(
f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a "
f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create"
)
font_file, width, oblique = preset
style = doc.TextStyles.Add(name.upper())
style.fontFile = font_file
style.Width = float(width)
style.ObliqueAngle = deg2rad(oblique)
style.Height = 0.0
return name.upper(), True
def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_override=None):
"""Best-effort ISO 129 tolerance rendering on a live COM dimension.
@@ -814,6 +923,13 @@ class ComBackend(AutoCADBackend):
"viewport_render": FeatureCapability(True, "native"),
"solid_3d": _solid_3d_capability(),
"lisp": FeatureCapability(True, "sanitized"),
"mleaderstyle": FeatureCapability(
False,
reason=(
"no_activex_mleaderstyle_collection;"
"use_leader_create_mleader_per_leader_parameters"
),
),
},
)
@@ -4074,6 +4190,258 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
# ── styles (track E) ─────────────────────────────────────────────────────
#
# Fake-tested in tests/test_styles.py; executed live once by
# scripts/smoke_settings_com.py. See the module helper block for the
# ActiveX rules every method here relies on.
async def dimstyle_list(self) -> list[dict]:
from engineering.standards.dimstyles import PRESET_VARIABLES
def _sync():
doc = _acad_doc()
current = str(doc.ActiveDimStyle.Name)
rows = []
for index in range(doc.DimStyles.Count):
name = str(doc.DimStyles.Item(index).Name)
is_current = name.lower() == current.lower()
values = (
{
var: _com_dimvar_for_report(var, doc.GetVariable(var))
for var in PRESET_VARIABLES
}
if is_current
else None
)
rows.append(
{
"name": name,
"current": is_current,
"values": values,
"values_available": is_current,
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._run(_sync)
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
from engineering.standards.dimstyles import PRESET_VARIABLES, validate_overrides
clean = sanitize_symbol_name(name, kind="dimstyle")
typed = validate_overrides(values)
if not typed:
raise ValueError("dimstyle_create: values is empty; resolve a preset first")
def _sync():
doc = _acad_doc()
if _com_named(doc.DimStyles, clean) is not None:
raise ValueError(
f"dimstyle_create: dimension style {clean!r} already exists; "
"use dimstyle_modify to change it"
)
# The text style must exist before DIMTXSTY can name it.
txsty, textstyle_created = _ensure_com_textstyle(
doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
)
previous = doc.ActiveDimStyle
style = doc.DimStyles.Add(clean)
doc.ActiveDimStyle = style
for var, value in {**typed, "DIMTXSTY": txsty}.items():
doc.SetVariable(var, _com_dimvar_for_write(var, value))
style.CopyFrom(doc)
written = {
var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in PRESET_VARIABLES
}
if not set_current:
doc.ActiveDimStyle = previous
_regen()
return {
"ok": True,
"name": clean,
"values": written,
"written": sorted(typed),
"current": bool(set_current),
"textstyle_created": textstyle_created,
}
return await self._run(_sync)
async def dimstyle_modify(self, name: str, values: dict) -> dict:
from engineering.standards.dimstyles import validate_overrides
typed = validate_overrides(values)
if not typed:
raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable")
def _sync():
doc = _acad_doc()
style = _com_named(doc.DimStyles, name)
if style is None:
raise ValueError(
f"dimstyle_modify: dimension style {name!r} does not exist; "
"dimstyle_list names the styles this drawing holds"
)
if "DIMTXSTY" in typed and _com_named(doc.TextStyles, typed["DIMTXSTY"]) is None:
raise ValueError(
f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
"create it first with textstyle_create"
)
previous = doc.ActiveDimStyle
switched = str(previous.Name).lower() != str(style.Name).lower()
if switched:
doc.ActiveDimStyle = style
before = {var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in typed}
changed: dict[str, list] = {}
for var, value in typed.items():
if _same_dimvar(before[var], value):
continue
doc.SetVariable(var, _com_dimvar_for_write(var, value))
changed[var] = [before[var], value]
if changed:
style.CopyFrom(doc)
if switched:
doc.ActiveDimStyle = previous
using = _com_dimensions_using(doc, str(style.Name))
if changed:
_regen()
return {
"ok": True,
"name": str(style.Name),
"changed": changed,
"dimensions_using_style": using,
"rerender_required": False, # AutoCAD re-renders on regen
}
return await self._run(_sync)
async def dimstyle_set_current(self, name: str) -> dict:
def _sync():
doc = _acad_doc()
style = _com_named(doc.DimStyles, name)
if style is None:
raise ValueError(
f"dimstyle_set_current: dimension style {name!r} does not exist; "
"dimstyle_list names the styles this drawing holds"
)
previous = str(doc.ActiveDimStyle.Name)
changed = previous.lower() != str(style.Name).lower()
if changed:
doc.ActiveDimStyle = style
return {
"ok": True,
"current": str(style.Name),
"previous": previous,
"changed": changed,
}
return await self._run(_sync)
async def textstyle_list(self) -> list[dict]:
def _sync():
doc = _acad_doc()
current = str(doc.ActiveTextStyle.Name)
rows = []
for index in range(doc.TextStyles.Count):
style = doc.TextStyles.Item(index)
rows.append(
{
"name": str(style.Name),
"font": str(style.fontFile),
"height": float(style.Height),
"width_factor": float(style.Width),
"oblique_deg": rad2deg(float(style.ObliqueAngle)),
"current": str(style.Name).lower() == current.lower(),
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._run(_sync)
async def textstyle_create(
self,
name: str,
font: str,
height: float = 0.0,
width_factor: float = 1.0,
oblique_deg: float = 0.0,
set_current: bool = False,
) -> dict:
from engineering.standards.textstyles import validate_textstyle
spec = validate_textstyle(name, font, height, width_factor, oblique_deg)
def _sync():
doc = _acad_doc()
if _com_named(doc.TextStyles, spec["name"]) is not None:
raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists")
style = doc.TextStyles.Add(spec["name"])
style.fontFile = spec["font_file"]
style.Width = spec["width_factor"]
style.ObliqueAngle = deg2rad(spec["oblique_deg"])
style.Height = spec["height"]
if set_current:
doc.ActiveTextStyle = style
return {
"ok": True,
"name": spec["name"],
"font": spec["font_file"],
"font_resolved": spec["known"] or _com_font_on_support_path(spec["font_file"]),
"current": bool(set_current),
}
return await self._run(_sync)
async def textstyle_set_current(self, name: str) -> dict:
def _sync():
doc = _acad_doc()
style = _com_named(doc.TextStyles, name)
if style is None:
raise ValueError(
f"textstyle_set_current: text style {name!r} does not exist; "
"textstyle_list names the styles this drawing holds"
)
previous = str(doc.ActiveTextStyle.Name)
changed = previous.lower() != str(style.Name).lower()
if changed:
doc.ActiveTextStyle = style
return {
"ok": True,
"current": str(style.Name),
"previous": previous,
"changed": changed,
}
return await self._run(_sync)
async def mleaderstyle_list(self) -> list[dict]:
def _sync():
doc = _acad_doc()
try:
dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE")
except Exception:
return []
rows = []
for index in range(dictionary.Count):
rows.append(
{
"name": str(dictionary.GetName(dictionary.Item(index))),
# ActiveX exposes no MLeaderStyle object: names only.
"arrow_size": None,
"landing_gap": None,
"text_style": None,
"text_height": None,
"values_available": False,
}
)
rows.sort(key=lambda row: row["name"].lower())
return rows
return await self._run(_sync)
# ── corner ops ──────────────────────────────────────────────────────────
@staticmethod