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
+2
View File
@@ -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.
+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
+2
View File
@@ -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",
+143
View File
@@ -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
View File
@@ -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