merge: track-e group S
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import asyncio
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
@@ -318,6 +319,451 @@ 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)
|
||||
|
||||
|
||||
#: ``SelectionSet.Select`` mode ``acSelectionSetAll`` (AcSelect enum): every
|
||||
#: entity in the database, in every layout — ``ssget "X"``.
|
||||
_AC_SELECTION_SET_ALL = 5
|
||||
|
||||
#: Characters an ``ssget`` filter string treats as wildcards (AutoCAD's
|
||||
#: ``wcmatch`` grammar). Symbol names may carry most of them: a dimension
|
||||
#: style called ``A.B`` also matched ``A_B``, ``N#1`` matched ``N21``,
|
||||
#: ``P[1]`` matched ``P1`` and ``S@X`` matched ``SAX`` until the name was
|
||||
#: escaped (measured live, AutoCAD 2026).
|
||||
_WCMATCH_SPECIALS = frozenset("*?#@.~[]-,`")
|
||||
|
||||
|
||||
def _wcmatch_literal(text: str) -> str:
|
||||
"""``text`` spelled so an ``ssget`` filter matches it literally.
|
||||
|
||||
Every wildcard-significant character is escaped with a backtick, the
|
||||
grammar's own escape. A symbol name can never contain a backtick itself
|
||||
(AutoCAD refuses one), so the escaped form is unambiguous; the match stays
|
||||
case-insensitive, as any symbol-table name comparison is.
|
||||
"""
|
||||
return "".join("`" + char if char in _WCMATCH_SPECIALS else char for char in text)
|
||||
|
||||
|
||||
def _com_dimensions_using(doc, style_name: str) -> list[str]:
|
||||
"""Handles of every DIMENSION, in every layout, whose style is ``style_name``.
|
||||
|
||||
One filtered ``SelectionSet.Select(acSelectionSetAll)`` — the ``ssget "X"``
|
||||
route with ``(0 . "DIMENSION") (3 . <style>)`` — answers from AutoCAD's own
|
||||
index, so the cost does not grow with the drawing. The walk this replaced
|
||||
read ``ObjectName`` on every entity of every layout through a dynamic
|
||||
proxy (~8-10 ms each), which put ``dimstyle_modify`` past the 60 s
|
||||
``COM_CALL_TIMEOUT`` on a drawing of ~6k entities — after its write had
|
||||
already landed. Measured live (AutoCAD 2026, 1000 LINEs + 14 dimensions
|
||||
across model and paper space): walk 12.7-15.8 s, this 0.02-0.09 s, same
|
||||
handles. The style name is escaped as a literal (``_wcmatch_literal``)
|
||||
because the filter is a wildcard pattern; the ``DIMENSION`` group-0 name
|
||||
keeps parity with the headless engine's ``query("DIMENSION")``.
|
||||
|
||||
Handles come back in ascending handle order (database order), whatever
|
||||
order the selection set reports them in. Nothing is swallowed: a
|
||||
selection that fails is an error the caller sees, not an empty list
|
||||
reported as fact.
|
||||
"""
|
||||
ss = doc.SelectionSets.Add(f"_DIMSTYLE_{uuid.uuid4().hex[:8]}")
|
||||
try:
|
||||
filter_types = _ai([0, 3])
|
||||
filter_values = win32com.client.VARIANT(
|
||||
pythoncom.VT_ARRAY | pythoncom.VT_VARIANT,
|
||||
["DIMENSION", _wcmatch_literal(style_name)],
|
||||
)
|
||||
ss.Select(_AC_SELECTION_SET_ALL, None, None, filter_types, filter_values)
|
||||
# ``Handle`` is an IAcadObject member, so it answers on the narrowed
|
||||
# ``IAcadEntity`` wrapper ``SelectionSet.Item`` hands back.
|
||||
handles = [str(ss.Item(index).Handle) for index in range(ss.Count)]
|
||||
finally:
|
||||
try:
|
||||
ss.Delete()
|
||||
except Exception as exc:
|
||||
log.debug("SelectionSet cleanup failed: %s", exc)
|
||||
return sorted(handles, key=lambda handle: int(handle, 16))
|
||||
|
||||
|
||||
#: File suffixes AutoCAD hands to the Windows font system rather than its
|
||||
#: SHX loader (see ``_com_locate_font``).
|
||||
_TRUETYPE_SUFFIXES = frozenset({".ttf", ".ttc", ".otf"})
|
||||
|
||||
|
||||
def _com_font_folders() -> list[Path]:
|
||||
"""Every folder a font file is looked for, in order.
|
||||
|
||||
AutoCAD's support path (``Preferences.Files.SupportPath``, which carries
|
||||
the install's ``fonts`` folder with the SHX shape files), then the Windows
|
||||
font folders — machine-wide ``%WINDIR%\\Fonts`` (where AutoCAD's installer
|
||||
puts ISOCPEUR next to Arial; measured) and the per-user
|
||||
``%LOCALAPPDATA%\\Microsoft\\Windows\\Fonts``.
|
||||
"""
|
||||
folders: list[Path] = []
|
||||
try:
|
||||
support = str(_acad_app().Preferences.Files.SupportPath)
|
||||
except Exception:
|
||||
support = ""
|
||||
folders.extend(Path(folder) for folder in support.split(";") if folder)
|
||||
windir = os.environ.get("WINDIR") or os.environ.get("SystemRoot")
|
||||
if windir:
|
||||
folders.append(Path(windir) / "Fonts")
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
if local:
|
||||
folders.append(Path(local) / "Microsoft" / "Windows" / "Fonts")
|
||||
return folders
|
||||
|
||||
|
||||
def _com_font_path(font_file: str) -> Path | None:
|
||||
"""The file ``font_file`` names on this machine, or None when nobody has it.
|
||||
|
||||
An absolute path must exist as given; a bare name is searched over
|
||||
``_com_font_folders``, and a name without a suffix also as ``.shx`` (the
|
||||
SHX loader appends it: ``fontFile = "isocp"`` is accepted live and stored
|
||||
as given).
|
||||
"""
|
||||
path = Path(font_file)
|
||||
if path.is_absolute():
|
||||
return path if path.is_file() else None
|
||||
names = [font_file] if path.suffix else [font_file, font_file + ".shx"]
|
||||
for folder in _com_font_folders():
|
||||
for name in names:
|
||||
candidate = folder / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _truetype_face(path: Path) -> tuple[str, bool, bool] | None:
|
||||
"""``(family, bold, italic)`` from a TrueType/OpenType file's ``name`` table.
|
||||
|
||||
The family (name ID 1) is the typeface Windows GDI — and therefore
|
||||
AutoCAD's ``TextStyle.SetFont`` — knows the font by; the subfamily (ID 2)
|
||||
says whether it is the bold and/or italic face, which ``SetFont`` takes
|
||||
as flags. Windows/Unicode strings (platform 3, en-US preferred) win over
|
||||
Macintosh Roman ones. A collection (``ttcf``) is read by its first font.
|
||||
None for a file that is not an sfnt or carries no family name.
|
||||
"""
|
||||
import struct
|
||||
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
offset = struct.unpack(">I", data[12:16])[0] if data[:4] == b"ttcf" else 0
|
||||
num_tables = struct.unpack(">H", data[offset + 4 : offset + 6])[0]
|
||||
name_table = None
|
||||
for index in range(num_tables):
|
||||
record = offset + 12 + 16 * index
|
||||
if data[record : record + 4] == b"name":
|
||||
name_table = struct.unpack(">II", data[record + 8 : record + 16])
|
||||
break
|
||||
if name_table is None:
|
||||
return None
|
||||
table_offset, _length = name_table
|
||||
count, strings = struct.unpack(">HH", data[table_offset + 2 : table_offset + 6])
|
||||
found: dict[tuple[int, int], str] = {} # (name id, priority) -> text
|
||||
for index in range(count):
|
||||
record = table_offset + 6 + 12 * index
|
||||
platform, _encoding, language, name_id, length, start = struct.unpack(
|
||||
">HHHHHH", data[record : record + 12]
|
||||
)
|
||||
if name_id not in (1, 2):
|
||||
continue
|
||||
raw = data[table_offset + strings + start : table_offset + strings + start + length]
|
||||
if platform == 3:
|
||||
priority = 0 if language == 0x409 else 1
|
||||
text = raw.decode("utf-16-be", "replace")
|
||||
elif platform == 1:
|
||||
priority = 2
|
||||
text = raw.decode("mac-roman", "replace")
|
||||
else:
|
||||
continue
|
||||
found.setdefault((name_id, priority), text)
|
||||
except (struct.error, IndexError):
|
||||
return None
|
||||
family = next((found[key] for key in sorted(found) if key[0] == 1), "").strip()
|
||||
if not family:
|
||||
return None
|
||||
subfamily = next((found[key] for key in sorted(found) if key[0] == 2), "").lower()
|
||||
return family, "bold" in subfamily, "italic" in subfamily or "oblique" in subfamily
|
||||
|
||||
|
||||
class _ComFont:
|
||||
"""A font file located for the live engine: where it is and, for
|
||||
TrueType, the typeface ``SetFont`` needs (None for an SHX)."""
|
||||
|
||||
__slots__ = ("font_file", "path", "face")
|
||||
|
||||
def __init__(self, font_file: str, path: Path, face: tuple[str, bool, bool] | None):
|
||||
self.font_file = font_file
|
||||
self.path = path
|
||||
self.face = face
|
||||
|
||||
|
||||
def _com_font_refusal(key: str, font_file: str, detail: str) -> ValueError:
|
||||
from engineering.standards.textstyles import TEXT_PRESETS
|
||||
|
||||
presets = sorted(font for font, _w, _o in TEXT_PRESETS.values())
|
||||
return ValueError(
|
||||
f"{key}: font file {font_file!r} {detail}; ActiveX refuses a font it cannot open, "
|
||||
"so the live engine refuses it before any write (headlessly it would be written and "
|
||||
"reported font_resolved false) — copy the file to a support-path or Windows Fonts "
|
||||
f"folder, or use a bundled preset ({presets})"
|
||||
)
|
||||
|
||||
|
||||
def _com_locate_font(key: str, font_file: str) -> _ComFont:
|
||||
"""The font ``font_file`` names, resolved *before* ``TextStyles.Add`` — or a refusal.
|
||||
|
||||
Asked first because ``TextStyle.fontFile`` on a file AutoCAD cannot open
|
||||
raises ``DISP_E_EXCEPTION 'Filer error'`` (measured on AutoCAD 2026 for
|
||||
``nosuchfont.shx``) *after* the table already holds the new name with an
|
||||
empty font — a stub a retry then meets as "already exists". A TrueType
|
||||
file must also carry a readable family name, because that — not the file
|
||||
name — is what the write uses (``_com_write_font``).
|
||||
"""
|
||||
path = _com_font_path(font_file)
|
||||
if path is None:
|
||||
raise _com_font_refusal(
|
||||
key, font_file, "is not on AutoCAD's support path or in the Windows Fonts folder"
|
||||
)
|
||||
face = None
|
||||
if path.suffix.lower() in _TRUETYPE_SUFFIXES:
|
||||
face = _truetype_face(path)
|
||||
if face is None:
|
||||
raise _com_font_refusal(key, font_file, f"({path}) is not a readable TrueType file")
|
||||
return _ComFont(font_file, path, face)
|
||||
|
||||
|
||||
def _com_write_font(style, font: _ComFont) -> None:
|
||||
"""Set ``style``'s font from a ``_com_locate_font`` result.
|
||||
|
||||
Measured on AutoCAD 2026: ``fontFile = "isocp.shx"`` (an SHX on the
|
||||
support path) is accepted and stored as given, so an SHX is written by
|
||||
the name the caller gave. A bare TrueType name — ``"arial.ttf"``,
|
||||
``"isocpeur.ttf"``, bundled presets included — raises ``'Filer error'``
|
||||
although the files exist; the full path is accepted but the STYLE record
|
||||
then carries the machine path (``fontFile`` reads back
|
||||
``C:\\Windows\\Fonts\\arial.ttf``); ``SetFont("Arial", bold, italic, 0, 0)``
|
||||
— AutoCAD's own STYLE-dialog route — is accepted and reads back as the
|
||||
clean ``arial.ttf``. A TrueType font is therefore written by its typeface,
|
||||
and ``SetFont`` refusing a face Windows has not installed (``'Invalid
|
||||
input'``) is the one failure that can still land after ``Add``; the
|
||||
caller removes the entry it added.
|
||||
"""
|
||||
if font.face is None:
|
||||
style.fontFile = font.font_file
|
||||
else:
|
||||
family, bold, italic = font.face
|
||||
style.SetFont(family, bold, italic, 0, 0)
|
||||
|
||||
|
||||
def _com_delete_quietly(obj, what: str) -> None:
|
||||
"""Best-effort ``Delete`` of a half-made table entry on an error path."""
|
||||
try:
|
||||
obj.Delete()
|
||||
except Exception as exc:
|
||||
log.warning("could not remove half-made %s: %s", what, exc)
|
||||
|
||||
|
||||
def _com_create_textstyle(
|
||||
doc, name: str, font_file: str, *, width: float, oblique_deg: float, height: float, key: str
|
||||
):
|
||||
"""``TextStyles.Add`` plus its font, width, oblique and height — or nothing.
|
||||
|
||||
The font is located first (``_com_locate_font``) so a refusal precedes
|
||||
the ``Add``; a write that still fails removes the entry it added, because
|
||||
a STYLE row with an empty font that blocks a retry as "already exists" is
|
||||
exactly the half-made state measured before this helper existed.
|
||||
"""
|
||||
font = _com_locate_font(key, font_file)
|
||||
style = doc.TextStyles.Add(name)
|
||||
try:
|
||||
_com_write_font(style, font)
|
||||
style.Width = float(width)
|
||||
style.ObliqueAngle = deg2rad(oblique_deg)
|
||||
style.Height = float(height)
|
||||
except Exception:
|
||||
_com_delete_quietly(style, f"text style {name!r}")
|
||||
raise
|
||||
return style
|
||||
|
||||
|
||||
def _com_require_arrowhead_blocks(doc, values: dict[str, Any]) -> None:
|
||||
"""Refuse a user arrowhead block the drawing does not define — before any write.
|
||||
|
||||
The live twin of the headless ``_require_arrowhead_blocks``: a built-in
|
||||
(``ARROWHEAD_BLOCKS``) and closed filled (``""``) need no block, a user
|
||||
name must be in ``doc.Blocks`` — AutoCAD's ``SetVariable("DIMBLK",
|
||||
"NOSUCHBLOCK")`` raises ``DISP_E_EXCEPTION`` mid-write, after
|
||||
``DimStyles.Add`` and ``ActiveDimStyle`` (measured: the half-made style was
|
||||
left *current*). The name is rewritten to the block table's own spelling,
|
||||
the rule the headless engine follows.
|
||||
"""
|
||||
from engineering.standards.dimstyles import ARROWHEAD_BLOCKS
|
||||
|
||||
for var in ("DIMBLK", "DIMBLK1", "DIMBLK2"):
|
||||
name = values.get(var)
|
||||
if not name or name in ARROWHEAD_BLOCKS:
|
||||
continue
|
||||
block = _com_named(doc.Blocks, name)
|
||||
if block is None:
|
||||
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] = str(block.Name)
|
||||
|
||||
|
||||
_MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE"
|
||||
_MLEADERSTYLE_CLASS = "AcDbMLeaderStyle"
|
||||
|
||||
|
||||
def _com_unnarrow(obj):
|
||||
"""Re-dispatch ``obj`` on its raw ``IDispatch`` so its *runtime* class is reachable.
|
||||
|
||||
acax25enu.tlb declares ``IAcadDictionaries.Item``, ``IAcadDictionary.Item``
|
||||
and ``IAcadDictionary.AddObject`` as returning ``IAcadObject*``. Whenever a
|
||||
makepy cache for the AutoCAD type library exists (it does on any machine
|
||||
where ``win32com.client.gencache`` ever ran against AutoCAD), pywin32 wraps
|
||||
such a return in the gen_py class of the *declared* type, and that wrapper
|
||||
exposes only ``IAcadObject`` members: ``.Count`` / ``.Item`` / ``.GetName``
|
||||
on the dictionary and ``.ArrowSize`` / ``.TextStyle`` on a leader style all
|
||||
raise ``AttributeError`` even though the live object answers them. (The
|
||||
rest of this backend never meets the problem: ``HandleToObject`` is declared
|
||||
``IDispatch*``, which keeps the concrete class, and ``ModelSpace.Item`` is
|
||||
read only through ``IAcadEntity`` members.)
|
||||
|
||||
``win32com.client.dynamic.Dispatch`` on the raw interface builds a
|
||||
late-bound proxy from the object's own type info, so every member of the
|
||||
concrete class resolves — verified live on AutoCAD 2026 — and, unlike
|
||||
``CastTo``, it never calls ``gencache.EnsureDispatch`` and never writes the
|
||||
makepy cache. A wrapper whose ``_oleobj_`` is not a COM interface (the
|
||||
fake in tests/test_styles.py) is unwrapped to that object as-is; anything
|
||||
without ``_oleobj_`` is returned unchanged.
|
||||
"""
|
||||
ole = getattr(obj, "_oleobj_", None)
|
||||
if ole is None:
|
||||
return obj
|
||||
if _COM_IMPORTS_OK and isinstance(ole, pythoncom.TypeIIDs[pythoncom.IID_IDispatch]):
|
||||
return win32com.client.dynamic.Dispatch(ole)
|
||||
return ole
|
||||
|
||||
|
||||
def _com_mleaderstyle_dictionary(doc, *, create: bool = False):
|
||||
"""The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none.
|
||||
|
||||
ActiveX exposes no ``MLeaderStyles`` *collection*, but the dictionary's
|
||||
items are full ``IAcadMLeaderStyle`` objects (acax25enu.tlb: ``Name``,
|
||||
``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and
|
||||
``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the
|
||||
documented VBA route — creates one. Every drawing AutoCAD makes carries
|
||||
the dictionary; ``create`` adds it to a foreign file that does not.
|
||||
|
||||
Every object this route hands over goes through ``_com_unnarrow`` (see
|
||||
there): ``Dictionaries.Item`` is declared ``IAcadObject*`` and comes back
|
||||
as a wrapper with no ``Count``. Verified live on AutoCAD 2026 (list and
|
||||
create on a scratch document); scripts/smoke_settings_com.py re-runs it.
|
||||
"""
|
||||
try:
|
||||
dictionary = doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY)
|
||||
except Exception:
|
||||
if not create:
|
||||
return None
|
||||
dictionary = doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY)
|
||||
return _com_unnarrow(dictionary)
|
||||
|
||||
|
||||
def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]:
|
||||
"""``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key.
|
||||
|
||||
Each ``Item(i)`` is un-narrowed: declared ``IAcadObject*``, it would
|
||||
otherwise carry no ``ArrowSize``.
|
||||
"""
|
||||
rows = []
|
||||
for index in range(dictionary.Count):
|
||||
style = _com_unnarrow(dictionary.Item(index))
|
||||
rows.append((str(dictionary.GetName(style)), style))
|
||||
return rows
|
||||
|
||||
|
||||
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
|
||||
_com_create_textstyle(
|
||||
doc, name.upper(), font_file, width=width, oblique_deg=oblique, height=0.0, key=refusal_key
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -4581,6 +5027,328 @@ 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"
|
||||
)
|
||||
# Every refusal precedes the first write: a user arrowhead block
|
||||
# must exist (SetVariable("DIMBLK", ...) would otherwise raise
|
||||
# mid-loop, after Add and ActiveDimStyle), and the text style must
|
||||
# exist before DIMTXSTY can name it.
|
||||
_com_require_arrowhead_blocks(doc, typed)
|
||||
txsty, textstyle_created = _ensure_com_textstyle(
|
||||
doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
|
||||
)
|
||||
previous = doc.ActiveDimStyle
|
||||
style = doc.DimStyles.Add(clean)
|
||||
try:
|
||||
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
|
||||
except Exception:
|
||||
# Restoring the previous style discards the unsaved overrides
|
||||
# (AutoCAD's own Restore rule) and frees the new entry for
|
||||
# Delete, so the drawing is left exactly as it was found —
|
||||
# the half-made style is never the operator's current style.
|
||||
doc.ActiveDimStyle = previous
|
||||
_com_delete_quietly(style, f"dimension style {clean!r}")
|
||||
if textstyle_created:
|
||||
created = _com_named(doc.TextStyles, txsty)
|
||||
if created is not None:
|
||||
_com_delete_quietly(created, f"text style {txsty!r}")
|
||||
raise
|
||||
_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"
|
||||
)
|
||||
_com_require_arrowhead_blocks(doc, typed)
|
||||
previous = doc.ActiveDimStyle
|
||||
switched = str(previous.Name).lower() != str(style.Name).lower()
|
||||
if switched:
|
||||
doc.ActiveDimStyle = style
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
# Re-activating a style discards its unsaved overrides, so a
|
||||
# failed write leaves the target style as it was saved and the
|
||||
# operator's current style as it was found.
|
||||
doc.ActiveDimStyle = style if not switched else previous
|
||||
raise
|
||||
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")
|
||||
# Unlike the headless engine, which stores a name nobody can find
|
||||
# and says `font_resolved: false`, ActiveX refuses a font it
|
||||
# cannot open — so the file is located before TextStyles.Add and
|
||||
# a font AutoCAD cannot see is refused with nothing written.
|
||||
style = _com_create_textstyle(
|
||||
doc,
|
||||
spec["name"],
|
||||
spec["font_file"],
|
||||
width=spec["width_factor"],
|
||||
oblique_deg=spec["oblique_deg"],
|
||||
height=spec["height"],
|
||||
key="font",
|
||||
)
|
||||
if set_current:
|
||||
doc.ActiveTextStyle = style
|
||||
return {
|
||||
"ok": True,
|
||||
"name": spec["name"],
|
||||
"font": spec["font_file"],
|
||||
# Always true here: a font the live engine could not locate
|
||||
# was refused above, never written.
|
||||
"font_resolved": True,
|
||||
"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()
|
||||
dictionary = _com_mleaderstyle_dictionary(doc)
|
||||
if dictionary is None:
|
||||
return []
|
||||
rows = []
|
||||
for name, style in _com_mleaderstyle_rows(dictionary):
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"arrow_size": float(style.ArrowSize),
|
||||
"landing_gap": float(style.LandingGap),
|
||||
"text_style": str(style.TextStyle),
|
||||
"text_height": float(style.TextHeight),
|
||||
"values_available": True,
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: row["name"].lower())
|
||||
return rows
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
|
||||
from engineering.standards.mleaderstyles import validate_mleaderstyle
|
||||
|
||||
clean = sanitize_symbol_name(name, kind="mleaderstyle")
|
||||
typed = validate_mleaderstyle(values)
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
dictionary = _com_mleaderstyle_dictionary(doc, create=True)
|
||||
wanted = clean.lower()
|
||||
if any(
|
||||
existing.lower() == wanted for existing, _ in _com_mleaderstyle_rows(dictionary)
|
||||
):
|
||||
raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists")
|
||||
# The text style must exist before TextStyle can name it — and a
|
||||
# name that is neither present nor a preset is refused here,
|
||||
# before AddObject, so a refusal leaves no half-made style behind.
|
||||
text_style_name, textstyle_created = _ensure_com_textstyle(
|
||||
doc, typed["text_style"], refusal_key="text_style"
|
||||
)
|
||||
# AddObject is declared IAcadObject* too: un-narrowed, or the
|
||||
# first property write below raises AttributeError.
|
||||
style = _com_unnarrow(dictionary.AddObject(clean, _MLEADERSTYLE_CLASS))
|
||||
style.ArrowSize = typed["arrow_size"]
|
||||
style.LandingGap = typed["landing_gap"]
|
||||
style.TextHeight = typed["text_height"]
|
||||
style.TextStyle = text_style_name
|
||||
return {
|
||||
"ok": True,
|
||||
"name": clean,
|
||||
"values": {**typed, "text_style": text_style_name},
|
||||
"textstyle_created": textstyle_created,
|
||||
}
|
||||
|
||||
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,136 @@
|
||||
"""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
|
||||
|
||||
|
||||
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}]``.
|
||||
|
||||
Both engines report the four values (``values_available: True``).
|
||||
Headless they are read from the MLEADERSTYLE object (schema defaults
|
||||
for an unset field); live from the ``AcadMLeaderStyle`` objects the
|
||||
``ACAD_MLEADERSTYLE`` dictionary holds (``ArrowSize`` / ``LandingGap``
|
||||
/ ``TextHeight`` in drawing units, ``TextStyle`` a name) — ActiveX
|
||||
exposes no *collection* property for them, but the dictionary items
|
||||
are full ``IAcadMLeaderStyle`` objects.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def mleaderstyle_create(self, name: str, values: dict) -> dict:
|
||||
"""Create a multileader style from a resolved ``values`` dict
|
||||
(``engineering.standards.mleaderstyles.resolve_mleaderstyle``).
|
||||
Both engines: ezdxf on ``doc.mleader_styles``; live through
|
||||
``Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name,
|
||||
"AcDbMLeaderStyle")`` and the object's property writes. 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 — before anything is written."""
|
||||
...
|
||||
+586
-16
@@ -387,15 +387,26 @@ def _normalise_dimstyle_rounding(doc) -> None:
|
||||
itself — silently rounded every dimension to a whole unit *before* DIMDEC
|
||||
got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6.
|
||||
|
||||
Every style in the table, not `Standard` alone: ezdxf's exporter writes
|
||||
group 45 = 0.0 for *each* DIMSTYLE entry, so a user style (the ISO-25 /
|
||||
ANSI presets `dimstyle_create` makes) came back poisoned from every DXF
|
||||
round trip — save/open, `transaction_rollback`, `drawing_undo` — and a
|
||||
33.333 mm feature dimensioned with it rendered "33" while `dimstyle_list`
|
||||
still reported DIMDEC 2. Measured before this ran on the whole table.
|
||||
|
||||
Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm
|
||||
rounding keeps it.
|
||||
"""
|
||||
try:
|
||||
style = doc.dimstyles.get("Standard")
|
||||
styles = list(doc.dimstyles)
|
||||
except Exception:
|
||||
return
|
||||
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
|
||||
style.dxf.discard("dimrnd")
|
||||
for style in styles:
|
||||
try:
|
||||
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
|
||||
style.dxf.discard("dimrnd")
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
|
||||
def _apply_iso_dimstyle(doc) -> None:
|
||||
@@ -591,7 +602,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 +673,291 @@ 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 into ``$DIM*`` — every whitelist variable, a live seat's Restore.
|
||||
|
||||
``-DIMSTYLE Restore`` replaces *all* the DIM* system variables with the
|
||||
style's settings; a DIMSTYLE entry that omits a code means its schema
|
||||
default. Writing only the attributes the style *stores* left every other
|
||||
``$DIM*`` at the previous style's value — measured, ISO-25 with DIMZIN 0 /
|
||||
DIMSCALE 2.0 made current and then ``dimstyle_set_current("Standard")``
|
||||
rendered the next dimension at 5.0 mm as "33.30": `_with_header_dimvars`
|
||||
folded the stale header onto it, and the saved file carried the same
|
||||
values next to ``$DIMSTYLE Standard``, which AutoCAD reads as overrides.
|
||||
The same gap kept ``$DIMRND 0.5`` in the header after `dimstyle_modify`
|
||||
discarded the style's own attribute.
|
||||
|
||||
Measured on ezdxf 1.4.4: a fresh header equals the schema default for
|
||||
every whitelist variable a fresh `Standard` does not store, so restoring
|
||||
`Standard` reproduces the header `drawing_new` made. `DIMTXSTY` has no
|
||||
schema default; AutoCAD reads ``Standard`` for it, as `_dimstyle_value`
|
||||
reports.
|
||||
|
||||
The arrowheads cross a vocabulary boundary here. The DIMSTYLE entry holds
|
||||
ezdxf's writer spelling (``OBLIQUE``, see `_write_dimstyle_values`); the
|
||||
``$DIMBLK`` / ``$DIMBLK1`` / ``$DIMBLK2`` header variables are DXF-facing
|
||||
and spell a built-in with its underscore (``_OBLIQUE``, what a live seat
|
||||
stores and what `system_get_variable("DIMBLK")` returns on COM). Copied
|
||||
raw, the saved file carried ``$DIMBLK "OBLIQUE"`` next to blocks named
|
||||
``_OBLIQUE`` — a current-override naming a block that does not exist —
|
||||
and the two engines answered the same read two ways. `reported_arrowhead`
|
||||
is the same read-side rule `_dimstyle_value` applies.
|
||||
"""
|
||||
from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST, reported_arrowhead
|
||||
|
||||
for var in sorted(DIM_VARIABLE_WHITELIST):
|
||||
attr = var.lower()
|
||||
value = style.dxf.get(attr) if style.dxf.hasattr(attr) else None
|
||||
if value is None:
|
||||
value = _dimstyle_schema_default(var)
|
||||
if value is None and var == "DIMTXSTY":
|
||||
value = "Standard"
|
||||
if var in _ARROWHEAD_VARS:
|
||||
value = reported_arrowhead(value)
|
||||
key = f"${var}"
|
||||
if value is None:
|
||||
try:
|
||||
del doc.header[key]
|
||||
except KeyError:
|
||||
pass
|
||||
continue
|
||||
doc.header[key] = value
|
||||
|
||||
|
||||
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 _style_table_references(doc) -> tuple[set[str], set[str]]:
|
||||
"""``(block names, text-style names)`` the style tables and header reference.
|
||||
|
||||
Lower-cased, for `drawing_purge`. A DIMSTYLE names its text style and up
|
||||
to four arrowhead blocks (stored in ezdxf's spelling, ``OBLIQUE``, while
|
||||
the block is ``_OBLIQUE`` — both spellings are counted); an MLEADERSTYLE
|
||||
references its text style and arrowhead by handle; the header names the
|
||||
current text style and dimension text style. ``Standard`` is never purged.
|
||||
"""
|
||||
blocks: set[str] = set()
|
||||
styles: set[str] = {"standard"}
|
||||
|
||||
def _add_block(name) -> None:
|
||||
if isinstance(name, str) and name:
|
||||
lowered = name.lower()
|
||||
blocks.add(lowered)
|
||||
blocks.add(lowered if lowered.startswith("_") else f"_{lowered}")
|
||||
|
||||
def _add_style(name) -> None:
|
||||
if isinstance(name, str) and name:
|
||||
styles.add(name.lower())
|
||||
|
||||
def _add_by_handle(handle, into) -> None:
|
||||
entity = doc.entitydb.get(handle) if handle else None
|
||||
if entity is not None:
|
||||
into(entity.dxf.get("name"))
|
||||
|
||||
try:
|
||||
for var in ("$TEXTSTYLE", "$DIMTXSTY"):
|
||||
_add_style(doc.header.get(var, None))
|
||||
for var in ("$DIMBLK", "$DIMBLK1", "$DIMBLK2", "$DIMLDRBLK"):
|
||||
_add_block(doc.header.get(var, None))
|
||||
except Exception: # an opened document may not have a readable header
|
||||
pass
|
||||
for style in doc.dimstyles:
|
||||
_add_style(style.dxf.get("dimtxsty"))
|
||||
for attr in ("dimblk", "dimblk1", "dimblk2", "dimldrblk"):
|
||||
_add_block(style.dxf.get(attr))
|
||||
for _name, style in doc.mleader_styles:
|
||||
_add_by_handle(style.dxf.get("text_style_handle"), _add_style)
|
||||
_add_by_handle(style.dxf.get("arrow_head_handle"), _add_block)
|
||||
for entity in doc.entitydb.values():
|
||||
if entity.dxftype() != "MULTILEADER":
|
||||
continue
|
||||
context = getattr(entity, "context", None)
|
||||
_add_by_handle(getattr(context, "text_style_handle", None), _add_style)
|
||||
for arrow in getattr(entity, "arrow_heads", None) or ():
|
||||
_add_by_handle(getattr(arrow, "handle", None), _add_block)
|
||||
return blocks, styles
|
||||
|
||||
|
||||
def _round_measure(result: dict) -> dict:
|
||||
"""Six decimal places, matching analysis_measure_distance."""
|
||||
result["area"] = round(float(result["area"]), 6)
|
||||
@@ -2369,17 +2665,31 @@ class EzdxfBackend(AutoCADBackend):
|
||||
raise self._solid_unsupported("solid_boolean")
|
||||
|
||||
async def drawing_purge(self) -> dict:
|
||||
"""Delete every unreferenced block, layer, linetype and text style.
|
||||
|
||||
A reference is not only an entity's own attribute. The style tables
|
||||
reference each other — a DIMSTYLE names its text style (DIMTXSTY) and
|
||||
its arrowhead blocks, an MLEADERSTYLE holds a text-style handle — and
|
||||
the header names the current text style (``$TEXTSTYLE``, ``$DIMTXSTY``).
|
||||
Measured before those were counted: ``dimstyle_create("ISO-25", …,
|
||||
set_current=True)`` then ``drawing_purge()`` removed ISOCP, the next
|
||||
dimension silently rendered in ``Standard`` while `dimstyle_list` still
|
||||
said ISOCP, and ``drawing_save_as`` raised ``DXFTableEntryError`` after
|
||||
leaving a truncated file on disk. Names are compared case-insensitively,
|
||||
AutoCAD's rule for every symbol table.
|
||||
"""
|
||||
|
||||
def _sync():
|
||||
doc = self._require_doc()
|
||||
purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0}
|
||||
used_blocks, used_styles = _style_table_references(doc)
|
||||
|
||||
used_blocks: set[str] = set()
|
||||
for ent in doc.entitydb.values():
|
||||
if ent.dxftype() == "INSERT":
|
||||
used_blocks.add(ent.dxf.name)
|
||||
used_blocks.add(str(ent.dxf.name).lower())
|
||||
for blk in list(doc.blocks):
|
||||
name = blk.name
|
||||
if name.startswith("*") or name in used_blocks:
|
||||
if name.startswith("*") or name.lower() in used_blocks:
|
||||
continue
|
||||
try:
|
||||
doc.blocks.delete_block(name, safe=True)
|
||||
@@ -2417,13 +2727,12 @@ class EzdxfBackend(AutoCADBackend):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
used_styles: set[str] = {"Standard"}
|
||||
for ent in doc.entitydb.values():
|
||||
if hasattr(ent.dxf, "style"):
|
||||
used_styles.add(ent.dxf.style)
|
||||
used_styles.add(str(ent.dxf.style).lower())
|
||||
for st in list(doc.styles):
|
||||
name = st.dxf.name
|
||||
if name in used_styles:
|
||||
if name.lower() in used_styles:
|
||||
continue
|
||||
try:
|
||||
doc.styles.remove(name)
|
||||
@@ -2748,6 +3057,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)),
|
||||
@@ -2774,7 +3084,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:
|
||||
@@ -3361,7 +3675,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()
|
||||
@@ -3391,7 +3705,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()
|
||||
@@ -3423,7 +3737,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()
|
||||
@@ -3468,7 +3782,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()
|
||||
@@ -3518,7 +3832,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()
|
||||
@@ -5958,6 +6272,262 @@ 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 validate_mleaderstyle
|
||||
from security import sanitize_symbol_name
|
||||
|
||||
clean = sanitize_symbol_name(name, kind="mleaderstyle")
|
||||
typed = validate_mleaderstyle(values)
|
||||
|
||||
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, typed["text_style"], refusal_key="text_style"
|
||||
)
|
||||
style = doc.mleader_styles.new(clean)
|
||||
style.dxf.arrow_head_size = typed["arrow_size"]
|
||||
style.dxf.landing_gap_size = typed["landing_gap"]
|
||||
style.dxf.char_height = typed["text_height"]
|
||||
style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle
|
||||
self._mark_dirty()
|
||||
return {
|
||||
"ok": True,
|
||||
"name": clean,
|
||||
"values": {**typed, "text_style": text_style_name},
|
||||
"textstyle_created": textstyle_created,
|
||||
}
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
# ── corner ops ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user