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:
co-authored by
Claude Opus 5
parent
2e2cd2773d
commit
cc3bd4876b
@@ -33,6 +33,7 @@ from backends.contracts import (
|
||||
PremiumContract,
|
||||
SettingsContract,
|
||||
SolidContract,
|
||||
StylesContract,
|
||||
SystemContract,
|
||||
TransactionContract,
|
||||
ViewContract,
|
||||
@@ -184,6 +185,7 @@ class AutoCADBackend(
|
||||
PremiumContract,
|
||||
GdtContract,
|
||||
SettingsContract,
|
||||
StylesContract,
|
||||
):
|
||||
"""All backends implement this interface.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ from backends.contracts.linetypes import LinetypeContract
|
||||
from backends.contracts.premium import PremiumContract
|
||||
from backends.contracts.settings import SettingsContract
|
||||
from backends.contracts.solids import SolidContract
|
||||
from backends.contracts.styles import StylesContract
|
||||
from backends.contracts.system import SystemContract
|
||||
from backends.contracts.transactions import TransactionContract
|
||||
from backends.contracts.view import ViewContract
|
||||
@@ -45,6 +46,7 @@ __all__ = [
|
||||
"PremiumContract",
|
||||
"SettingsContract",
|
||||
"SolidContract",
|
||||
"StylesContract",
|
||||
"SystemContract",
|
||||
"TransactionContract",
|
||||
"ViewContract",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Dimension, text and multileader styles (track E, group S).
|
||||
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
|
||||
Names are matched case-insensitively — AutoCAD's rule for every symbol
|
||||
table — and reported in the table's own spelling. Every refusal is raised
|
||||
before a table is touched: ``ValueError`` naming the key (a missing style, a
|
||||
clash, a variable outside ``engineering.standards.dimstyles.DIM_VARIABLE_RANGES``),
|
||||
``TypeError`` for a wrong type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from backends.capability import capability
|
||||
|
||||
|
||||
class StylesContract(ABC):
|
||||
@abstractmethod
|
||||
async def dimstyle_list(self) -> list[dict]:
|
||||
"""Every DIMSTYLE entry: ``[{name, current, values, values_available}]``.
|
||||
|
||||
``values`` holds the seventeen preset variables
|
||||
(``engineering.standards.dimstyles.PRESET_VARIABLES``) with ``DIMDSEP``
|
||||
as the character. Headless every row carries them (the DXF's stored
|
||||
value, else the schema default AutoCAD applies to an omitted code).
|
||||
On the live engine ActiveX has no per-style getters — ``AcadDimStyle``
|
||||
exposes ``Name`` and ``CopyFrom`` only — so ``values`` is read for the
|
||||
*current* style and the other rows carry ``values: None`` with
|
||||
``values_available: False`` rather than cycling the active style
|
||||
behind the operator's back.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
|
||||
"""Create a dimension style from a fully resolved ``values`` dict
|
||||
(``engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)``).
|
||||
|
||||
Returns ``{ok, name, values, written, current, textstyle_created}``.
|
||||
``DIMTXSTY`` must name a text style that exists or a bundled preset
|
||||
(ISOCP / ISOCPEUR / ARIAL / ROMANS), which is created first and
|
||||
reported in ``textstyle_created``; anything else is refused by name.
|
||||
A name clash is refused (``dimstyle_modify`` changes an existing
|
||||
style). With ``set_current`` the style becomes ``$DIMSTYLE`` *and* its
|
||||
values become the current ``DIM*`` settings — what a live seat does —
|
||||
so the next dimension carries them on both engines.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def dimstyle_modify(self, name: str, values: dict) -> dict:
|
||||
"""Change variables on an existing style.
|
||||
|
||||
Returns ``{ok, name, changed: {VAR: [old, new]}, dimensions_using_style,
|
||||
rerender_required}``. ``changed`` names only the variables that moved
|
||||
(the ``hatch_edit`` rule: re-setting a value to itself is not a
|
||||
change). Dimensions already drawn with the style are listed by handle;
|
||||
``rerender_required`` is ``True`` headlessly when any exist and
|
||||
something changed, because a headless DIMENSION carries its rendered
|
||||
block and shows the old style until it is redrawn — AutoCAD re-renders
|
||||
on the next regen, so the live engine reports ``False``. Modifying the
|
||||
current style also updates the ``DIM*`` current settings.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def dimstyle_set_current(self, name: str) -> dict:
|
||||
"""Make ``name`` the current dimension style.
|
||||
|
||||
Returns ``{ok, current, previous, changed}``. Sets ``$DIMSTYLE`` and
|
||||
loads the style's stored variables into the current ``DIM*`` settings
|
||||
(AutoCAD's ``-DIMSTYLE Restore``; on the live engine unsaved overrides
|
||||
of the previous style are discarded, which is AutoCAD's own rule).
|
||||
The dimension tools then create dimensions with it; the per-dimension
|
||||
override path (tolerances, fits, text override) is unchanged.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def textstyle_list(self) -> list[dict]:
|
||||
"""``[{name, font, height, width_factor, oblique_deg, current}]``."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
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:
|
||||
"""Create a text style. Returns ``{ok, name, font, font_resolved, current}``.
|
||||
|
||||
``font`` is a preset (``ISOCP`` / ``isocp.shx`` / ``isocp``, any case)
|
||||
or any file name. A file that is neither a preset nor found on the
|
||||
engine's font path is *written* and reported ``font_resolved: False``
|
||||
— a DXF stores only the name and AutoCAD substitutes at open — never
|
||||
refused. Refuses a clash, ``width_factor <= 0``, ``height < 0``, an
|
||||
oblique angle outside ±85°, an empty font.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def textstyle_set_current(self, name: str) -> dict:
|
||||
"""``$TEXTSTYLE``. Returns ``{ok, current, previous, changed}``; new
|
||||
TEXT/MTEXT then carry the style on both engines."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def mleaderstyle_list(self) -> list[dict]:
|
||||
"""``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``.
|
||||
|
||||
Headless the values are read from the MLEADERSTYLE object (schema
|
||||
defaults for an unset field). ActiveX exposes no MLeaderStyle object,
|
||||
so the live engine reads the *names* from the ``ACAD_MLEADERSTYLE``
|
||||
dictionary and reports the four values ``None`` with
|
||||
``values_available: False``.
|
||||
"""
|
||||
...
|
||||
|
||||
# B027 (empty method, no @abstractmethod) is what this pattern looks like
|
||||
# from the outside: the decorator replaces the `...` body with the refusing
|
||||
# default, so the source really is empty on purpose.
|
||||
@capability( # noqa: B027
|
||||
"mleaderstyle",
|
||||
reason=(
|
||||
"ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's "
|
||||
"per-leader parameters (arrow size, landing, text height) or the headless "
|
||||
"backend (AUTOCAD_MCP_BACKEND=ezdxf)"
|
||||
),
|
||||
)
|
||||
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
|
||||
"""Create a multileader style from a resolved ``values`` dict
|
||||
(``engineering.standards.mleaderstyles.resolve_mleaderstyle``).
|
||||
ezdxf only. Returns ``{ok, name, values, textstyle_created}``; the
|
||||
text style follows the ``dimstyle_create`` rule (exists or preset,
|
||||
else refused by name); a clash is refused."""
|
||||
...
|
||||
+475
-7
@@ -591,7 +591,7 @@ def _header_dimvar(doc, header_var: str, coerce):
|
||||
def _style_dimvar(doc, attribute: str, fallback, coerce):
|
||||
"""What the renderer would use with no header and no override in play."""
|
||||
try:
|
||||
style = doc.dimstyles.get(_DIMSTYLE_NAME)
|
||||
style = doc.dimstyles.get(_current_dimstyle_name(doc))
|
||||
if style.dxf.hasattr(attribute):
|
||||
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
|
||||
if stored is not None:
|
||||
@@ -662,6 +662,203 @@ def _with_header_dimvars(doc, override: dict | None) -> dict | None:
|
||||
return merged
|
||||
|
||||
|
||||
# ── styles (track E): module helpers ─────────────────────────────────────────
|
||||
#
|
||||
# `$DIMSTYLE` names the style new dimensions are created with, exactly as on
|
||||
# a live seat. Two measured ezdxf facts shape everything below:
|
||||
#
|
||||
# * `ezdxf.new()` writes `$DIMSTYLE "ISO-25"` into a fresh header with no such
|
||||
# table entry (only `Standard` exists). `_current_dimstyle_name` therefore
|
||||
# falls back to `Standard` when the header names nothing in the table, and
|
||||
# `dimstyle_create` pins the header so creating a style *called* ISO-25 does
|
||||
# not make it current by accident.
|
||||
# * `_with_header_dimvars` folds every `$DIM*` that differs from the current
|
||||
# style's stored value onto the next dimension. Making a style current
|
||||
# without loading its values into the header leaves the previous style's
|
||||
# header winning: measured, ISO-25 (comma) after `drawing_new` (point)
|
||||
# rendered "33.33". `_sync_header_to_dimstyle` is what a live seat's
|
||||
# `-DIMSTYLE Restore` does, and it makes the fold empty.
|
||||
|
||||
|
||||
def _current_dimstyle_name(doc) -> str:
|
||||
"""The dimension style new dimensions are created with.
|
||||
|
||||
`$DIMSTYLE` when the table holds it (in the table's spelling); otherwise
|
||||
`Standard` — ezdxf's own substitution for a missing style, made explicit.
|
||||
"""
|
||||
try:
|
||||
name = doc.header.get("$DIMSTYLE", _DIMSTYLE_NAME)
|
||||
except Exception:
|
||||
return _DIMSTYLE_NAME
|
||||
if isinstance(name, str) and name and doc.dimstyles.has_entry(name):
|
||||
return doc.dimstyles.get(name).dxf.name
|
||||
return _DIMSTYLE_NAME
|
||||
|
||||
|
||||
def _current_textstyle_name(doc) -> str:
|
||||
"""The text style new TEXT/MTEXT carry: `$TEXTSTYLE`, else `Standard`."""
|
||||
try:
|
||||
name = doc.header.get("$TEXTSTYLE", "Standard")
|
||||
except Exception:
|
||||
return "Standard"
|
||||
if isinstance(name, str) and name and doc.styles.has_entry(name):
|
||||
return doc.styles.get(name).dxf.name
|
||||
return "Standard"
|
||||
|
||||
|
||||
def _dimstyle_schema_default(var: str):
|
||||
"""What AutoCAD reads for a DIMSTYLE entry that omits the code."""
|
||||
from ezdxf.entities.dimstyle import acdb_dimstyle
|
||||
|
||||
attrib = acdb_dimstyle.attribs.get(var.lower())
|
||||
return None if attrib is None else attrib.default
|
||||
|
||||
|
||||
_ARROWHEAD_VARS = ("DIMBLK", "DIMBLK1", "DIMBLK2")
|
||||
|
||||
|
||||
def _dimstyle_value(style, var: str):
|
||||
"""A style's value for ``var`` in the tool vocabulary (DIMDSEP as the
|
||||
character, arrowheads in the canonical ``_OBLIQUE`` spelling)."""
|
||||
from engineering.standards.dimstyles import reported_arrowhead
|
||||
|
||||
attr = var.lower()
|
||||
raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None
|
||||
if raw is None:
|
||||
raw = _dimstyle_schema_default(var)
|
||||
if var == "DIMDSEP":
|
||||
code = 44 if raw is None else int(raw)
|
||||
return "," if code == 0 else chr(code)
|
||||
if var == "DIMTXSTY":
|
||||
return str(raw) if raw else "Standard"
|
||||
if var in _ARROWHEAD_VARS:
|
||||
# Measured: ezdxf holds the writer's ``OBLIQUE`` in-session and the
|
||||
# block name ``_OBLIQUE`` after a reload. Raw, one style would be
|
||||
# spelled two ways and re-setting a value to itself would report a
|
||||
# change (spec §8.3); canonicalised, both reads agree.
|
||||
return reported_arrowhead(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _read_dimstyle_values(style, variables=None) -> dict[str, Any]:
|
||||
from engineering.standards.dimstyles import PRESET_VARIABLES
|
||||
|
||||
return {var: _dimstyle_value(style, var) for var in (variables or PRESET_VARIABLES)}
|
||||
|
||||
|
||||
def _require_arrowhead_blocks(doc, values: dict[str, Any]) -> None:
|
||||
"""Refuse a user arrowhead block the drawing does not define — before any write.
|
||||
|
||||
A built-in (``ARROWHEAD_BLOCKS``) and closed filled (``""``) need no block:
|
||||
ezdxf creates the built-in's block on first use. A user block name passes
|
||||
the data layer (it cannot see the drawing) and, stored, ezdxf raises
|
||||
``DXFUndefinedBlockError`` at the next dimension render and ``DXFValueError:
|
||||
Block "MYARROW" does not exist`` at ``doc.write`` — after the create tool
|
||||
already said ``ok``. ``doc.blocks`` membership is case-insensitive, and a
|
||||
reload reports the block table's own spelling (measured: ``myarrow`` stored,
|
||||
``MYARROW`` after reopen), so the name is rewritten in ``values`` to that
|
||||
spelling here — the same "reported in the table's own spelling" rule the
|
||||
style names follow, and what keeps a re-set after reopen a non-change.
|
||||
"""
|
||||
from engineering.standards.dimstyles import ARROWHEAD_BLOCKS
|
||||
|
||||
for var in _ARROWHEAD_VARS:
|
||||
name = values.get(var)
|
||||
if not name or name in ARROWHEAD_BLOCKS:
|
||||
continue
|
||||
if name not in doc.blocks:
|
||||
raise ValueError(
|
||||
f"{var}: block {name!r} does not exist in this drawing; define it first "
|
||||
"with block_define, or name one of AutoCAD's built-in arrowheads"
|
||||
)
|
||||
values[var] = doc.blocks.get(name).name
|
||||
|
||||
|
||||
def _write_dimstyle_values(style, values: dict[str, Any]) -> None:
|
||||
"""Store typed values (from ``validate_overrides``) on a DIMSTYLE entry.
|
||||
|
||||
DIMRND 0 is AutoCAD's "no rounding"; ezdxf applies ``xround(x, 0.0)`` to a
|
||||
*stored* 0.0 and rounds every dimension to a whole unit, so it is
|
||||
discarded rather than stored (the `_normalise_dimstyle_rounding` lesson).
|
||||
|
||||
Arrowheads arrive in the canonical ``_OBLIQUE`` spelling (what ActiveX
|
||||
takes verbatim); ezdxf's writer wants ``OBLIQUE`` and raises at
|
||||
``doc.write`` on the underscore form, so they go through
|
||||
``ezdxf_arrowhead``. The caller has run ``_require_arrowhead_blocks``.
|
||||
"""
|
||||
from engineering.standards.dimstyles import ezdxf_arrowhead
|
||||
|
||||
for var, value in values.items():
|
||||
attr = var.lower()
|
||||
if var == "DIMRND" and float(value) == 0.0:
|
||||
style.dxf.discard("dimrnd")
|
||||
continue
|
||||
if var == "DIMDSEP":
|
||||
value = ord(value)
|
||||
elif var in _ARROWHEAD_VARS:
|
||||
value = ezdxf_arrowhead(value)
|
||||
style.dxf.set(attr, value)
|
||||
|
||||
|
||||
def _sync_header_to_dimstyle(doc, style) -> None:
|
||||
"""Load the style's stored variables into ``$DIM*`` — a live seat's Restore."""
|
||||
from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST
|
||||
|
||||
for var in sorted(DIM_VARIABLE_WHITELIST):
|
||||
attr = var.lower()
|
||||
if not style.dxf.hasattr(attr):
|
||||
continue
|
||||
raw = style.dxf.get(attr)
|
||||
if raw is None:
|
||||
continue
|
||||
doc.header[f"${var}"] = raw
|
||||
|
||||
|
||||
def _font_available(font_file: str) -> bool:
|
||||
"""Whether ezdxf's font manager can find ``font_file`` on this machine."""
|
||||
try:
|
||||
from ezdxf.fonts import fonts
|
||||
|
||||
return bool(fonts.font_manager.has_font(font_file))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]:
|
||||
"""``(table spelling, created)`` for a text style that must exist.
|
||||
|
||||
A bundled preset (ISOCP / ISOCPEUR / ARIAL / ROMANS) is created on demand;
|
||||
any other missing name is refused by ``refusal_key`` before a write.
|
||||
"""
|
||||
from engineering.standards.textstyles import TEXT_PRESETS
|
||||
|
||||
if doc.styles.has_entry(name):
|
||||
return doc.styles.get(name).dxf.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
|
||||
doc.styles.new(
|
||||
name.upper(),
|
||||
dxfattribs={"font": font_file, "width": width, "oblique": oblique, "height": 0.0},
|
||||
)
|
||||
return name.upper(), True
|
||||
|
||||
|
||||
def _dimensions_using(doc, style_name: str) -> list[str]:
|
||||
"""Handles of every DIMENSION, in any layout, drawn with ``style_name``."""
|
||||
wanted = style_name.lower()
|
||||
handles: list[str] = []
|
||||
for layout in doc.layouts:
|
||||
for entity in layout.query("DIMENSION"):
|
||||
if str(entity.dxf.get("dimstyle", "")).lower() == wanted:
|
||||
handles.append(entity.dxf.handle)
|
||||
return handles
|
||||
|
||||
|
||||
def _round_measure(result: dict) -> dict:
|
||||
"""Six decimal places, matching analysis_measure_distance."""
|
||||
result["area"] = round(float(result["area"]), 6)
|
||||
@@ -1058,6 +1255,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
False, reason="acis_generation_requires_live_autocad"
|
||||
),
|
||||
"lisp": FeatureCapability(False, reason="live_com_only"),
|
||||
"mleaderstyle": FeatureCapability(True, "native"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2719,6 +2917,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
ent = msp.add_text(
|
||||
text,
|
||||
dxfattribs={
|
||||
"style": _current_textstyle_name(self._require_doc()),
|
||||
"height": float(height),
|
||||
"rotation": float(rotation),
|
||||
"insert": (float(x), float(y)),
|
||||
@@ -2745,7 +2944,11 @@ class EzdxfBackend(AutoCADBackend):
|
||||
msp = self._msp()
|
||||
ent = msp.add_mtext(
|
||||
text,
|
||||
dxfattribs={"char_height": float(height), "width": float(width)},
|
||||
dxfattribs={
|
||||
"style": _current_textstyle_name(self._require_doc()),
|
||||
"char_height": float(height),
|
||||
"width": float(width),
|
||||
},
|
||||
)
|
||||
ent.dxf.insert = (float(x), float(y), 0.0)
|
||||
if rotation:
|
||||
@@ -3331,7 +3534,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
p2=(float(x2), float(y2)),
|
||||
angle=float(rotation),
|
||||
text=text if text is not None else "<>",
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
@@ -3361,7 +3564,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
distance=math.sqrt(
|
||||
(float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2
|
||||
),
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||
override=_with_header_dimvars(self._require_doc(), None),
|
||||
)
|
||||
dim.render()
|
||||
@@ -3393,7 +3596,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
line1=((vxf, vyf), (float(x1), float(y1))),
|
||||
line2=((vxf, vyf), (float(x2), float(y2))),
|
||||
location=(float(tx), float(ty)),
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||
override=_with_header_dimvars(self._require_doc(), None),
|
||||
)
|
||||
dim.render()
|
||||
@@ -3438,7 +3641,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
cyf + (radius + leader) * math.sin(angle_rad),
|
||||
),
|
||||
text=text if text is not None else "<>",
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
@@ -3488,7 +3691,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
cy + (radius + leader) * math.sin(angle_rad),
|
||||
),
|
||||
text=text if text is not None else "<>",
|
||||
dimstyle=_DIMSTYLE_NAME,
|
||||
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||
override=override or None,
|
||||
)
|
||||
dim.render()
|
||||
@@ -5548,6 +5751,271 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)",
|
||||
}
|
||||
|
||||
# ── styles (track E) ─────────────────────────────────────────────────────
|
||||
|
||||
async def dimstyle_list(self) -> list[dict]:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
current = _current_dimstyle_name(doc)
|
||||
rows = [
|
||||
{
|
||||
"name": style.dxf.name,
|
||||
"current": style.dxf.name == current,
|
||||
"values": _read_dimstyle_values(style),
|
||||
"values_available": True,
|
||||
}
|
||||
for style in doc.dimstyles
|
||||
]
|
||||
rows.sort(key=lambda row: row["name"].lower())
|
||||
return rows
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
|
||||
from engineering.standards.dimstyles import validate_overrides
|
||||
from security import sanitize_symbol_name
|
||||
|
||||
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 = self._require_doc()
|
||||
if doc.dimstyles.has_entry(clean):
|
||||
raise ValueError(
|
||||
f"dimstyle_create: dimension style {clean!r} already exists; "
|
||||
"use dimstyle_modify to change it"
|
||||
)
|
||||
previous = _current_dimstyle_name(doc)
|
||||
_require_arrowhead_blocks(doc, typed)
|
||||
txsty, textstyle_created = _ensure_textstyle(
|
||||
doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
|
||||
)
|
||||
style = doc.dimstyles.new(clean)
|
||||
_write_dimstyle_values(style, {**typed, "DIMTXSTY": txsty})
|
||||
if set_current:
|
||||
doc.header["$DIMSTYLE"] = clean
|
||||
_sync_header_to_dimstyle(doc, style)
|
||||
else:
|
||||
# Measured: ezdxf.new() writes $DIMSTYLE "ISO-25" into a fresh
|
||||
# header with no such table entry, so creating a style of that
|
||||
# name would silently make it current. Pin the header to the
|
||||
# style that was effectively current before this call.
|
||||
doc.header["$DIMSTYLE"] = previous
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": clean,
|
||||
"values": _read_dimstyle_values(style),
|
||||
"written": sorted(typed),
|
||||
"current": bool(set_current),
|
||||
"textstyle_created": textstyle_created,
|
||||
}
|
||||
|
||||
return await self._async(_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 = self._require_doc()
|
||||
if not doc.dimstyles.has_entry(name):
|
||||
raise ValueError(
|
||||
f"dimstyle_modify: dimension style {name!r} does not exist; "
|
||||
"dimstyle_list names the styles this drawing holds"
|
||||
)
|
||||
style = doc.dimstyles.get(name)
|
||||
if "DIMTXSTY" in typed and not doc.styles.has_entry(typed["DIMTXSTY"]):
|
||||
raise ValueError(
|
||||
f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
|
||||
"create it first with textstyle_create"
|
||||
)
|
||||
_require_arrowhead_blocks(doc, typed)
|
||||
before = _read_dimstyle_values(style, tuple(typed))
|
||||
_write_dimstyle_values(style, typed)
|
||||
after = _read_dimstyle_values(style, tuple(typed))
|
||||
changed = {var: [before[var], after[var]] for var in typed if before[var] != after[var]}
|
||||
if changed and style.dxf.name == _current_dimstyle_name(doc):
|
||||
_sync_header_to_dimstyle(doc, style)
|
||||
using = _dimensions_using(doc, style.dxf.name)
|
||||
if changed:
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": style.dxf.name,
|
||||
"changed": changed,
|
||||
"dimensions_using_style": using,
|
||||
# A headless DIMENSION carries its rendered block; it shows the
|
||||
# old style until redrawn. Stated, not silent.
|
||||
"rerender_required": bool(changed and using),
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def dimstyle_set_current(self, name: str) -> dict:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
if not doc.dimstyles.has_entry(name):
|
||||
raise ValueError(
|
||||
f"dimstyle_set_current: dimension style {name!r} does not exist; "
|
||||
"dimstyle_list names the styles this drawing holds"
|
||||
)
|
||||
style = doc.dimstyles.get(name)
|
||||
previous = _current_dimstyle_name(doc)
|
||||
doc.header["$DIMSTYLE"] = style.dxf.name
|
||||
_sync_header_to_dimstyle(doc, style)
|
||||
changed = style.dxf.name != previous
|
||||
if changed:
|
||||
self._mark_dirty()
|
||||
return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def textstyle_list(self) -> list[dict]:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
current = _current_textstyle_name(doc)
|
||||
rows = [
|
||||
{
|
||||
"name": style.dxf.name,
|
||||
"font": str(style.dxf.get("font", "") or ""),
|
||||
"height": float(style.dxf.get("height", 0.0) or 0.0),
|
||||
"width_factor": float(style.dxf.get("width", 1.0) or 1.0),
|
||||
"oblique_deg": float(style.dxf.get("oblique", 0.0) or 0.0),
|
||||
"current": style.dxf.name == current,
|
||||
}
|
||||
for style in doc.styles
|
||||
]
|
||||
rows.sort(key=lambda row: row["name"].lower())
|
||||
return rows
|
||||
|
||||
return await self._async(_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 = self._require_doc()
|
||||
if doc.styles.has_entry(spec["name"]):
|
||||
raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists")
|
||||
doc.styles.new(
|
||||
spec["name"],
|
||||
dxfattribs={
|
||||
"font": spec["font_file"],
|
||||
"width": spec["width_factor"],
|
||||
"oblique": spec["oblique_deg"],
|
||||
"height": spec["height"],
|
||||
},
|
||||
)
|
||||
if set_current:
|
||||
doc.header["$TEXTSTYLE"] = spec["name"]
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": spec["name"],
|
||||
"font": spec["font_file"],
|
||||
# A DXF stores the name only; a font nobody can find is written
|
||||
# and *said* to be unresolved, never refused.
|
||||
"font_resolved": spec["known"] or _font_available(spec["font_file"]),
|
||||
"current": bool(set_current),
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def textstyle_set_current(self, name: str) -> dict:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
if not doc.styles.has_entry(name):
|
||||
raise ValueError(
|
||||
f"textstyle_set_current: text style {name!r} does not exist; "
|
||||
"textstyle_list names the styles this drawing holds"
|
||||
)
|
||||
style = doc.styles.get(name)
|
||||
previous = _current_textstyle_name(doc)
|
||||
doc.header["$TEXTSTYLE"] = style.dxf.name
|
||||
changed = style.dxf.name != previous
|
||||
if changed:
|
||||
self._mark_dirty()
|
||||
return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def mleaderstyle_list(self) -> list[dict]:
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
rows = []
|
||||
for name, style in doc.mleader_styles:
|
||||
handle = style.dxf.get("text_style_handle")
|
||||
text_style = doc.entitydb.get(handle) if handle else None
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
# schema defaults (measured: 4 / 2.0 / 4) for an unset field
|
||||
"arrow_size": float(style.dxf.get("arrow_head_size", 4.0)),
|
||||
"landing_gap": float(style.dxf.get("landing_gap_size", 2.0)),
|
||||
"text_style": text_style.dxf.name if text_style is not None else "Standard",
|
||||
"text_height": float(style.dxf.get("char_height", 4.0)),
|
||||
"values_available": True,
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: row["name"].lower())
|
||||
return rows
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
|
||||
from engineering.standards.mleaderstyles import MLEADER_KEYS
|
||||
from security import sanitize_symbol_name
|
||||
|
||||
clean = sanitize_symbol_name(name, kind="mleaderstyle")
|
||||
missing = [key for key in MLEADER_KEYS if key not in values]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"mleaderstyle_create: values is missing {missing}; resolve a preset first"
|
||||
)
|
||||
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
if doc.mleader_styles.has_entry(clean):
|
||||
raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists")
|
||||
text_style_name, textstyle_created = _ensure_textstyle(
|
||||
doc, str(values["text_style"]), refusal_key="text_style"
|
||||
)
|
||||
style = doc.mleader_styles.new(clean)
|
||||
style.dxf.arrow_head_size = float(values["arrow_size"])
|
||||
style.dxf.landing_gap_size = float(values["landing_gap"])
|
||||
style.dxf.char_height = float(values["text_height"])
|
||||
style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": clean,
|
||||
"values": {
|
||||
"arrow_size": float(values["arrow_size"]),
|
||||
"landing_gap": float(values["landing_gap"]),
|
||||
"text_style": text_style_name,
|
||||
"text_height": float(values["text_height"]),
|
||||
},
|
||||
"textstyle_created": textstyle_created,
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
# ── corner ops ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Dimension, text and multileader styles on both engines.
|
||||
|
||||
Headless: real ezdxf tables, and the *rendered* dimension block as the witness
|
||||
that a current style reaches the next dimension (the header alone was never
|
||||
the thing that could lie — see tests/test_dimension_header_vars.py). Live:
|
||||
a fake ActiveX surface that records the call shapes the COM block relies on
|
||||
and models the one AutoCAD rule it depends on — making a style current
|
||||
restores that style's saved DIM* settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.capability import is_capability_default
|
||||
from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name
|
||||
from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle
|
||||
from engineering.standards.mleaderstyles import resolve_mleaderstyle
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _rendered(backend, handle):
|
||||
"""(text, text height, arrow xscales) out of the dimension's geometry block."""
|
||||
dim = backend._doc.entitydb.get(handle)
|
||||
block = backend._doc.blocks.get(dim.dxf.geometry)
|
||||
text = height = None
|
||||
arrows = []
|
||||
for entity in block:
|
||||
if entity.dxftype() == "MTEXT" and text is None:
|
||||
text, height = entity.text, round(float(entity.dxf.char_height), 4)
|
||||
elif entity.dxftype() == "TEXT" and text is None:
|
||||
text, height = entity.dxf.text, round(float(entity.dxf.height), 4)
|
||||
elif entity.dxftype() == "INSERT":
|
||||
arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4))
|
||||
if text is None:
|
||||
raise AssertionError("the dimension block carries no text")
|
||||
return text, height, sorted(set(arrows))
|
||||
|
||||
|
||||
# ── headless engine ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_dimstyle_list_reports_standard_current_with_the_seventeen_values(backend):
|
||||
rows = await backend.dimstyle_list()
|
||||
assert [r["name"] for r in rows] == ["Standard"]
|
||||
assert rows[0]["current"] is True and rows[0]["values_available"] is True
|
||||
assert tuple(rows[0]["values"]) == PRESET_VARIABLES
|
||||
assert rows[0]["values"]["DIMTXT"] == 2.5
|
||||
assert rows[0]["values"]["DIMDSEP"] == ".", "drawing_new's ISO point"
|
||||
assert rows[0]["values"]["DIMTXSTY"] == "Standard", "unstored code reports the schema default"
|
||||
|
||||
|
||||
async def test_dimstyle_create_iso25_writes_every_value_and_creates_isocp(backend):
|
||||
result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
assert result["ok"] is True and result["name"] == "ISO-25"
|
||||
assert result["textstyle_created"] is True and result["current"] is False
|
||||
assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
|
||||
assert result["written"] == sorted(PRESET_VARIABLES)
|
||||
style = backend._doc.dimstyles.get("ISO-25")
|
||||
assert style.dxf.dimtxt == 2.5 and style.dxf.dimdsep == 44 and style.dxf.dimtad == 1
|
||||
assert style.dxf.dimtxsty == "ISOCP" and style.dxf.dimlwd == -2 and style.dxf.dimblk == ""
|
||||
assert backend._doc.styles.get("ISOCP").dxf.font == "isocp.shx"
|
||||
# ezdxf.new() writes $DIMSTYLE "ISO-25" with no such entry; creating the
|
||||
# entry must not make it current by accident.
|
||||
assert backend._doc.header["$DIMSTYLE"] == "Standard"
|
||||
assert _current_dimstyle_name(backend._doc) == "Standard"
|
||||
|
||||
|
||||
async def test_set_current_syncs_the_header_and_the_next_dimension_renders_with_it(backend):
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
|
||||
header = backend._doc.header
|
||||
assert header["$DIMSTYLE"] == "ISO-25"
|
||||
assert header["$DIMDSEP"] == 44 and header["$DIMTXT"] == 2.5 and header["$DIMTXSTY"] == "ISOCP"
|
||||
|
||||
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
||||
assert backend._doc.entitydb.get(dim.handle).dxf.dimstyle == "ISO-25"
|
||||
assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
|
||||
|
||||
await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
|
||||
dim2 = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
|
||||
assert backend._doc.entitydb.get(dim2.handle).dxf.dimstyle == "ANSI"
|
||||
assert _rendered(backend, dim2.handle) == ("33.33", 3.0, [3.0])
|
||||
for creator, args in (
|
||||
(backend.dimension_aligned, (0, 0, 10, 10, 5, 15)),
|
||||
(backend.dimension_radius, (50, 50, 60, 50)),
|
||||
(backend.dimension_diameter, (70, 0, 90, 0)),
|
||||
(backend.dimension_angular, (0, 0, 10, 0, 0, 10, 5, 5)),
|
||||
):
|
||||
created = await creator(*args)
|
||||
assert backend._doc.entitydb.get(created.handle).dxf.dimstyle == "ANSI", creator.__name__
|
||||
|
||||
|
||||
async def test_dimstyle_modify_reports_only_what_moved_and_the_dimensions_using_it(backend):
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
|
||||
dim = await backend.dimension_linear(0, 0, 10, 0, 5, -10)
|
||||
result = await backend.dimstyle_modify("iso-25", {"DIMTXT": 2.5, "DIMDEC": 3, "dimrnd": 0})
|
||||
assert result["name"] == "ISO-25"
|
||||
assert result["changed"] == {"DIMDEC": [2, 3]}
|
||||
assert result["dimensions_using_style"] == [dim.handle]
|
||||
assert result["rerender_required"] is True
|
||||
assert backend._doc.header["$DIMDEC"] == 3, "the current style's header twin follows"
|
||||
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimrnd"), (
|
||||
"DIMRND 0 is 'no rounding' and must be discarded, not stored (xround(x, 0.0) rounds)"
|
||||
)
|
||||
again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
|
||||
assert again["changed"] == {} and again["rerender_required"] is False
|
||||
on_standard = await backend.dimstyle_modify("Standard", {"DIMDEC": 4})
|
||||
assert on_standard["dimensions_using_style"] == [] and on_standard["rerender_required"] is False
|
||||
assert backend._doc.header["$DIMDEC"] == 3, "a non-current style does not touch the header"
|
||||
|
||||
|
||||
async def test_refusals_happen_before_anything_is_written(backend):
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await backend.dimstyle_create("iso-25", resolve_dimstyle("iso-25", None))
|
||||
with pytest.raises(ValueError, match="DIMTXSTY"):
|
||||
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
|
||||
assert not backend._doc.dimstyles.has_entry("X")
|
||||
with pytest.raises(ValueError, match="DIMFOO"):
|
||||
await backend.dimstyle_create("Y", {"DIMFOO": 1})
|
||||
assert not backend._doc.dimstyles.has_entry("Y")
|
||||
with pytest.raises(ValueError, match="DIMDEC"):
|
||||
await backend.dimstyle_modify("ISO-25", {"DIMDEC": 9})
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
await backend.dimstyle_modify("ISO-25", {})
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await backend.dimstyle_set_current("NOPE")
|
||||
assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2
|
||||
|
||||
|
||||
async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path):
|
||||
"""`validate_overrides` hands the canonical `_OBLIQUE`; stored verbatim ezdxf
|
||||
raises at render and at `doc.write`. The witness is the rendered INSERT and
|
||||
a save/reopen that reports the same name before and after."""
|
||||
values = resolve_dimstyle(
|
||||
"iso-25", {"DIMBLK": "oblique", "DIMSAH": 1, "DIMBLK1": "_DOT", "DIMBLK2": "."}
|
||||
)
|
||||
result = await backend.dimstyle_create("ISO-25", values, set_current=True)
|
||||
assert result["values"]["DIMBLK"] == "_OBLIQUE"
|
||||
style = backend._doc.dimstyles.get("ISO-25")
|
||||
assert style.dxf.dimblk == "OBLIQUE" and style.dxf.dimblk1 == "DOT" and style.dxf.dimblk2 == ""
|
||||
dim = await backend.dimension_linear(0, 0, 20, 0, 10, -10)
|
||||
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
|
||||
assert {e.dxf.name for e in block if e.dxftype() == "INSERT"} == {"_DOT", "_CLOSEDFILLED"}, (
|
||||
"DIMSAH on: the two separate arrowheads win over DIMBLK"
|
||||
)
|
||||
again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"})
|
||||
assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)"
|
||||
path = str(tmp_path / "arrows.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
rows = {r["name"]: r for r in await backend.dimstyle_list()}
|
||||
assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling"
|
||||
reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"})
|
||||
assert reopened["changed"] == {}
|
||||
|
||||
|
||||
async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend):
|
||||
with pytest.raises(ValueError, match="DIMBLK: block 'MYARROW' does not exist"):
|
||||
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMBLK": "MYARROW"}))
|
||||
assert not backend._doc.dimstyles.has_entry("X")
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
with pytest.raises(ValueError, match="DIMBLK1: block 'MYARROW' does not exist"):
|
||||
await backend.dimstyle_modify("ISO-25", {"DIMSAH": 1, "DIMBLK1": "MYARROW"})
|
||||
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimsah"), "nothing written"
|
||||
backend._doc.blocks.new("MYARROW").add_line((0, 0), (1, 1))
|
||||
result = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "myarrow"})
|
||||
assert result["changed"] == {"DIMBLK": ["", "MYARROW"]}, "the block table's own spelling"
|
||||
assert backend._doc.dimstyles.get("ISO-25").dxf.dimblk == "MYARROW"
|
||||
|
||||
|
||||
async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path):
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
result = await backend.dimstyle_set_current("iso-25")
|
||||
assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
|
||||
same = await backend.dimstyle_set_current("ISO-25")
|
||||
assert same["changed"] is False and same["previous"] == "ISO-25"
|
||||
path = str(tmp_path / "styles.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
rows = await backend.dimstyle_list()
|
||||
assert [r["name"] for r in rows if r["current"]] == ["ISO-25"]
|
||||
assert rows[0]["name"] == "ISO-25" and rows[0]["values"]["DIMDSEP"] == ","
|
||||
|
||||
|
||||
async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend):
|
||||
result = await backend.textstyle_create("ISOCP", "isocp", set_current=True)
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"name": "ISOCP",
|
||||
"font": "isocp.shx",
|
||||
"font_resolved": True,
|
||||
"current": True,
|
||||
}
|
||||
rows = await backend.textstyle_list()
|
||||
assert [r["name"] for r in rows] == ["ISOCP", "Standard"]
|
||||
assert rows[0] == {
|
||||
"name": "ISOCP",
|
||||
"font": "isocp.shx",
|
||||
"height": 0.0,
|
||||
"width_factor": 1.0,
|
||||
"oblique_deg": 0.0,
|
||||
"current": True,
|
||||
}
|
||||
assert backend._doc.header["$TEXTSTYLE"] == "ISOCP"
|
||||
assert _current_textstyle_name(backend._doc) == "ISOCP"
|
||||
text = await backend.entity_create_text("note", 0, 0)
|
||||
mtext = await backend.entity_create_mtext("note", 0, 10)
|
||||
assert backend._doc.entitydb.get(text.handle).dxf.style == "ISOCP"
|
||||
assert backend._doc.entitydb.get(mtext.handle).dxf.style == "ISOCP"
|
||||
back = await backend.textstyle_set_current("standard")
|
||||
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
|
||||
assert (
|
||||
backend._doc.entitydb.get((await backend.entity_create_text("x", 0, 0)).handle).dxf.style
|
||||
== "Standard"
|
||||
)
|
||||
|
||||
|
||||
async def test_textstyle_unknown_font_is_written_but_reported_unresolved(backend):
|
||||
result = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15)
|
||||
assert result["font_resolved"] is False and result["current"] is False
|
||||
style = backend._doc.styles.get("ODD")
|
||||
assert style.dxf.font == "acadmcp-missing-font.ttf"
|
||||
assert style.dxf.height == 3.5 and style.dxf.width == 0.8 and style.dxf.oblique == 15.0
|
||||
|
||||
|
||||
async def test_textstyle_refusals_write_nothing(backend):
|
||||
await backend.textstyle_create("ISOCP", "isocp.shx")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await backend.textstyle_create("isocp", "isocp.shx")
|
||||
with pytest.raises(ValueError, match="width_factor"):
|
||||
await backend.textstyle_create("Z", "arial.ttf", width_factor=0)
|
||||
with pytest.raises(ValueError, match="oblique_deg"):
|
||||
await backend.textstyle_create("Z", "arial.ttf", oblique_deg=90)
|
||||
with pytest.raises(ValueError, match="height"):
|
||||
await backend.textstyle_create("Z", "arial.ttf", height=-1)
|
||||
with pytest.raises(ValueError, match="font"):
|
||||
await backend.textstyle_create("Z", "")
|
||||
assert not backend._doc.styles.has_entry("Z")
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await backend.textstyle_set_current("NOPE")
|
||||
|
||||
|
||||
async def test_mleaderstyle_create_iso_and_list(backend):
|
||||
result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
|
||||
assert result["ok"] is True and result["textstyle_created"] is True
|
||||
assert result["values"] == {
|
||||
"arrow_size": 2.5,
|
||||
"landing_gap": 1.0,
|
||||
"text_style": "ISOCP",
|
||||
"text_height": 2.5,
|
||||
}
|
||||
style = backend._doc.mleader_styles.get("ISO")
|
||||
assert style.dxf.arrow_head_size == 2.5 and style.dxf.landing_gap_size == 1.0
|
||||
assert backend._doc.entitydb[style.dxf.text_style_handle].dxf.name == "ISOCP"
|
||||
rows = await backend.mleaderstyle_list()
|
||||
assert rows == [
|
||||
{
|
||||
"name": "ISO",
|
||||
"arrow_size": 2.5,
|
||||
"landing_gap": 1.0,
|
||||
"text_style": "ISOCP",
|
||||
"text_height": 2.5,
|
||||
"values_available": True,
|
||||
},
|
||||
{
|
||||
"name": "Standard",
|
||||
"arrow_size": 4.0,
|
||||
"landing_gap": 2.0,
|
||||
"text_style": "Standard",
|
||||
"text_height": 4.0,
|
||||
"values_available": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_mleaderstyle_refusals_write_nothing(backend):
|
||||
await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await backend.mleaderstyle_create("iso", resolve_mleaderstyle("iso", None))
|
||||
with pytest.raises(ValueError, match="text_style"):
|
||||
await backend.mleaderstyle_create("X", resolve_mleaderstyle("iso", {"text_style": "NOPE"}))
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
await backend.mleaderstyle_create("X", {"arrow_size": 2.5})
|
||||
assert not backend._doc.mleader_styles.has_entry("X")
|
||||
|
||||
|
||||
async def test_both_capability_maps_declare_mleaderstyle(backend):
|
||||
from backends.com_backend import ComBackend
|
||||
|
||||
assert backend.capabilities().to_dict()["features"]["mleaderstyle"]["supported"] is True
|
||||
com = ComBackend().capabilities().to_dict()["features"]["mleaderstyle"]
|
||||
assert com["supported"] is False and "leader_create_mleader" in com["reason"]
|
||||
|
||||
|
||||
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
||||
|
||||
|
||||
class _FakeStyle:
|
||||
def __init__(self, name, calls):
|
||||
self.Name = name
|
||||
self._calls = calls
|
||||
self.fontFile = "txt.shx"
|
||||
self.Width = 1.0
|
||||
self.ObliqueAngle = 0.0
|
||||
self.Height = 0.0
|
||||
self.saved: dict | None = None # the DIM* values this style holds, once saved
|
||||
|
||||
def CopyFrom(self, source):
|
||||
# AcadDimStyle.CopyFrom(document) saves the document's current
|
||||
# dimension settings into this style.
|
||||
self._calls.append(("CopyFrom", self.Name, source))
|
||||
self.saved = dict(source.variables)
|
||||
|
||||
|
||||
class _FakeCollection:
|
||||
def __init__(self, kind, calls, *names):
|
||||
self._kind = kind
|
||||
self._calls = calls
|
||||
self.entries = [_FakeStyle(name, calls) for name in names]
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.entries)
|
||||
|
||||
def Item(self, index):
|
||||
return self.entries[index]
|
||||
|
||||
def Add(self, name):
|
||||
self._calls.append((f"{self._kind}.Add", name))
|
||||
style = _FakeStyle(name, self._calls)
|
||||
self.entries.append(style)
|
||||
return style
|
||||
|
||||
|
||||
class _FakeBlock:
|
||||
def __init__(self, *entities):
|
||||
self.entities = list(entities)
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.entities)
|
||||
|
||||
def Item(self, index):
|
||||
return self.entities[index]
|
||||
|
||||
|
||||
class _FakeLayouts:
|
||||
def __init__(self, *blocks):
|
||||
self.layouts = [types.SimpleNamespace(Block=block) for block in blocks]
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.layouts)
|
||||
|
||||
def Item(self, index):
|
||||
return self.layouts[index]
|
||||
|
||||
|
||||
class _FakeDictionary:
|
||||
def __init__(self, *names):
|
||||
self.objects = [types.SimpleNamespace(mleader_name=name) for name in names]
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.objects)
|
||||
|
||||
def Item(self, index):
|
||||
return self.objects[index]
|
||||
|
||||
def GetName(self, obj):
|
||||
return obj.mleader_name
|
||||
|
||||
|
||||
class _FakeDictionaries:
|
||||
def __init__(self, entries):
|
||||
self.entries = entries
|
||||
|
||||
def Item(self, name):
|
||||
if name not in self.entries:
|
||||
raise RuntimeError(f"no dictionary {name}")
|
||||
return self.entries[name]
|
||||
|
||||
|
||||
CURRENT_DIMVARS = {
|
||||
"DIMTXT": 2.5,
|
||||
"DIMASZ": 2.5,
|
||||
"DIMEXE": 1.25,
|
||||
"DIMEXO": 0.625,
|
||||
"DIMGAP": 0.625,
|
||||
"DIMTAD": 1,
|
||||
"DIMTIH": 0,
|
||||
"DIMTOH": 0,
|
||||
"DIMDEC": 2,
|
||||
"DIMDSEP": ".",
|
||||
"DIMLUNIT": 2,
|
||||
"DIMZIN": 8,
|
||||
"DIMBLK": "",
|
||||
"DIMTXSTY": "Standard",
|
||||
"DIMLWD": -2,
|
||||
"DIMLWE": -2,
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
|
||||
class _FakeDocument:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple] = []
|
||||
self.DimStyles = _FakeCollection("DimStyles", self.calls, "Standard")
|
||||
self.TextStyles = _FakeCollection("TextStyles", self.calls, "Standard")
|
||||
self.variables = dict(CURRENT_DIMVARS)
|
||||
self._active_dim = self.DimStyles.entries[0]
|
||||
self._active_dim.saved = dict(CURRENT_DIMVARS)
|
||||
self._active_text = self.TextStyles.entries[0]
|
||||
dim = types.SimpleNamespace(
|
||||
ObjectName="AcDbRotatedDimension", StyleName="ISO-25", Handle="2A"
|
||||
)
|
||||
line = types.SimpleNamespace(ObjectName="AcDbLine", Handle="2B")
|
||||
sheet_dim = types.SimpleNamespace(
|
||||
ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C"
|
||||
)
|
||||
self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
|
||||
self.Dictionaries = _FakeDictionaries(
|
||||
{"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")}
|
||||
)
|
||||
|
||||
def GetVariable(self, name):
|
||||
return self.variables[name]
|
||||
|
||||
def SetVariable(self, name, value):
|
||||
self.calls.append(("SetVariable", name, value))
|
||||
self.variables[name] = value
|
||||
|
||||
@property
|
||||
def ActiveDimStyle(self):
|
||||
return self._active_dim
|
||||
|
||||
@ActiveDimStyle.setter
|
||||
def ActiveDimStyle(self, style):
|
||||
# Making a style current restores its saved settings as the current
|
||||
# DIM* variables and discards unsaved overrides (AutoCAD's -DIMSTYLE
|
||||
# Restore rule; scripts/smoke_settings_com.py confirms it live). A
|
||||
# style never saved (fresh DimStyles.Add) keeps the current variables.
|
||||
self.calls.append(("ActiveDimStyle", style.Name))
|
||||
self._active_dim = style
|
||||
if style.saved is not None:
|
||||
self.variables.update(style.saved)
|
||||
|
||||
@property
|
||||
def ActiveTextStyle(self):
|
||||
return self._active_text
|
||||
|
||||
@ActiveTextStyle.setter
|
||||
def ActiveTextStyle(self, style):
|
||||
self.calls.append(("ActiveTextStyle", style.Name))
|
||||
self._active_text = style
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from backends import com_backend as module
|
||||
|
||||
document = _FakeDocument()
|
||||
app = types.SimpleNamespace(
|
||||
Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=""))
|
||||
)
|
||||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||||
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
||||
monkeypatch.setattr(module, "_regen", lambda: None)
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, document
|
||||
|
||||
|
||||
async def test_com_create_sets_variables_then_copies_from_the_document(com_backend):
|
||||
backend, document = com_backend
|
||||
result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
assert result["ok"] is True and result["current"] is False
|
||||
assert result["textstyle_created"] is True
|
||||
names = [call[0] for call in document.calls]
|
||||
assert names[:2] == ["TextStyles.Add", "DimStyles.Add"], (
|
||||
"the text style exists before DIMTXSTY names it"
|
||||
)
|
||||
assert document.calls[2] == ("ActiveDimStyle", "ISO-25")
|
||||
sets = [call for call in document.calls if call[0] == "SetVariable"]
|
||||
assert len(sets) == 17
|
||||
assert ("SetVariable", "DIMDSEP", ",") in sets, "DIMDSEP is a string on ActiveX"
|
||||
assert ("SetVariable", "DIMTXSTY", "ISOCP") in sets
|
||||
assert ("SetVariable", "DIMTXT", 2.5) in sets and ("SetVariable", "DIMTAD", 1) in sets
|
||||
copy_index = names.index("CopyFrom")
|
||||
assert copy_index > names.index("SetVariable")
|
||||
assert document.calls[copy_index] == ("CopyFrom", "ISO-25", document)
|
||||
assert document.calls[-1] == ("ActiveDimStyle", "Standard"), "restored: set_current was False"
|
||||
assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
|
||||
isocp = document.TextStyles.entries[-1]
|
||||
assert isocp.Name == "ISOCP" and isocp.fontFile == "isocp.shx"
|
||||
|
||||
|
||||
async def test_com_create_set_current_keeps_the_new_style_active(com_backend):
|
||||
backend, document = com_backend
|
||||
result = await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
|
||||
assert result["current"] is True
|
||||
activations = [c for c in document.calls if c[0] == "ActiveDimStyle"]
|
||||
assert activations == [("ActiveDimStyle", "ANSI")]
|
||||
assert document.ActiveDimStyle.Name == "ANSI"
|
||||
|
||||
|
||||
async def test_com_create_refuses_before_touching_activex(com_backend):
|
||||
backend, document = com_backend
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await backend.dimstyle_create("standard", resolve_dimstyle("iso-25", None))
|
||||
with pytest.raises(ValueError, match="DIMTXSTY"):
|
||||
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
|
||||
with pytest.raises(ValueError, match="DIMDEC"):
|
||||
await backend.dimstyle_create("X", {"DIMDEC": 9})
|
||||
assert document.calls == []
|
||||
|
||||
|
||||
async def test_com_list_reports_values_for_the_current_style_only(com_backend):
|
||||
backend, document = com_backend
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
rows = await backend.dimstyle_list()
|
||||
assert [(r["name"], r["current"], r["values_available"]) for r in rows] == [
|
||||
("ISO-25", False, False),
|
||||
("Standard", True, True),
|
||||
]
|
||||
assert rows[0]["values"] is None
|
||||
assert rows[1]["values"]["DIMDSEP"] == "." and tuple(rows[1]["values"]) == PRESET_VARIABLES
|
||||
|
||||
|
||||
async def test_com_modify_switches_saves_and_switches_back(com_backend):
|
||||
backend, document = com_backend
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
document.calls.clear()
|
||||
result = await backend.dimstyle_modify("iso-25", {"DIMDEC": 3, "DIMTXT": 2.5})
|
||||
assert result["name"] == "ISO-25"
|
||||
assert result["changed"] == {"DIMDEC": [2, 3]}
|
||||
assert result["dimensions_using_style"] == ["2A"], "model and paper space, dimensions only"
|
||||
assert result["rerender_required"] is False, "AutoCAD re-renders on regen"
|
||||
assert document.calls == [
|
||||
("ActiveDimStyle", "ISO-25"),
|
||||
("SetVariable", "DIMDEC", 3),
|
||||
("CopyFrom", "ISO-25", document),
|
||||
("ActiveDimStyle", "Standard"),
|
||||
]
|
||||
document.calls.clear()
|
||||
again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
|
||||
assert again["changed"] == {}
|
||||
assert [c[0] for c in document.calls] == ["ActiveDimStyle", "ActiveDimStyle"], (
|
||||
"no CopyFrom when nothing moved"
|
||||
)
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
|
||||
|
||||
|
||||
async def test_com_set_current(com_backend):
|
||||
backend, document = com_backend
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
result = await backend.dimstyle_set_current("ISO-25")
|
||||
assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
|
||||
assert document.ActiveDimStyle.Name == "ISO-25"
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
await backend.dimstyle_set_current("NOPE")
|
||||
|
||||
|
||||
async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend):
|
||||
backend, document = com_backend
|
||||
result = await backend.textstyle_create("ISOCP", "isocp", 0.0, 1.0, 0.0, set_current=True)
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"name": "ISOCP",
|
||||
"font": "isocp.shx",
|
||||
"font_resolved": True,
|
||||
"current": True,
|
||||
}
|
||||
style = document.TextStyles.entries[-1]
|
||||
assert (style.fontFile, style.Width, style.ObliqueAngle, style.Height) == (
|
||||
"isocp.shx",
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
)
|
||||
assert document.ActiveTextStyle.Name == "ISOCP"
|
||||
odd = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15.0)
|
||||
assert odd["font_resolved"] is False
|
||||
assert document.TextStyles.entries[-1].ObliqueAngle == pytest.approx(0.2617993878), "radians"
|
||||
rows = await backend.textstyle_list()
|
||||
assert [(r["name"], r["current"]) for r in rows] == [
|
||||
("ISOCP", True),
|
||||
("ODD", False),
|
||||
("Standard", False),
|
||||
]
|
||||
assert rows[1]["oblique_deg"] == pytest.approx(15.0)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await backend.textstyle_create("isocp", "isocp.shx")
|
||||
back = await backend.textstyle_set_current("standard")
|
||||
assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
|
||||
|
||||
|
||||
async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend):
|
||||
backend, document = com_backend
|
||||
rows = await backend.mleaderstyle_list()
|
||||
assert rows == [
|
||||
{
|
||||
"name": "Annotative",
|
||||
"arrow_size": None,
|
||||
"landing_gap": None,
|
||||
"text_style": None,
|
||||
"text_height": None,
|
||||
"values_available": False,
|
||||
},
|
||||
{
|
||||
"name": "Standard",
|
||||
"arrow_size": None,
|
||||
"landing_gap": None,
|
||||
"text_style": None,
|
||||
"text_height": None,
|
||||
"values_available": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend):
|
||||
from backends.com_backend import ComBackend
|
||||
|
||||
backend, document = com_backend
|
||||
assert is_capability_default(ComBackend, "mleaderstyle_create")
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
|
||||
assert excinfo.value.capability == "mleaderstyle"
|
||||
assert "leader_create_mleader" in str(excinfo.value)
|
||||
assert document.calls == []
|
||||
Reference in New Issue
Block a user