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:
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> **v1.5 release snapshot:** 176 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,20 +72,27 @@ class ToolAliases:
|
||||
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
|
||||
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
|
||||
# class, a number and markers; a drafter typing it could mean either.
|
||||
# DIMSTYLE, STYLE, MLEADERSTYLE -- one dialog each in AutoCAD for
|
||||
# list / create / modify / set-current; this server splits each into
|
||||
# discrete style tools (track E), so the command is a correct
|
||||
# destination for every one of them.
|
||||
#
|
||||
# Every other AutoCAD command must map to exactly one tool.
|
||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"ARRAY",
|
||||
"BLOCK",
|
||||
"DIMSTYLE",
|
||||
"ERASE",
|
||||
"INSERT",
|
||||
"LAYER",
|
||||
"LAYOUT",
|
||||
"MEASUREGEOM",
|
||||
"MLEADERSTYLE",
|
||||
"PLINE",
|
||||
"QSELECT",
|
||||
"SETVAR",
|
||||
"STYLE",
|
||||
"ZOOM",
|
||||
}
|
||||
)
|
||||
@@ -1431,6 +1438,100 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"loop number",
|
||||
),
|
||||
),
|
||||
# ── Styles (track E, group S) ───────────────────────────────────────────
|
||||
"dimstyle_create": ToolAliases(
|
||||
acad=("DIMSTYLE", "DDIM"),
|
||||
synonyms=(
|
||||
"new dimension style",
|
||||
"create a dimension style",
|
||||
"iso-25 dimension style",
|
||||
"ansi dimension style",
|
||||
"dimension style from a preset",
|
||||
"set up dimension text height and arrows",
|
||||
),
|
||||
),
|
||||
"dimstyle_list": ToolAliases(
|
||||
acad=("DIMSTYLE",),
|
||||
synonyms=(
|
||||
"list dimension styles",
|
||||
"dimension styles in the drawing",
|
||||
"dimension style table",
|
||||
"current dimstyle",
|
||||
"dim styles",
|
||||
),
|
||||
),
|
||||
"dimstyle_modify": ToolAliases(
|
||||
acad=("DIMSTYLE",),
|
||||
synonyms=(
|
||||
"change a dimension style",
|
||||
"edit the dimension style",
|
||||
"change dimension text height for the whole drawing",
|
||||
"set dimdec on a style",
|
||||
"modify dimstyle variables",
|
||||
),
|
||||
),
|
||||
"dimstyle_set_current": ToolAliases(
|
||||
acad=("DIMSTYLE",),
|
||||
synonyms=(
|
||||
"current dimension style",
|
||||
"make this dimension style current",
|
||||
"switch dimension style",
|
||||
"use iso-25 for new dimensions",
|
||||
"activate a dimstyle",
|
||||
),
|
||||
),
|
||||
"drawing_apply_standard": ToolAliases(
|
||||
acad=(),
|
||||
synonyms=(
|
||||
"set the drawing up to iso",
|
||||
"iso drafting standard",
|
||||
"ansi drawing setup",
|
||||
"apply the drafting standard",
|
||||
"iso-25 and isocp in one go",
|
||||
"standard styles units and layers",
|
||||
),
|
||||
),
|
||||
"mleaderstyle_create": ToolAliases(
|
||||
acad=("MLEADERSTYLE",),
|
||||
synonyms=(
|
||||
"new leader style",
|
||||
"multileader style",
|
||||
"leader arrow size and landing",
|
||||
"iso leader style",
|
||||
),
|
||||
),
|
||||
"mleaderstyle_list": ToolAliases(
|
||||
acad=("MLEADERSTYLE",),
|
||||
synonyms=("list leader styles", "multileader styles in the drawing", "mleader styles"),
|
||||
),
|
||||
"textstyle_create": ToolAliases(
|
||||
acad=("STYLE",),
|
||||
synonyms=(
|
||||
"new text style",
|
||||
"create a text style",
|
||||
"isocp font",
|
||||
"set the font",
|
||||
"text style with width factor and oblique angle",
|
||||
),
|
||||
),
|
||||
"textstyle_list": ToolAliases(
|
||||
acad=("STYLE",),
|
||||
synonyms=(
|
||||
"list text styles",
|
||||
"fonts loaded in the drawing",
|
||||
"text style table",
|
||||
"current text style",
|
||||
),
|
||||
),
|
||||
"textstyle_set_current": ToolAliases(
|
||||
acad=("STYLE", "TEXTSTYLE"),
|
||||
synonyms=(
|
||||
"make this text style current",
|
||||
"switch the text style",
|
||||
"use isocp for new text",
|
||||
"active text style",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3351,7 +3351,7 @@ Part of `docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`. Its
|
||||
- Test: `tests/test_standards_presets.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`.
|
||||
- Produces: `PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}`, `PRESET_VARIABLES` (the 17, spec order), `DIM_VARIABLE_WHITELIST: frozenset` (17 + 16 = 33), `DIM_VARIABLE_RANGES: dict[str, tuple | set]`, `resolve_dimstyle(preset, overrides) -> dict`, `validate_overrides(overrides) -> dict`, `check_dim_value(name, value)`, `describe_preset(name) -> dict`; the arrowhead vocabulary (added by the Task 9 review fixes and consumed by Task 10's helpers below): `ARROWHEAD_BLOCKS: frozenset` (AutoCAD's nineteen named built-ins, `_OBLIQUE` spelling, pinned equal to ezdxf's `ARROWS.__acad__`), `canonical_arrowhead(key, value) -> str` (what `check_dim_value` returns for `DIMBLK`/`DIMBLK1`/`DIMBLK2`: `""` for closed filled and its aliases `.`/`CLOSEDFILLED`/`_CLOSEDFILLED`, a built-in in any case with or without its underscore → its block name, anything else a user block name under the symbol-name rule — whether that block *exists* is the backend's check against `doc.blocks`, before any write), `ezdxf_arrowhead(canonical) -> str` (the one translation the headless **writer** needs: `_OBLIQUE` → `OBLIQUE`, user blocks and `""` unchanged — ezdxf raises `DXFValueError: Block "_OBLIQUE" does not exist` at `doc.write` and `DXFUndefinedBlockError` at render if the canonical spelling is stored verbatim), `reported_arrowhead(raw) -> str` (the **read** side, never raises: ezdxf reports `OBLIQUE` in-session and `_OBLIQUE` after a reload, COM reports `_OBLIQUE`; both come back as the canonical name, a foreign name passes through); `TEXT_PRESETS`, `resolve_font(font) -> (font_file, known)`, `validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict`; `MLEADER_PRESETS`, `MLEADER_KEYS`, `resolve_mleaderstyle(preset, overrides) -> dict`.
|
||||
- Every refusal is `ValueError` (wrong type: `TypeError`) naming the key, and nothing in this package writes anything — the backends call these before they touch a table.
|
||||
- Groups C and P add `sysvars.py`, `papers.py`, `pdfinfo.py`, `templates.py`, `plot.py` to the same package and import them by path; this `__init__.py` re-exports only group S's names, so those groups never need to edit it.
|
||||
|
||||
@@ -3821,7 +3821,9 @@ LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_T
|
||||
#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted).
|
||||
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
|
||||
#: * ``("char",)`` — exactly one character.
|
||||
#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow).
|
||||
#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of ``ARROWHEAD_BLOCKS``
|
||||
#: given with or without its underscore in any case, or a user block name under the
|
||||
#: symbol-name rule; ``check_dim_value`` returns ``canonical_arrowhead(key, value)``.
|
||||
#: * ``("name",)`` — a non-empty DXF symbol-table name.
|
||||
#: * a ``set`` — one of its members.
|
||||
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
@@ -3838,15 +3840,20 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
"DIMDSEP": ("char",),
|
||||
"DIMLUNIT": ("int", 1, 6),
|
||||
"DIMZIN": ("int", 0, 15), # a bitmask
|
||||
"DIMBLK": ("str",),
|
||||
"DIMBLK": ("arrow",),
|
||||
"DIMTXSTY": ("name",),
|
||||
"DIMLWD": LINEWEIGHT_CODES,
|
||||
"DIMLWE": LINEWEIGHT_CODES,
|
||||
"DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport"
|
||||
# tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist
|
||||
"DIMTOL": {0, 1},
|
||||
"DIMTP": ("float", 0.0, 1e6),
|
||||
"DIMTM": ("float", 0.0, 1e6),
|
||||
# Both signed, as AutoCAD documents them: the displayed lower deviation is
|
||||
# -DIMTM, so a double-positive fit (ISO 286 p6, +0.035/+0.022) stores
|
||||
# DIMTM -0.022 and a double-negative one (f7, -0.020/-0.041) stores DIMTP
|
||||
# -0.020 — exactly what engineering/tolerances.py::build_dim_override emits
|
||||
# per dimension; a style must be able to hold the same numbers.
|
||||
"DIMTP": ("float", -1e6, 1e6),
|
||||
"DIMTM": ("float", -1e6, 1e6),
|
||||
"DIMTOLJ": ("int", 0, 2),
|
||||
"DIMTFAC": ("float", 1e-9, 1e6),
|
||||
"DIMLFAC": ("nonzero_float", -1e6, 1e6),
|
||||
@@ -3857,8 +3864,8 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
"DIMCLRE": ("int", 0, 256),
|
||||
"DIMCLRT": ("int", 0, 256),
|
||||
"DIMSAH": {0, 1},
|
||||
"DIMBLK1": ("str",),
|
||||
"DIMBLK2": ("str",),
|
||||
"DIMBLK1": ("arrow",),
|
||||
"DIMBLK2": ("arrow",),
|
||||
"DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none
|
||||
}
|
||||
|
||||
@@ -4224,7 +4231,7 @@ git commit -m "feat(standards): ISO-25 and ANSI dimension, text and leader style
|
||||
- Test: `tests/test_styles.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9); `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM).
|
||||
- Consumes: `resolve_dimstyle`, `validate_overrides`, `PRESET_VARIABLES`, `DIM_VARIABLE_WHITELIST`, `ARROWHEAD_BLOCKS`, `ezdxf_arrowhead`, `reported_arrowhead`, `TEXT_PRESETS`, `validate_textstyle`, `MLEADER_KEYS` (Task 9) — `validate_overrides` already returns `DIMBLK`/`DIMBLK1`/`DIMBLK2` in the canonical `_OBLIQUE` spelling, which ActiveX takes verbatim but ezdxf's writer refuses, so the headless helpers translate on write (`ezdxf_arrowhead`), canonicalise on read (`reported_arrowhead`, both engines) and refuse a user block absent from `doc.blocks` before any write; `sanitize_symbol_name` (`security.py`) — note it raises `ToolError`, not `ValueError`, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the `ValueError`/`TypeError` rule); `_async`/`_require_doc`/`_mark_dirty` (ezdxf), `_run`/`_acad_doc`/`_acad_app`/`_regen`/`deg2rad`/`rad2deg` (COM).
|
||||
- Produces: the nine `StylesContract` methods on both backends, exactly the plan's signatures. Extra keys beyond the plan's row shapes, on both engines: `dimstyle_list` rows carry `values_available: bool`; `dimstyle_create` carries `written: [vars]`; `dimstyle_set_current` / `textstyle_set_current` carry `changed: bool`; `mleaderstyle_create` carries `textstyle_created`. Names are matched case-insensitively (AutoCAD's rule) and reported in the table's own spelling.
|
||||
- The COM rule, stated once in the code and enforced by the fake: `AcadDimStyle` has `Name` and `CopyFrom` only. Values are written by setting the `DIM*` document variables and `CopyFrom(document)`; making a style current (`ActiveDimStyle`) restores its saved settings and drops unsaved overrides; reading a non-current style's values is impossible, so `dimstyle_list` reports `values` for the current style only. `mleaderstyle_create` stays the `@capability("mleaderstyle")` default on COM (never defined there); `mleaderstyle_list` reads names from the `ACAD_MLEADERSTYLE` dictionary with `values_available: false`.
|
||||
|
||||
@@ -4368,6 +4375,46 @@ async def test_refusals_happen_before_anything_is_written(backend):
|
||||
assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2
|
||||
|
||||
|
||||
async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path):
|
||||
"""`validate_overrides` hands the canonical `_OBLIQUE`; stored verbatim ezdxf
|
||||
raises at render and at `doc.write`. The witness is the rendered INSERT and
|
||||
a save/reopen that reports the same name before and after."""
|
||||
values = resolve_dimstyle("iso-25", {"DIMBLK": "oblique", "DIMSAH": 1, "DIMBLK1": "_DOT",
|
||||
"DIMBLK2": "."})
|
||||
result = await backend.dimstyle_create("ISO-25", values, set_current=True)
|
||||
assert result["values"]["DIMBLK"] == "_OBLIQUE"
|
||||
style = backend._doc.dimstyles.get("ISO-25")
|
||||
assert style.dxf.dimblk == "OBLIQUE" and style.dxf.dimblk1 == "DOT" and style.dxf.dimblk2 == ""
|
||||
dim = await backend.dimension_linear(0, 0, 20, 0, 10, -10)
|
||||
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
|
||||
assert {e.dxf.name for e in block if e.dxftype() == "INSERT"} == {"_DOT", "_CLOSEDFILLED"}, (
|
||||
"DIMSAH on: the two separate arrowheads win over DIMBLK"
|
||||
)
|
||||
again = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "OBLIQUE", "DIMBLK1": "_dot"})
|
||||
assert again["changed"] == {}, "re-setting a value to itself is not a change (spec §8.3)"
|
||||
path = str(tmp_path / "arrows.dxf")
|
||||
await backend.drawing_save_as(path)
|
||||
await backend.drawing_open(path)
|
||||
rows = {r["name"]: r for r in await backend.dimstyle_list()}
|
||||
assert rows["ISO-25"]["values"]["DIMBLK"] == "_OBLIQUE", "reloaded, the same spelling"
|
||||
reopened = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "oblique"})
|
||||
assert reopened["changed"] == {}
|
||||
|
||||
|
||||
async def test_user_arrowhead_block_must_exist_before_anything_is_written(backend):
|
||||
with pytest.raises(ValueError, match="DIMBLK: block 'MYARROW' does not exist"):
|
||||
await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMBLK": "MYARROW"}))
|
||||
assert not backend._doc.dimstyles.has_entry("X")
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
with pytest.raises(ValueError, match="DIMBLK1: block 'MYARROW' does not exist"):
|
||||
await backend.dimstyle_modify("ISO-25", {"DIMSAH": 1, "DIMBLK1": "MYARROW"})
|
||||
assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimsah"), "nothing written"
|
||||
backend._doc.blocks.new("MYARROW").add_line((0, 0), (1, 1))
|
||||
result = await backend.dimstyle_modify("ISO-25", {"DIMBLK": "myarrow"})
|
||||
assert result["changed"] == {"DIMBLK": ["", "MYARROW"]}, "the block table's own spelling"
|
||||
assert backend._doc.dimstyles.get("ISO-25").dxf.dimblk == "MYARROW"
|
||||
|
||||
|
||||
async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path):
|
||||
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||
result = await backend.dimstyle_set_current("iso-25")
|
||||
@@ -5019,8 +5066,14 @@ def _dimstyle_schema_default(var: str):
|
||||
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)."""
|
||||
"""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:
|
||||
@@ -5030,6 +5083,12 @@ def _dimstyle_value(style, var: str):
|
||||
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
|
||||
|
||||
|
||||
@@ -5039,19 +5098,58 @@ def _read_dimstyle_values(style, variables=None) -> dict[str, Any]:
|
||||
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
|
||||
style.dxf.set(attr, ord(value) if var == "DIMDSEP" else value)
|
||||
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:
|
||||
@@ -5179,6 +5277,7 @@ Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly
|
||||
"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"
|
||||
)
|
||||
@@ -5225,6 +5324,7 @@ Insert immediately before ` # ── corner ops` in `EzdxfBackend` (directly
|
||||
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))
|
||||
@@ -5458,7 +5558,14 @@ def _com_dimvar_for_report(var: str, raw: Any) -> Any:
|
||||
return "," if raw == 0 else chr(raw)
|
||||
text = str(raw)
|
||||
return text[:1] if text else ","
|
||||
if var in ("DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"):
|
||||
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
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ name.
|
||||
- `dimstyle_modify(name, overrides)` — `changed` reports only variables that actually moved (the `hatch_edit` rule). Dimensions already using the style are listed in `dimensions_using_style` with `rerender_required: true` on the headless engine: AutoCAD re-renders them on the next regen, but a headless DIMENSION carries its rendered block, so the drawing shows the old style until the dimension is redrawn — stated rather than silent.
|
||||
- `dimstyle_set_current(name)` — `$DIMSTYLE`; the dimension tools then use it (the existing per-dimension override path stays as is).
|
||||
- `textstyle_list()`, `textstyle_create(name, font: "isocp.shx"|"isocpeur.ttf"|"arial.ttf"|"romans.shx"|<file>, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False)`, `textstyle_set_current(name)` — `$TEXTSTYLE`; a font file that is neither a bundled preset nor present in the engine's font path is written but reported `font_resolved: false` (AutoCAD substitutes at open; the headless renderer falls back), never refused, because DXF only stores the name.
|
||||
- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height on ezdxf's `doc.mleader_styles`. ActiveX exposes no MLeaderStyle collection, so the COM engine refuses both with `capability: "mleaderstyle"` (the reason names `leader_create_mleader`'s existing per-leader parameters as the route); `mleaderstyle_list` on COM still reads the names from the `ACAD_MLEADERSTYLE` dictionary.
|
||||
- `mleaderstyle_list()`, `mleaderstyle_create(name, preset: "iso"|"ansi", overrides)` — arrow size, landing gap, text style, text height, both engines: ezdxf on `doc.mleader_styles`; COM through the `ACAD_MLEADERSTYLE` dictionary, whose items are `IAcadMLeaderStyle` objects (`ArrowSize` / `LandingGap` / `TextHeight` in drawing units, `TextStyle` a name — verified against the AutoCAD 2026 type library), created with `Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")` followed by the property writes. ActiveX has no `MLeaderStyles` *collection* property, which is not an engine boundary — no `mleaderstyle` capability key exists.
|
||||
- `drawing_apply_standard(standard: "iso"|"ansi", layers: bool = True, units: bool = True)` — one call: the dimstyle preset (created and set current), its text style, `drawing_settings` (units mm, decimal linear and angular units, LTSCALE 1, DIMSCALE 1; the decimal separator comes from the preset) and, with `layers`, the `mech` layer set for both standards (ANSI layer naming is company-specific; the set reuses the mech names with ISO 128 lineweights — only styles and the separator differ between the two). Returns, per item, whether it was created or was already present.
|
||||
|
||||
## 5. Settings (SECTION 20 shares these; 3 tools + facade growth)
|
||||
@@ -187,9 +187,10 @@ A state is the layer table snapshot `{layer: {on, frozen, locked, color, linetyp
|
||||
|
||||
### 8.1 Capability keys (added to **both** maps)
|
||||
|
||||
`mleaderstyle` (COM false), `dwgprops` (ezdxf false: summary fields), `dwt_write`
|
||||
(ezdxf false), `live_application`, `preferences`, `interactive_prompt` (ezdxf
|
||||
false). `tests/test_capability_contract.py` enforces both maps.
|
||||
`dwgprops` (ezdxf false: summary fields), `dwt_write` (ezdxf false),
|
||||
`live_application`, `preferences`, `interactive_prompt` (ezdxf false).
|
||||
`tests/test_capability_contract.py` enforces both maps. (`mleaderstyle` was
|
||||
listed here as COM false; the ActiveX route exists — §4.2 — so there is no key.)
|
||||
|
||||
### 8.2 Packs, profiles, discovery
|
||||
|
||||
@@ -219,7 +220,9 @@ shop-floor synonyms; the discovery golden set gains ≥ 10 queries for them
|
||||
(A0 841×1189 … A4 210×297; ANSI A 216×279 … E 864×1118), every catalog
|
||||
sysvar has type+meaning+engines.
|
||||
- Dual-engine tests for every tool (ezdxf real; COM fake-ActiveX asserting call
|
||||
shapes: `DimStyles.Add`, `TextStyles.Add`, `Layout.ConfigName/…`,
|
||||
shapes: `DimStyles.Add`, `TextStyles.Add`,
|
||||
`Dictionaries.Item("ACAD_MLEADERSTYLE").AddObject(name, "AcDbMLeaderStyle")`,
|
||||
`Layout.ConfigName/…`,
|
||||
`Documents.Item().Activate`, `Dictionaries.Add`+`AddXRecord`, `Views.Add`,
|
||||
`UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`,
|
||||
`Utility.GetPoint`).
|
||||
|
||||
+143
-2
@@ -3,10 +3,10 @@
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 166,
|
||||
"tools": 176,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 20
|
||||
"groups": 21
|
||||
},
|
||||
"groups": {
|
||||
"analysis": 12,
|
||||
@@ -24,6 +24,7 @@
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
"solids": 5,
|
||||
"styles": 10,
|
||||
"system": 7,
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
@@ -450,6 +451,62 @@
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dimstyle_create",
|
||||
"group": "styles",
|
||||
"cost": "mutate",
|
||||
"summary": "Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.",
|
||||
"tags": [
|
||||
"create",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: Create Dimension Style",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dimstyle_list",
|
||||
"group": "styles",
|
||||
"cost": "read",
|
||||
"summary": "List dimension styles with their ISO-25/ANSI variables and the current one.",
|
||||
"tags": [
|
||||
"query",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: List Dimension Styles",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dimstyle_modify",
|
||||
"group": "styles",
|
||||
"cost": "mutate",
|
||||
"summary": "Change DIM* variables on an existing dimension style; reports only what moved.",
|
||||
"tags": [
|
||||
"modify",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: Modify Dimension Style",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dimstyle_set_current",
|
||||
"group": "styles",
|
||||
"cost": "safe",
|
||||
"summary": "Make a dimension style current so new dimensions use it.",
|
||||
"tags": [
|
||||
"modify",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: Set Current Dimension Style",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_apply_iso_layers",
|
||||
"group": "premium",
|
||||
@@ -464,6 +521,20 @@
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_apply_standard",
|
||||
"group": "styles",
|
||||
"cost": "mutate",
|
||||
"summary": "Put the drawing on ISO or ANSI in one call: dimstyle, text style, units, mech layers.",
|
||||
"tags": [
|
||||
"drawing",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Drawing: Apply Drafting Standard",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_audit",
|
||||
"group": "drawing",
|
||||
@@ -1717,6 +1788,34 @@
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mleaderstyle_create",
|
||||
"group": "styles",
|
||||
"cost": "mutate",
|
||||
"summary": "Create a multileader style from the ISO or ANSI preset, on both engines.",
|
||||
"tags": [
|
||||
"create",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: Create Multileader Style",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mleaderstyle_list",
|
||||
"group": "styles",
|
||||
"cost": "read",
|
||||
"summary": "List multileader styles: arrow size, landing gap, text style and height.",
|
||||
"tags": [
|
||||
"query",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: List Multileader Styles",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pid_equipment_list",
|
||||
"group": "pid",
|
||||
@@ -2155,6 +2254,48 @@
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "textstyle_create",
|
||||
"group": "styles",
|
||||
"cost": "mutate",
|
||||
"summary": "Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.",
|
||||
"tags": [
|
||||
"create",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: Create Text Style",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "textstyle_list",
|
||||
"group": "styles",
|
||||
"cost": "read",
|
||||
"summary": "List text styles: font file, height, width factor, oblique angle.",
|
||||
"tags": [
|
||||
"query",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: List Text Styles",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "textstyle_set_current",
|
||||
"group": "styles",
|
||||
"cost": "safe",
|
||||
"summary": "Make a text style current so new text uses it.",
|
||||
"tags": [
|
||||
"modify",
|
||||
"style"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Styles: Set Current Text Style",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "titleblock_apply_iso_a3",
|
||||
"group": "engineering",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Authored drafting standards data.
|
||||
|
||||
Dimension, text and leader style presets live here (track E, group S); the
|
||||
system-variable catalogue, paper sizes, plot styles and templates are sibling
|
||||
modules added by their own groups and imported by path. Everything in this
|
||||
package is data plus validation — no I/O, no ezdxf, no COM — so the backends,
|
||||
the tools and the provenance tests read one copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engineering.standards.dimstyles import (
|
||||
ARROWHEAD_BLOCKS,
|
||||
DIM_VARIABLE_RANGES,
|
||||
DIM_VARIABLE_WHITELIST,
|
||||
PRESET_VARIABLES,
|
||||
PRESETS,
|
||||
canonical_arrowhead,
|
||||
check_dim_value,
|
||||
describe_preset,
|
||||
ezdxf_arrowhead,
|
||||
reported_arrowhead,
|
||||
resolve_dimstyle,
|
||||
validate_overrides,
|
||||
)
|
||||
from engineering.standards.mleaderstyles import (
|
||||
MLEADER_PRESETS,
|
||||
resolve_mleaderstyle,
|
||||
validate_mleaderstyle,
|
||||
)
|
||||
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
|
||||
|
||||
__all__ = [
|
||||
"ARROWHEAD_BLOCKS",
|
||||
"DIM_VARIABLE_RANGES",
|
||||
"DIM_VARIABLE_WHITELIST",
|
||||
"MLEADER_PRESETS",
|
||||
"PRESETS",
|
||||
"PRESET_VARIABLES",
|
||||
"TEXT_PRESETS",
|
||||
"canonical_arrowhead",
|
||||
"check_dim_value",
|
||||
"describe_preset",
|
||||
"ezdxf_arrowhead",
|
||||
"reported_arrowhead",
|
||||
"resolve_dimstyle",
|
||||
"resolve_font",
|
||||
"resolve_mleaderstyle",
|
||||
"validate_mleaderstyle",
|
||||
"validate_overrides",
|
||||
"validate_textstyle",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""One call to put a drawing on a drafting standard: styles, units, layers.
|
||||
|
||||
Composes only backend methods that already exist (``textstyle_*``,
|
||||
``dimstyle_*``, ``system_get/set_variable``, ``drawing_apply_iso_layers``),
|
||||
so it runs on both engines and needs nothing from the settings facade. Units
|
||||
are the five system variables both standards share; the decimal separator is
|
||||
the preset's and arrives through the dimension style. ANSI layer naming is
|
||||
company-specific, so both standards use the ``mech`` layer set — only styles
|
||||
and the separator differ (spec §4.2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from engineering.standards.dimstyles import resolve_dimstyle
|
||||
from engineering.standards.textstyles import TEXT_PRESETS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backends.base import AutoCADBackend
|
||||
|
||||
__all__ = ["STANDARDS", "STANDARD_SYSVARS", "apply_standard", "resolve_standard"]
|
||||
|
||||
STANDARDS: dict[str, dict[str, str]] = {
|
||||
"iso": {"preset": "iso-25", "dimstyle": "ISO-25", "textstyle": "ISOCP", "layer_set": "mech"},
|
||||
"ansi": {"preset": "ansi", "dimstyle": "ANSI", "textstyle": "ROMANS", "layer_set": "mech"},
|
||||
}
|
||||
|
||||
#: Millimetres, decimal linear and angular units, 1:1 linetype and dimension
|
||||
#: scale. Written by name so the report can say what moved.
|
||||
STANDARD_SYSVARS: tuple[tuple[str, Any], ...] = (
|
||||
("INSUNITS", 4),
|
||||
("LUNITS", 2),
|
||||
("AUNITS", 0),
|
||||
("LTSCALE", 1.0),
|
||||
("DIMSCALE", 1.0),
|
||||
)
|
||||
|
||||
|
||||
def resolve_standard(standard: str) -> tuple[str, dict[str, str]]:
|
||||
"""``(key, row)`` for ``iso`` / ``ansi`` (any case), else ``ValueError``."""
|
||||
key = str(standard).strip().lower() if standard is not None else ""
|
||||
if key not in STANDARDS:
|
||||
raise ValueError(f"standard: unknown value {standard!r}; use one of {sorted(STANDARDS)}")
|
||||
return key, STANDARDS[key]
|
||||
|
||||
|
||||
def _same(old: Any, new: Any) -> bool:
|
||||
if old is None:
|
||||
return False
|
||||
try:
|
||||
return abs(float(old) - float(new)) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return str(old) == str(new)
|
||||
|
||||
|
||||
async def apply_standard(
|
||||
backend: AutoCADBackend, standard: str, layers: bool = True, units: bool = True
|
||||
) -> dict:
|
||||
"""Styles created (or reused) and made current, units written, layers bootstrapped.
|
||||
|
||||
Every item reports whether it was created or already present; ``settings``
|
||||
reports only the variables that moved. An existing style of the standard's
|
||||
name is used as it is — ``dimstyle_modify`` changes one on purpose.
|
||||
"""
|
||||
key, row = resolve_standard(standard)
|
||||
if not isinstance(layers, bool) or not isinstance(units, bool):
|
||||
raise TypeError("layers and units must be booleans")
|
||||
|
||||
text_names = {entry["name"].lower() for entry in await backend.textstyle_list()}
|
||||
textstyle_created = row["textstyle"].lower() not in text_names
|
||||
if textstyle_created:
|
||||
font_file, width, oblique = TEXT_PRESETS[row["textstyle"]]
|
||||
await backend.textstyle_create(row["textstyle"], font_file, 0.0, width, oblique, False)
|
||||
await backend.textstyle_set_current(row["textstyle"])
|
||||
|
||||
dim_names = {entry["name"].lower() for entry in await backend.dimstyle_list()}
|
||||
dimstyle_created = row["dimstyle"].lower() not in dim_names
|
||||
if dimstyle_created:
|
||||
await backend.dimstyle_create(
|
||||
row["dimstyle"], resolve_dimstyle(row["preset"], None), set_current=True
|
||||
)
|
||||
else:
|
||||
await backend.dimstyle_set_current(row["dimstyle"])
|
||||
|
||||
changed: dict[str, list] = {}
|
||||
if units:
|
||||
for var, value in STANDARD_SYSVARS:
|
||||
old = await backend.system_get_variable(var)
|
||||
if _same(old, value):
|
||||
continue
|
||||
await backend.system_set_variable(var, value)
|
||||
changed[var] = [old, value]
|
||||
|
||||
layer_result = None
|
||||
if layers:
|
||||
applied = await backend.drawing_apply_iso_layers(row["layer_set"])
|
||||
layer_result = {"layer_set": row["layer_set"], "layers": applied["layers"]}
|
||||
|
||||
return {
|
||||
"standard": key,
|
||||
"dimstyle": {"name": row["dimstyle"], "created": dimstyle_created, "current": True},
|
||||
"textstyle": {"name": row["textstyle"], "created": textstyle_created, "current": True},
|
||||
"settings": {"changed": changed, "applied": units},
|
||||
"layers": layer_result,
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"""ISO-25 and ANSI dimension style presets as data.
|
||||
|
||||
Every value in ``PRESETS`` is pinned by name in
|
||||
``tests/test_standards_presets.py`` against the table in §4.1 of
|
||||
``docs/superpowers/specs/2026-09-16-v1.6-settings-design.md``:
|
||||
|
||||
* ``iso-25`` — ISO 129-1:2018 dimensioning at the 2.5 mm lettering row of
|
||||
ISO 3098-1: 2.5 mm text and arrowheads, text above and aligned with the
|
||||
dimension line, decimal comma (ISO 80000-1 allows either marker; AutoCAD's
|
||||
own ISO-25 uses the comma).
|
||||
* ``ansi`` — ASME Y14.2-2014 line conventions and lettering (3 mm / .12 in
|
||||
lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads,
|
||||
text centred in a gap and horizontal, decimal point.
|
||||
|
||||
``DIMDSEP`` is a one-character *string* here. DXF stores the character code
|
||||
(group 278, a 16-bit value) and ezdxf raises on a string in the header, so the
|
||||
translation belongs to the backends, not to the data — but the data is where
|
||||
the refusal starts: a control character (``\\n`` splits the dimension text over
|
||||
two lines, NUL reads back as a comma) or a code point above ``0x7FFF``
|
||||
(``ezdxf`` writes it, AutoCAD refuses it as "not a single character") is
|
||||
refused here, before ``ord()`` runs in a backend.
|
||||
|
||||
Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the
|
||||
tools and the provenance tests all read one copy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = [
|
||||
"ANSI",
|
||||
"ARROWHEAD_BLOCKS",
|
||||
"DIM_VARIABLE_RANGES",
|
||||
"DIM_VARIABLE_WHITELIST",
|
||||
"ISO_25",
|
||||
"LINEWEIGHT_CODES",
|
||||
"PRESETS",
|
||||
"PRESET_SOURCES",
|
||||
"PRESET_VARIABLES",
|
||||
"canonical_arrowhead",
|
||||
"check_dim_value",
|
||||
"describe_preset",
|
||||
"ezdxf_arrowhead",
|
||||
"reported_arrowhead",
|
||||
"resolve_dimstyle",
|
||||
"validate_overrides",
|
||||
]
|
||||
|
||||
#: The seventeen variables every preset sets, in the order the spec table
|
||||
#: lists them. ``dimstyle_list`` reports exactly these.
|
||||
PRESET_VARIABLES: tuple[str, ...] = (
|
||||
"DIMTXT",
|
||||
"DIMASZ",
|
||||
"DIMEXE",
|
||||
"DIMEXO",
|
||||
"DIMGAP",
|
||||
"DIMTAD",
|
||||
"DIMTIH",
|
||||
"DIMTOH",
|
||||
"DIMDEC",
|
||||
"DIMDSEP",
|
||||
"DIMLUNIT",
|
||||
"DIMZIN",
|
||||
"DIMBLK",
|
||||
"DIMTXSTY",
|
||||
"DIMLWD",
|
||||
"DIMLWE",
|
||||
"DIMSCALE",
|
||||
)
|
||||
|
||||
ISO_25: dict[str, Any] = {
|
||||
"DIMTXT": 2.5, # text height, mm — ISO 3098-1 lettering row 2.5
|
||||
"DIMASZ": 2.5, # arrowhead length, matched to the text
|
||||
"DIMEXE": 1.25, # extension line beyond the dimension line
|
||||
"DIMEXO": 0.625, # extension line offset from the feature
|
||||
"DIMGAP": 0.625, # gap between dimension line and text
|
||||
"DIMTAD": 1, # text above the dimension line
|
||||
"DIMTIH": 0, # inside text aligned with the dimension line
|
||||
"DIMTOH": 0, # outside text aligned with the dimension line
|
||||
"DIMDEC": 2, # linear decimals
|
||||
"DIMDSEP": ",", # decimal comma
|
||||
"DIMLUNIT": 2, # decimal linear units
|
||||
"DIMZIN": 8, # trailing zeros suppressed
|
||||
"DIMBLK": "", # closed filled arrowhead
|
||||
"DIMTXSTY": "ISOCP", # ISO 3098 type B lettering (created if missing)
|
||||
"DIMLWD": -2, # dimension line lineweight ByBlock
|
||||
"DIMLWE": -2, # extension line lineweight ByBlock
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
ANSI: dict[str, Any] = {
|
||||
"DIMTXT": 3.0, # 3 mm / .12 in lettering, ASME Y14.2-2014
|
||||
"DIMASZ": 3.0, # .125 in arrowheads, metric
|
||||
"DIMEXE": 1.5,
|
||||
"DIMEXO": 1.5,
|
||||
"DIMGAP": 1.0,
|
||||
"DIMTAD": 0, # text centred in a gap in the dimension line
|
||||
"DIMTIH": 1, # inside text horizontal
|
||||
"DIMTOH": 1, # outside text horizontal
|
||||
"DIMDEC": 2,
|
||||
"DIMDSEP": ".", # decimal point
|
||||
"DIMLUNIT": 2,
|
||||
"DIMZIN": 8,
|
||||
"DIMBLK": "",
|
||||
"DIMTXSTY": "ROMANS", # AutoCAD's simplex roman, the ASME Gothic look
|
||||
"DIMLWD": -2,
|
||||
"DIMLWE": -2,
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
PRESETS: dict[str, dict[str, Any]] = {"iso-25": ISO_25, "ansi": ANSI}
|
||||
|
||||
PRESET_SOURCES: dict[str, str] = {
|
||||
"iso-25": (
|
||||
"ISO 129-1:2018 (dimensioning) with the 2.5 mm lettering row of "
|
||||
"ISO 3098-1 (text and arrow sizes); decimal comma per ISO 80000-1"
|
||||
),
|
||||
"ansi": (
|
||||
"ASME Y14.2-2014 line conventions and lettering: 3 mm / .12 in lettering, "
|
||||
".125 in arrowheads, metric values"
|
||||
),
|
||||
}
|
||||
|
||||
#: The lineweights AutoCAD accepts, in hundredths of a millimetre, plus the
|
||||
#: ByLayer / ByBlock / Default sentinels. Every ISO 128 weight is in here.
|
||||
_LINEWEIGHT_TEXT = (
|
||||
"-3 -2 -1 0 5 9 13 15 18 20 25 30 35 40 50 53 60 70 80 90 100 106 120 140 158 200 211"
|
||||
)
|
||||
LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split())
|
||||
|
||||
#: AutoCAD's nineteen named built-in arrowheads, spelled the way the DIMBLK
|
||||
#: documentation, the ``$DIMBLK`` header, ActiveX and a loaded ezdxf DIMSTYLE
|
||||
#: all spell them: the block name, underscore first. The twentieth, closed
|
||||
#: filled, has no block name and is ``""`` (the spec §4.1 default).
|
||||
#: ``tests/test_standards_presets.py`` pins this set equal to ezdxf's
|
||||
#: ``ARROWS.__acad__`` so the two vocabularies cannot drift apart unseen.
|
||||
ARROWHEAD_BLOCKS: frozenset[str] = frozenset(
|
||||
"_ARCHTICK _BOXBLANK _BOXFILLED _CLOSED _CLOSEDBLANK _DATUMBLANK _DATUMFILLED "
|
||||
"_DOT _DOTBLANK _DOTSMALL _INTEGRAL _NONE _OBLIQUE _OPEN _OPEN30 _OPEN90 "
|
||||
"_ORIGIN _ORIGIN2 _SMALL".split()
|
||||
)
|
||||
|
||||
#: The spellings of the closed filled default a caller may send: AutoCAD's own
|
||||
#: "enter a single period to return to closed filled", and the block name
|
||||
#: ezdxf gives the arrow it draws for it.
|
||||
_CLOSED_FILLED_ALIASES = frozenset({".", "CLOSEDFILLED", "_CLOSEDFILLED"})
|
||||
|
||||
#: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each
|
||||
#: value must sit in. The shape of each entry:
|
||||
#:
|
||||
#: * ``("float", low, high)`` — a number (bool refused) within the inclusive
|
||||
#: bounds; a ``low`` of ``1e-9`` is the repo's spelling of "strictly positive"
|
||||
#: (``backends/contracts/settings.py::_SETTING_RANGES``).
|
||||
#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted).
|
||||
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
|
||||
#: * ``("char",)`` — exactly one character.
|
||||
#: * ``("arrow",)`` — an arrowhead: ``""`` (closed filled), one of
|
||||
#: :data:`ARROWHEAD_BLOCKS` given with or without its underscore in any case,
|
||||
#: or a user block name under the symbol-name rule; see :func:`canonical_arrowhead`.
|
||||
#: * ``("name",)`` — a non-empty DXF symbol-table name.
|
||||
#: * a ``set`` — one of its members.
|
||||
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||
# the seventeen the presets set
|
||||
"DIMTXT": ("float", 1e-9, 1e6),
|
||||
"DIMASZ": ("float", 1e-9, 1e6),
|
||||
"DIMEXE": ("float", 0.0, 1e6),
|
||||
"DIMEXO": ("float", 0.0, 1e6),
|
||||
"DIMGAP": ("float", -1e6, 1e6), # negative draws the basic-dimension box
|
||||
"DIMTAD": ("int", 0, 4),
|
||||
"DIMTIH": {0, 1},
|
||||
"DIMTOH": {0, 1},
|
||||
"DIMDEC": ("int", 0, 8),
|
||||
"DIMDSEP": ("char",),
|
||||
"DIMLUNIT": ("int", 1, 6),
|
||||
"DIMZIN": ("int", 0, 15), # a bitmask
|
||||
"DIMBLK": ("arrow",),
|
||||
"DIMTXSTY": ("name",),
|
||||
"DIMLWD": LINEWEIGHT_CODES,
|
||||
"DIMLWE": LINEWEIGHT_CODES,
|
||||
"DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport"
|
||||
# tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist
|
||||
"DIMTOL": {0, 1},
|
||||
# Both signed, as AutoCAD documents them: the displayed lower deviation is
|
||||
# -DIMTM, so a double-positive fit (ISO 286 p6, +0.035/+0.022) stores
|
||||
# DIMTM -0.022 and a double-negative one (f7, -0.020/-0.041) stores DIMTP
|
||||
# -0.020 — exactly what engineering/tolerances.py::build_dim_override emits
|
||||
# per dimension; a style must be able to hold the same numbers.
|
||||
"DIMTP": ("float", -1e6, 1e6),
|
||||
"DIMTM": ("float", -1e6, 1e6),
|
||||
"DIMTOLJ": ("int", 0, 2),
|
||||
"DIMTFAC": ("float", 1e-9, 1e6),
|
||||
"DIMLFAC": ("nonzero_float", -1e6, 1e6),
|
||||
"DIMRND": ("float", 0.0, 1e6), # 0 = no rounding; the backends discard it, never store it
|
||||
"DIMATFIT": ("int", 0, 3),
|
||||
"DIMTMOVE": ("int", 0, 2),
|
||||
"DIMCLRD": ("int", 0, 256),
|
||||
"DIMCLRE": ("int", 0, 256),
|
||||
"DIMCLRT": ("int", 0, 256),
|
||||
"DIMSAH": {0, 1},
|
||||
"DIMBLK1": ("arrow",),
|
||||
"DIMBLK2": ("arrow",),
|
||||
"DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none
|
||||
}
|
||||
|
||||
DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES)
|
||||
|
||||
|
||||
def canonical_arrowhead(key: str, value: Any) -> str:
|
||||
"""``value`` as the arrowhead name DIMBLK stores, or ``TypeError`` /
|
||||
``ValueError`` naming ``key``.
|
||||
|
||||
``""``, ``"."``, ``CLOSEDFILLED`` and ``_CLOSEDFILLED`` are the closed
|
||||
filled default and come back as ``""``. A built-in is accepted with or
|
||||
without its underscore in any case (``oblique``, ``_Dot``) and comes back
|
||||
as its block name (``_OBLIQUE``, ``_DOT``) — AutoCAD's documented spelling,
|
||||
what ActiveX takes verbatim and what ezdxf reports after a load. Anything
|
||||
else is a user block name and must obey the symbol-name rule; whether the
|
||||
block exists is the backend's question, asked against the open drawing.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
if value == "":
|
||||
return ""
|
||||
text = value.strip()
|
||||
if text.upper() in _CLOSED_FILLED_ALIASES:
|
||||
return ""
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f'{key}: {value!r} is blank; use "" for the closed filled arrowhead or a block name'
|
||||
)
|
||||
upper = text.upper()
|
||||
candidate = upper if upper.startswith("_") else "_" + upper
|
||||
if candidate in ARROWHEAD_BLOCKS:
|
||||
return candidate
|
||||
return check_name(key, text, what="block name")
|
||||
|
||||
|
||||
def reported_arrowhead(raw: Any) -> str:
|
||||
"""A *stored* arrowhead name in the canonical spelling, never raising.
|
||||
|
||||
The read-side twin of :func:`canonical_arrowhead`: ezdxf reports the name
|
||||
the writer stored (``OBLIQUE``) in-session and the block name
|
||||
(``_OBLIQUE``) after a reload, so a raw read would spell one style two
|
||||
ways and make re-setting a value to itself look like a change (spec §8.3).
|
||||
A built-in in any spelling comes back as its block name and the closed
|
||||
filled aliases as ``""``; ``None`` is ``""``; anything else — a user block,
|
||||
or a foreign drawing's name the symbol-name rule would refuse — passes
|
||||
through unchanged, because a read must not raise.
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
text = str(raw)
|
||||
try:
|
||||
return canonical_arrowhead("DIMBLK", text)
|
||||
except (TypeError, ValueError):
|
||||
return text
|
||||
|
||||
|
||||
def ezdxf_arrowhead(canonical: str) -> str:
|
||||
"""The name ezdxf's DIMSTYLE takes for a :func:`canonical_arrowhead` value.
|
||||
|
||||
ezdxf's ``ARROWS`` vocabulary drops the underscore (``_DOT`` → ``DOT``,
|
||||
``""`` stays ``""``) and its export raises ``DXFValueError`` on ``_DOT``
|
||||
because no block of that name exists until it creates one from ``DOT``. A
|
||||
user block is passed through unchanged. The COM engine needs no mapping:
|
||||
AutoCAD takes the canonical spelling.
|
||||
"""
|
||||
if canonical in ARROWHEAD_BLOCKS:
|
||||
return canonical[1:]
|
||||
return canonical
|
||||
|
||||
|
||||
DIMDSEP_MAX_CODE = 0x7FFF
|
||||
"""The largest character code ``DIMDSEP`` accepts.
|
||||
|
||||
DXF group 278 is a 16-bit integer and the DWG field a signed short; AutoCAD
|
||||
refuses anything above it as "not a single character", while ezdxf would
|
||||
write it verbatim (measured: ``'\\U0001F600'`` reached the file as 128512).
|
||||
"""
|
||||
|
||||
|
||||
def _number(key: str, value: Any) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"{key}: {value!r} is not a finite number")
|
||||
return number
|
||||
|
||||
|
||||
def check_dim_value(name: str, value: Any) -> Any:
|
||||
"""``value`` coerced to what ``name`` stores, or ``TypeError``/``ValueError`` naming it.
|
||||
|
||||
Nothing here writes; the backends call this before touching a table.
|
||||
"""
|
||||
key = str(name).strip().upper()
|
||||
spec = DIM_VARIABLE_RANGES.get(key)
|
||||
if spec is None:
|
||||
raise ValueError(
|
||||
f"{name}: not a dimension variable this tool writes; allowed: "
|
||||
f"{sorted(DIM_VARIABLE_WHITELIST)}"
|
||||
)
|
||||
if isinstance(spec, (set, frozenset)):
|
||||
number = _number(key, value)
|
||||
if number != int(number) or int(number) not in spec:
|
||||
raise ValueError(f"{key}: {value!r} is not one of {sorted(spec)}")
|
||||
return int(number)
|
||||
kind = spec[0]
|
||||
if kind in ("float", "nonzero_float"):
|
||||
number = _number(key, value)
|
||||
low, high = spec[1], spec[2]
|
||||
if not low <= number <= high:
|
||||
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
|
||||
raise ValueError(
|
||||
f"{key}: {number:g} is out of range - must be {low_text} and at most {high:g}."
|
||||
)
|
||||
if kind == "nonzero_float" and number == 0.0:
|
||||
raise ValueError(f"{key}: 0 is refused (AutoCAD treats it as no factor at all)")
|
||||
return number
|
||||
if kind == "int":
|
||||
number = _number(key, value)
|
||||
if number != int(number):
|
||||
raise ValueError(f"{key}: {value!r} must be a whole number")
|
||||
low, high = spec[1], spec[2]
|
||||
if not low <= number <= high:
|
||||
raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}")
|
||||
return int(number)
|
||||
if kind == "arrow":
|
||||
return canonical_arrowhead(key, value)
|
||||
if kind == "name":
|
||||
return check_name(key, value, what="style name")
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
if len(value) != 1: # "char"
|
||||
raise ValueError(f"{key}: {value!r} must be exactly one character")
|
||||
code = ord(value)
|
||||
if code < 32 or code == 127:
|
||||
raise ValueError(
|
||||
f"{key}: {value!r} is a control character, which a DXF cannot hold as a separator"
|
||||
)
|
||||
if code > DIMDSEP_MAX_CODE:
|
||||
raise ValueError(
|
||||
f"{key}: {value!r} (U+{code:04X}) is outside the 16-bit character code DXF group "
|
||||
f"278 stores (at most U+{DIMDSEP_MAX_CODE:04X})"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def validate_overrides(overrides: dict | None) -> dict[str, Any]:
|
||||
"""``overrides`` as a typed, upper-cased dict — every key checked, nothing written."""
|
||||
if overrides is None:
|
||||
return {}
|
||||
if not isinstance(overrides, dict):
|
||||
raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}")
|
||||
typed: dict[str, Any] = {}
|
||||
for raw_key, value in overrides.items():
|
||||
key = str(raw_key).strip().upper()
|
||||
typed[key] = check_dim_value(key, value)
|
||||
return typed
|
||||
|
||||
|
||||
def resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict[str, Any]:
|
||||
"""The preset's seventeen values with ``overrides`` applied on top.
|
||||
|
||||
``preset`` ``None`` means the ISO-25 base. An unknown preset, an override
|
||||
key outside :data:`DIM_VARIABLE_WHITELIST` or a value outside its range
|
||||
raises ``ValueError`` (``TypeError`` for the wrong type) naming the key —
|
||||
before anything is written, because nothing here writes.
|
||||
"""
|
||||
key = "iso-25" if preset is None else str(preset).strip().lower()
|
||||
if key not in PRESETS:
|
||||
raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(PRESETS)}")
|
||||
values = dict(PRESETS[key])
|
||||
values.update(validate_overrides(overrides))
|
||||
return values
|
||||
|
||||
|
||||
def describe_preset(name: str) -> dict:
|
||||
"""``{name, source, values, textstyle}`` for a preset, or ``ValueError``."""
|
||||
key = str(name).strip().lower()
|
||||
if key not in PRESETS:
|
||||
raise ValueError(f"preset: unknown value {name!r}; use one of {sorted(PRESETS)}")
|
||||
return {
|
||||
"name": key,
|
||||
"source": PRESET_SOURCES[key],
|
||||
"values": dict(PRESETS[key]),
|
||||
"textstyle": PRESETS[key]["DIMTXSTY"],
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Multileader style presets: arrow, landing gap, text style and height.
|
||||
|
||||
Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows
|
||||
and lettering; ANSI: 3 mm), so a leader note and a dimension on the same
|
||||
sheet read at one size. Both engines author the style: ezdxf on
|
||||
``doc.mleader_styles``, ActiveX through the ``ACAD_MLEADERSTYLE`` dictionary's
|
||||
``AddObject(name, "AcDbMLeaderStyle")`` and the ``IAcadMLeaderStyle``
|
||||
properties (see ``backends/contracts/styles.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle", "validate_mleaderstyle"]
|
||||
|
||||
MLEADER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5},
|
||||
"ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0},
|
||||
}
|
||||
|
||||
MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height")
|
||||
|
||||
|
||||
def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"{key}: {value!r} is not a finite number")
|
||||
if number < 0.0 or (number == 0.0 and not allow_zero):
|
||||
raise ValueError(f"{key}: {value!r} must be greater than 0")
|
||||
return number
|
||||
|
||||
|
||||
def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str, Any]:
|
||||
"""The preset's four values with ``overrides`` applied; refusals name the key."""
|
||||
key = str(preset).strip().lower() if preset is not None else ""
|
||||
if key not in MLEADER_PRESETS:
|
||||
raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(MLEADER_PRESETS)}")
|
||||
values = dict(MLEADER_PRESETS[key])
|
||||
if overrides is None:
|
||||
return values
|
||||
if not isinstance(overrides, dict):
|
||||
raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}")
|
||||
for raw_key, value in overrides.items():
|
||||
name = str(raw_key).strip().lower()
|
||||
if name not in MLEADER_KEYS:
|
||||
raise ValueError(
|
||||
f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}"
|
||||
)
|
||||
if name == "text_style":
|
||||
values[name] = check_name(name, value, what="text style name")
|
||||
elif name == "landing_gap":
|
||||
values[name] = _positive(name, value, allow_zero=True)
|
||||
else:
|
||||
values[name] = _positive(name, value)
|
||||
return values
|
||||
|
||||
|
||||
def validate_mleaderstyle(values: Any) -> dict[str, Any]:
|
||||
"""The four ``MLEADER_KEYS`` of ``values``, typed, or a refusal naming the key.
|
||||
|
||||
What both engines' ``mleaderstyle_create`` run before touching a table:
|
||||
a missing key is ``ValueError`` (``resolve a preset first``), and each
|
||||
value is checked exactly as :func:`resolve_mleaderstyle` checks an
|
||||
override — so a hand-built dict is held to the preset's rules too.
|
||||
Unknown keys are ignored: the row shape is the contract, not the input.
|
||||
"""
|
||||
if not isinstance(values, dict):
|
||||
raise TypeError(f"values: expected a mapping, got {type(values).__name__}")
|
||||
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"
|
||||
)
|
||||
return {
|
||||
"arrow_size": _positive("arrow_size", values["arrow_size"]),
|
||||
"landing_gap": _positive("landing_gap", values["landing_gap"], allow_zero=True),
|
||||
"text_style": check_name("text_style", values["text_style"], what="text style name"),
|
||||
"text_height": _positive("text_height", values["text_height"]),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""The DXF symbol-table name rule, in one place, for the standards data.
|
||||
|
||||
``security.illegal_symbol_name_chars`` is the same rule at the tool layer; it
|
||||
sits behind a fastmcp import and this package stays free of fastmcp, so the
|
||||
rule is restated here and ``tests/test_standards_presets.py`` pins the two
|
||||
character sets equal. A style, block or font name that passes here is one
|
||||
ezdxf will save and AutoCAD will load; ezdxf's own check fires at
|
||||
``doc.write``, long after a create tool has already said ``ok``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["ILLEGAL_NAME_CHARS", "check_name", "illegal_name_chars"]
|
||||
|
||||
#: Characters DXF forbids in a symbol-table name, plus the whitespace that
|
||||
#: ends a SendCommand segment — byte-for-byte ``security._FORBIDDEN_SYMBOL_CHARS``.
|
||||
ILLEGAL_NAME_CHARS: frozenset[str] = frozenset('<>/\\":;?*|,=`') | frozenset("\n\r\t")
|
||||
|
||||
|
||||
def illegal_name_chars(name: str) -> list[str]:
|
||||
"""The characters in ``name`` a DXF symbol-table name cannot hold, sorted."""
|
||||
return sorted({c for c in name if c in ILLEGAL_NAME_CHARS or ord(c) < 32})
|
||||
|
||||
|
||||
def check_name(key: str, value: Any, *, what: str = "name") -> str:
|
||||
"""``value`` as a stripped, non-empty, legal symbol-table name, or a
|
||||
``TypeError`` / ``ValueError`` naming ``key`` (``what`` words the refusal)."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError(f"{key}: a {what} cannot be empty")
|
||||
bad = illegal_name_chars(text)
|
||||
if bad:
|
||||
raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}")
|
||||
return text
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Text style presets: the four fonts the standards data refers to by name.
|
||||
|
||||
``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique
|
||||
degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape
|
||||
file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the
|
||||
general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face
|
||||
ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font
|
||||
*name*, so an unknown file is never refused here: ``resolve_font`` says
|
||||
whether it is a bundled preset, the backend adds whether its font path has
|
||||
it, and the caller sees ``font_resolved``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import PurePath
|
||||
from typing import Any
|
||||
|
||||
from engineering.standards.names import check_name
|
||||
|
||||
__all__ = [
|
||||
"MAX_OBLIQUE_DEG",
|
||||
"TEXT_PRESETS",
|
||||
"resolve_font",
|
||||
"validate_textstyle",
|
||||
]
|
||||
|
||||
TEXT_PRESETS: dict[str, tuple[str, float, float]] = {
|
||||
"ISOCP": ("isocp.shx", 1.0, 0.0),
|
||||
"ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0),
|
||||
"ARIAL": ("arial.ttf", 1.0, 0.0),
|
||||
"ROMANS": ("romans.shx", 1.0, 0.0),
|
||||
}
|
||||
|
||||
#: AutoCAD's own limit for the STYLE command's obliquing angle.
|
||||
MAX_OBLIQUE_DEG = 85.0
|
||||
|
||||
|
||||
def _number(key: str, value: Any) -> float:
|
||||
"""A finite number, or ``TypeError`` / ``ValueError`` naming ``key``.
|
||||
|
||||
``nan`` and ``inf`` pass every ``<`` / ``>`` check, and a backend writing
|
||||
them verbatim leaves ``50\\nnan`` in the STYLE record — the silent wrong
|
||||
number the contract forbids.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"{key}: {value!r} is not a finite number")
|
||||
return number
|
||||
|
||||
|
||||
def resolve_font(font: str) -> tuple[str, bool]:
|
||||
"""``(font_file, known)``: a preset name or file resolves to the bundled
|
||||
file with ``known=True``; anything else is passed through with ``False``.
|
||||
|
||||
Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem
|
||||
(``isocp``), case-insensitively. Refuses an empty string or control
|
||||
characters — the only inputs a DXF cannot hold.
|
||||
"""
|
||||
if not isinstance(font, str):
|
||||
raise TypeError(f"font: expected a string, got {type(font).__name__}")
|
||||
text = font.strip()
|
||||
if not text:
|
||||
raise ValueError("font: a font file name cannot be empty")
|
||||
if any(ord(ch) < 32 for ch in text):
|
||||
raise ValueError("font: control characters are refused")
|
||||
upper = text.upper()
|
||||
if upper in TEXT_PRESETS:
|
||||
return TEXT_PRESETS[upper][0], True
|
||||
for font_file, _width, _oblique in TEXT_PRESETS.values():
|
||||
if upper in (font_file.upper(), PurePath(font_file).stem.upper()):
|
||||
return font_file, True
|
||||
return text, False
|
||||
|
||||
|
||||
def validate_textstyle(
|
||||
name: str,
|
||||
font: str,
|
||||
height: float = 0.0,
|
||||
width_factor: float = 1.0,
|
||||
oblique_deg: float = 0.0,
|
||||
) -> dict:
|
||||
"""The typed request both engines write, or a ``ValueError``/``TypeError``
|
||||
naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal;
|
||||
the width factor must be positive; the oblique angle is AutoCAD's ±85°;
|
||||
every number must be finite. The name obeys the DXF symbol-name rule
|
||||
(``engineering.standards.names``) — this is the only gate on the
|
||||
``textstyle_create`` path, and a name that passes must be one the drawing
|
||||
can still be saved with."""
|
||||
clean = check_name("name", name, what="text style name")
|
||||
font_file, known = resolve_font(font)
|
||||
height_value = _number("height", height)
|
||||
width_value = _number("width_factor", width_factor)
|
||||
oblique_value = _number("oblique_deg", oblique_deg)
|
||||
if height_value < 0.0:
|
||||
raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'")
|
||||
if width_value <= 0.0:
|
||||
raise ValueError(f"width_factor: {width_factor!r} must be greater than 0")
|
||||
if abs(oblique_value) > MAX_OBLIQUE_DEG:
|
||||
raise ValueError(
|
||||
f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees"
|
||||
)
|
||||
return {
|
||||
"name": clean,
|
||||
"font_file": font_file,
|
||||
"known": known,
|
||||
"height": height_value,
|
||||
"width_factor": width_value,
|
||||
"oblique_deg": oblique_value,
|
||||
}
|
||||
@@ -703,6 +703,7 @@ _GROUP_TAG_PRIORITY = (
|
||||
"validation",
|
||||
"analysis",
|
||||
"block",
|
||||
"style",
|
||||
"layer",
|
||||
"linetype",
|
||||
"create",
|
||||
@@ -728,6 +729,7 @@ _GROUP_TAG_LABELS = {
|
||||
"analysis": "analysis",
|
||||
"validation": "validation",
|
||||
"block": "blocks",
|
||||
"style": "styles",
|
||||
"layer": "layers",
|
||||
"linetype": "layers",
|
||||
"create": "entity_creation",
|
||||
@@ -930,6 +932,12 @@ LEAN_TOOL_NAMES = frozenset(
|
||||
"pid_symbol_insert",
|
||||
"pid_line_draw",
|
||||
"pid_graph",
|
||||
# Styles (track E) — a lean client *uses* a style far more often than
|
||||
# it authors one; drawing_apply_standard authors the whole set in one
|
||||
# call when it must.
|
||||
"dimstyle_set_current",
|
||||
"textstyle_set_current",
|
||||
"drawing_apply_standard",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6890,6 +6898,301 @@ async def pid_tag_parse(
|
||||
return parse_tag(tag, kind, equipment_prefixes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 18: Styles (10 tools) ───────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Pack: core (drafting essentials). Lean profile: dimstyle_set_current and
|
||||
# textstyle_set_current only (Task 13) — a lean client needs to *use* a
|
||||
# style a template already holds far more often than to author one.
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="List dimension styles with their ISO-25/ANSI variables and the current one.",
|
||||
cost="read",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: List Dimension Styles", "readOnlyHint": True},
|
||||
tags={"style", "query"},
|
||||
)
|
||||
async def dimstyle_list(ctx: Context = None) -> dict:
|
||||
"""Every DIMSTYLE table entry with the seventeen preset variables
|
||||
(DIMTXT, DIMASZ, DIMEXE, DIMEXO, DIMGAP, DIMTAD, DIMTIH, DIMTOH, DIMDEC,
|
||||
DIMDSEP, DIMLUNIT, DIMZIN, DIMBLK, DIMTXSTY, DIMLWD, DIMLWE, DIMSCALE).
|
||||
|
||||
Headless, `values` is what the DXF stores (the schema default where an
|
||||
entry omits a code). On the live engine ActiveX has no per-style getters,
|
||||
so `values` is read for the *current* style only and the other rows carry
|
||||
`values: null`, `values_available: false` — `dimstyle_set_current` a
|
||||
style to read it. Never refuses on an open drawing.
|
||||
"""
|
||||
rows = await _backend(ctx).dimstyle_list()
|
||||
return {
|
||||
"ok": True,
|
||||
"styles": rows,
|
||||
"count": len(rows),
|
||||
"current": next((row["name"] for row in rows if row["current"]), None),
|
||||
}
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: Create Dimension Style", "readOnlyHint": False},
|
||||
tags={"style", "create"},
|
||||
)
|
||||
async def dimstyle_create(
|
||||
name: Annotated[str, "New dimension style name, e.g. ISO-25, ANSI, MECH-A3"],
|
||||
preset: Annotated[
|
||||
str | None,
|
||||
"iso-25 (ISO 129-1, 2.5 mm, comma) | ansi (ASME Y14.2, 3 mm, point); omit for the ISO-25 base",
|
||||
] = None,
|
||||
overrides: Annotated[
|
||||
dict | None,
|
||||
"DIM* variables applied on top of the preset, e.g. {DIMTXT: 3.5, DIMDSEP: '.', DIMTAD: 0}",
|
||||
] = None,
|
||||
set_current: Annotated[bool, "Also make it the current dimension style"] = False,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a dimension style: preset values first, then `overrides`.
|
||||
|
||||
Refuses, before anything is written: an unknown preset; an override key
|
||||
outside the 33-variable whitelist (the 17 preset variables plus DIMTOL,
|
||||
DIMTP, DIMTM, DIMTOLJ, DIMTFAC, DIMLFAC, DIMRND, DIMATFIT, DIMTMOVE,
|
||||
DIMCLRD/E/T, DIMSAH, DIMBLK1/2, DIMCEN); a value outside its range
|
||||
(DIMDEC 0-8, sizes > 0, DIMTAD 0-4, DIMDSEP one character, lineweights an
|
||||
AutoCAD code); a name that already exists (use `dimstyle_modify`); a
|
||||
user DIMBLK/DIMBLK1/DIMBLK2 naming a block the drawing does not define
|
||||
(built-in arrowheads need none); a DIMTXSTY that is neither an existing
|
||||
text style nor a bundled preset (ISOCP, ISOCPEUR, ARIAL, ROMANS — those
|
||||
are created on both engines, `textstyle_created`; live, a preset whose
|
||||
font file the seat lacks is refused the same way, nothing written). On
|
||||
the live engine a write ActiveX still refuses after the style is added
|
||||
is rolled back — previous style current, the half-made entry deleted.
|
||||
With `set_current` the next dimension carries the style on both engines.
|
||||
"""
|
||||
from engineering.standards.dimstyles import resolve_dimstyle
|
||||
|
||||
values = resolve_dimstyle(preset, overrides)
|
||||
await ctx.info(f"Dimension style {name!r} from preset {preset or 'iso-25'}")
|
||||
return await _backend(ctx).dimstyle_create(name, values, set_current)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Change DIM* variables on an existing dimension style; reports only what moved.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: Modify Dimension Style", "readOnlyHint": False},
|
||||
tags={"style", "modify"},
|
||||
)
|
||||
async def dimstyle_modify(
|
||||
name: Annotated[str, "Existing dimension style name"],
|
||||
overrides: Annotated[dict, "DIM* variables to change, e.g. {DIMDEC: 3, DIMTXT: 3.5}"],
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Change variables on an existing dimension style.
|
||||
|
||||
`changed` names only the variables that actually moved — re-setting a
|
||||
value to itself is not a change. `dimensions_using_style` lists the
|
||||
dimensions already drawn with it; headlessly `rerender_required: true`
|
||||
says they still show the old style until redrawn (AutoCAD re-renders on
|
||||
the next regen). Refuses a missing style, an empty `overrides`, a key
|
||||
outside the whitelist, a value outside its range, a DIMTXSTY that does
|
||||
not exist, a user arrowhead block the drawing does not define — all
|
||||
before any write. On the live engine, editing a non-current style makes
|
||||
it current for the duration of the call and the previous style is
|
||||
restored — also when a write fails midway (its unsaved overrides are
|
||||
discarded, which is AutoCAD's own DIMSTYLE rule).
|
||||
"""
|
||||
await ctx.info(f"Modifying dimension style {name!r}")
|
||||
return await _backend(ctx).dimstyle_modify(name, overrides)
|
||||
|
||||
|
||||
@cad_tool(summary="Make a dimension style current so new dimensions use it.", cost="safe")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: Set Current Dimension Style", "readOnlyHint": False},
|
||||
tags={"style", "modify"},
|
||||
)
|
||||
async def dimstyle_set_current(
|
||||
name: Annotated[str, "Dimension style to make current (case-insensitive)"],
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Set `$DIMSTYLE` and load the style's variables as the current DIM*
|
||||
settings — what a live seat's DIMSTYLE Restore does — so `dimension_*`
|
||||
and `dimension_auto` draw with it on both engines. Per-dimension
|
||||
tolerances, fits and text overrides still apply on top. Refuses a style
|
||||
the drawing does not hold. Returns `previous` and `changed`.
|
||||
"""
|
||||
return await _backend(ctx).dimstyle_set_current(name)
|
||||
|
||||
|
||||
@cad_tool(summary="List text styles: font file, height, width factor, oblique angle.", cost="read")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: List Text Styles", "readOnlyHint": True},
|
||||
tags={"style", "query"},
|
||||
)
|
||||
async def textstyle_list(ctx: Context = None) -> dict:
|
||||
"""Every STYLE table entry and the one new TEXT/MTEXT will use."""
|
||||
rows = await _backend(ctx).textstyle_list()
|
||||
return {
|
||||
"ok": True,
|
||||
"styles": rows,
|
||||
"count": len(rows),
|
||||
"current": next((row["name"] for row in rows if row["current"]), None),
|
||||
}
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: Create Text Style", "readOnlyHint": False},
|
||||
tags={"style", "create"},
|
||||
)
|
||||
async def textstyle_create(
|
||||
name: Annotated[str, "New text style name, e.g. ISOCP"],
|
||||
font: Annotated[
|
||||
str,
|
||||
"isocp.shx | isocpeur.ttf | arial.ttf | romans.shx (name or file, any case) or any font file",
|
||||
],
|
||||
height: Annotated[
|
||||
float, Field(default=0.0, ge=0, description="Fixed height; 0 = ask per text")
|
||||
] = 0.0,
|
||||
width_factor: Annotated[float, Field(default=1.0, gt=0, description="Width factor")] = 1.0,
|
||||
oblique_deg: Annotated[float, "Obliquing angle in degrees, -85..85"] = 0.0,
|
||||
set_current: Annotated[bool, "Also make it the current text style"] = False,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a text style.
|
||||
|
||||
Headlessly a font nobody can find is *written* and reported
|
||||
`font_resolved: false` (AutoCAD substitutes at open; the headless
|
||||
renderer falls back), because a DXF stores only the name. The live
|
||||
engine cannot do that: ActiveX refuses a font AutoCAD cannot open, so a
|
||||
file that is neither on its support path nor in the Windows Fonts folder
|
||||
is refused there before any write (the message names the folders and the
|
||||
presets); a TrueType font is written by its typeface (`SetFont`, what the
|
||||
STYLE dialog does — the record carries `arial.ttf`, never a machine
|
||||
path), an SHX by the file name. Refuses, before any write on both
|
||||
engines: a name that exists, an empty font, `width_factor <= 0`,
|
||||
`height < 0`, an oblique angle outside ±85°.
|
||||
"""
|
||||
await ctx.info(f"Text style {name!r} with font {font!r}")
|
||||
return await _backend(ctx).textstyle_create(
|
||||
name, font, height, width_factor, oblique_deg, set_current
|
||||
)
|
||||
|
||||
|
||||
@cad_tool(summary="Make a text style current so new text uses it.", cost="safe")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: Set Current Text Style", "readOnlyHint": False},
|
||||
tags={"style", "modify"},
|
||||
)
|
||||
async def textstyle_set_current(
|
||||
name: Annotated[str, "Text style to make current (case-insensitive)"],
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Set `$TEXTSTYLE`; `entity_create_text` / `entity_create_mtext` then
|
||||
carry the style on both engines. Refuses a style the drawing does not
|
||||
hold. Returns `previous` and `changed`."""
|
||||
return await _backend(ctx).textstyle_set_current(name)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="List multileader styles: arrow size, landing gap, text style and height.",
|
||||
cost="read",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: List Multileader Styles", "readOnlyHint": True},
|
||||
tags={"style", "query"},
|
||||
)
|
||||
async def mleaderstyle_list(ctx: Context = None) -> dict:
|
||||
"""Every MLEADERSTYLE with its four values on both engines
|
||||
(`values_available: true`). Headless they are read from the MLEADERSTYLE
|
||||
object; live from the `AcadMLeaderStyle` objects the `ACAD_MLEADERSTYLE`
|
||||
dictionary holds (ActiveX exposes no *collection* property for them, but
|
||||
the dictionary items are full objects). `leader_create_mleader` can still
|
||||
override any of them per leader."""
|
||||
rows = await _backend(ctx).mleaderstyle_list()
|
||||
return {"ok": True, "styles": rows, "count": len(rows)}
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Create a multileader style from the ISO or ANSI preset, on both engines.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Styles: Create Multileader Style", "readOnlyHint": False},
|
||||
tags={"style", "create"},
|
||||
)
|
||||
async def mleaderstyle_create(
|
||||
name: Annotated[str, "New multileader style name"],
|
||||
preset: Annotated[
|
||||
str, "iso (2.5 mm arrow, 1.0 landing, ISOCP 2.5) | ansi (3.0, 1.5, ROMANS 3.0)"
|
||||
],
|
||||
overrides: Annotated[
|
||||
dict | None, "arrow_size | landing_gap | text_style | text_height on top of the preset"
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Create a multileader style: preset values first, then `overrides`.
|
||||
|
||||
Headless it lands on `doc.mleader_styles`; live it is added through the
|
||||
`ACAD_MLEADERSTYLE` dictionary (`AddObject(name, "AcDbMLeaderStyle")`)
|
||||
and the object's properties are written. Refuses, before any write: an
|
||||
unknown preset; an override key outside the four; `arrow_size` /
|
||||
`text_height <= 0`, `landing_gap < 0`; a name that exists; a `text_style`
|
||||
that is neither present nor a bundled preset (ISOCP, ISOCPEUR, ARIAL,
|
||||
ROMANS — those are created on both engines, `textstyle_created`; live, a
|
||||
preset whose font file the seat lacks is refused, nothing written).
|
||||
"""
|
||||
from engineering.standards.mleaderstyles import resolve_mleaderstyle
|
||||
|
||||
values = resolve_mleaderstyle(preset, overrides)
|
||||
await ctx.info(f"Multileader style {name!r} from preset {preset}")
|
||||
return await _backend(ctx).mleaderstyle_create(name, values)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Put the drawing on ISO or ANSI in one call: dimstyle, text style, units, mech layers.",
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Drawing: Apply Drafting Standard", "readOnlyHint": False},
|
||||
tags={"style", "drawing"},
|
||||
)
|
||||
async def drawing_apply_standard(
|
||||
standard: Annotated[
|
||||
str, "iso (ISO-25 / ISOCP, decimal comma) | ansi (ANSI / ROMANS, decimal point)"
|
||||
],
|
||||
layers: Annotated[bool, "Also bootstrap the mech layer set with ISO 128 lineweights"] = True,
|
||||
units: Annotated[
|
||||
bool, "Also write INSUNITS mm, LUNITS/AUNITS decimal, LTSCALE 1, DIMSCALE 1"
|
||||
] = True,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""One call for the common case: the standard's dimension style (created
|
||||
if missing, then current), its text style (created if missing, then
|
||||
current), the shared units, and with `layers` the `mech` layer set (ANSI
|
||||
layer naming is company-specific, so both standards share it).
|
||||
|
||||
Every item reports `created` or already present; `settings.changed`
|
||||
names only the variables that moved, so a second call reports nothing.
|
||||
An existing ISO-25 / ANSI style is reused as it is, not reset — use
|
||||
`dimstyle_modify` to change one. Refuses an unknown standard before any
|
||||
write; the underlying style refusals (`dimstyle_create`,
|
||||
`textstyle_create`) apply unchanged. In the `lean` profile (Task 13).
|
||||
"""
|
||||
from engineering.standards.apply import apply_standard
|
||||
|
||||
await ctx.info(f"Applying the {standard} drafting standard")
|
||||
return await apply_standard(_backend(ctx), standard, layers, units)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Golden query set for tool discovery: English in, one right tool out.
|
||||
|
||||
Sixty-six cases (51 tuning + 15 holdout) split into a **tuning** set and a
|
||||
Seventy-one cases (56 tuning + 15 holdout) split into a **tuning** set and a
|
||||
**holdout** set. The split is
|
||||
the point of the file. Ranking work is measured against
|
||||
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
||||
@@ -143,6 +143,18 @@ TUNING_CASES: tuple[GoldenCase, ...] = (
|
||||
"counting",
|
||||
risk="read",
|
||||
),
|
||||
# Styles (track E). DIMSTYLE / STYLE / MLEADERSTYLE are umbrella commands
|
||||
# shared by their list/create/modify/set-current tools, so the one command
|
||||
# case uses the command unique to a single tool and the rest are phrased.
|
||||
GoldenCase("DDIM", "dimstyle_create", "command"),
|
||||
GoldenCase("make ISO-25 the current dimension style", "dimstyle_set_current", "paraphrase"),
|
||||
GoldenCase(
|
||||
"set the drawing up to the ISO drafting standard in one go",
|
||||
"drawing_apply_standard",
|
||||
"paraphrase",
|
||||
),
|
||||
GoldenCase("create a text style that uses the isocp font", "textstyle_create", "paraphrase"),
|
||||
GoldenCase("which dimension styles are defined", "dimstyle_list", "counting", risk="read"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
# tests/test_standards_presets.py
|
||||
"""Provenance for the authored style presets, pinned by name.
|
||||
|
||||
Every ISO-25 and ANSI value is asserted against the table in spec §4.1 — a
|
||||
reader with ISO 129-1 / ISO 3098-1 / ASME Y14.2 open can check each line.
|
||||
The refusals are pinned too: a value the standards data lets through is a
|
||||
value a backend writes, so the data is where "nothing written on refusal"
|
||||
starts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.standards.dimstyles import (
|
||||
ARROWHEAD_BLOCKS,
|
||||
DIM_VARIABLE_RANGES,
|
||||
DIM_VARIABLE_WHITELIST,
|
||||
LINEWEIGHT_CODES,
|
||||
PRESET_VARIABLES,
|
||||
PRESETS,
|
||||
check_dim_value,
|
||||
describe_preset,
|
||||
ezdxf_arrowhead,
|
||||
reported_arrowhead,
|
||||
resolve_dimstyle,
|
||||
validate_overrides,
|
||||
)
|
||||
from engineering.standards.mleaderstyles import (
|
||||
MLEADER_KEYS,
|
||||
MLEADER_PRESETS,
|
||||
resolve_mleaderstyle,
|
||||
validate_mleaderstyle,
|
||||
)
|
||||
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle
|
||||
|
||||
# ── ISO-25: ISO 129-1:2018 at the 2.5 mm row of ISO 3098-1 ──────────────────
|
||||
|
||||
ISO_25_EXPECTED = {
|
||||
"DIMTXT": 2.5,
|
||||
"DIMASZ": 2.5,
|
||||
"DIMEXE": 1.25,
|
||||
"DIMEXO": 0.625,
|
||||
"DIMGAP": 0.625,
|
||||
"DIMTAD": 1,
|
||||
"DIMTIH": 0,
|
||||
"DIMTOH": 0,
|
||||
"DIMDEC": 2,
|
||||
"DIMDSEP": ",",
|
||||
"DIMLUNIT": 2,
|
||||
"DIMZIN": 8,
|
||||
"DIMBLK": "",
|
||||
"DIMTXSTY": "ISOCP",
|
||||
"DIMLWD": -2,
|
||||
"DIMLWE": -2,
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
# ── ANSI: ASME Y14.2-2014, metric values ────────────────────────────────────
|
||||
|
||||
ANSI_EXPECTED = {
|
||||
"DIMTXT": 3.0,
|
||||
"DIMASZ": 3.0,
|
||||
"DIMEXE": 1.5,
|
||||
"DIMEXO": 1.5,
|
||||
"DIMGAP": 1.0,
|
||||
"DIMTAD": 0,
|
||||
"DIMTIH": 1,
|
||||
"DIMTOH": 1,
|
||||
"DIMDEC": 2,
|
||||
"DIMDSEP": ".",
|
||||
"DIMLUNIT": 2,
|
||||
"DIMZIN": 8,
|
||||
"DIMBLK": "",
|
||||
"DIMTXSTY": "ROMANS",
|
||||
"DIMLWD": -2,
|
||||
"DIMLWE": -2,
|
||||
"DIMSCALE": 1.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("variable", "expected"), sorted(ISO_25_EXPECTED.items()))
|
||||
def test_iso25_value_by_name(variable, expected):
|
||||
assert PRESETS["iso-25"][variable] == expected
|
||||
assert type(PRESETS["iso-25"][variable]) is type(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("variable", "expected"), sorted(ANSI_EXPECTED.items()))
|
||||
def test_ansi_value_by_name(variable, expected):
|
||||
assert PRESETS["ansi"][variable] == expected
|
||||
assert type(PRESETS["ansi"][variable]) is type(expected)
|
||||
|
||||
|
||||
def test_presets_set_exactly_the_seventeen_spec_variables():
|
||||
assert len(PRESET_VARIABLES) == 17
|
||||
for name, preset in PRESETS.items():
|
||||
assert tuple(preset) == PRESET_VARIABLES, name
|
||||
|
||||
|
||||
def test_the_whitelist_is_the_seventeen_plus_the_sixteen_spec_extras():
|
||||
extras = set(
|
||||
"DIMTOL DIMTP DIMTM DIMTOLJ DIMTFAC DIMLFAC DIMRND DIMATFIT "
|
||||
"DIMTMOVE DIMCLRD DIMCLRE DIMCLRT DIMSAH DIMBLK1 DIMBLK2 DIMCEN".split()
|
||||
)
|
||||
assert DIM_VARIABLE_WHITELIST == set(PRESET_VARIABLES) | extras
|
||||
assert set(DIM_VARIABLE_RANGES) == DIM_VARIABLE_WHITELIST
|
||||
|
||||
|
||||
def test_iso_lineweights_are_all_legal_codes():
|
||||
for mm in (0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0):
|
||||
assert round(mm * 100) in LINEWEIGHT_CODES
|
||||
|
||||
|
||||
def test_describe_preset_names_the_standard():
|
||||
iso = describe_preset("ISO-25")
|
||||
assert iso["name"] == "iso-25"
|
||||
assert "ISO 129-1" in iso["source"] and "ISO 3098-1" in iso["source"]
|
||||
assert iso["values"] == ISO_25_EXPECTED and iso["textstyle"] == "ISOCP"
|
||||
ansi = describe_preset("ansi")
|
||||
assert "ASME Y14.2" in ansi["source"] and ansi["textstyle"] == "ROMANS"
|
||||
with pytest.raises(ValueError, match="preset"):
|
||||
describe_preset("din")
|
||||
|
||||
|
||||
# ── resolve_dimstyle ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_none_preset_is_the_iso25_base():
|
||||
assert resolve_dimstyle(None, None) == ISO_25_EXPECTED
|
||||
assert resolve_dimstyle("ISO-25", {}) == ISO_25_EXPECTED
|
||||
assert resolve_dimstyle("ansi", None) == ANSI_EXPECTED
|
||||
|
||||
|
||||
def test_overrides_apply_on_top_and_are_upper_cased_and_typed():
|
||||
values = resolve_dimstyle("iso-25", {"dimtxt": 3, "DIMDEC": 3.0, "dimdsep": "."})
|
||||
assert values["DIMTXT"] == 3.0 and type(values["DIMTXT"]) is float
|
||||
assert values["DIMDEC"] == 3 and type(values["DIMDEC"]) is int
|
||||
assert values["DIMDSEP"] == "."
|
||||
assert values["DIMASZ"] == 2.5, "untouched values stay the preset's"
|
||||
|
||||
|
||||
def test_resolve_leaves_the_preset_table_untouched():
|
||||
resolve_dimstyle("iso-25", {"DIMTXT": 9.0})
|
||||
assert PRESETS["iso-25"]["DIMTXT"] == 2.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "fragment"),
|
||||
[
|
||||
({"DIMFOO": 1}, "DIMFOO"),
|
||||
({"DIMDEC": 9}, "DIMDEC"),
|
||||
({"DIMDEC": -1}, "DIMDEC"),
|
||||
({"DIMDEC": 2.5}, "whole number"),
|
||||
({"DIMTXT": 0}, "DIMTXT"),
|
||||
({"DIMASZ": -1}, "DIMASZ"),
|
||||
({"DIMTAD": 5}, "DIMTAD"),
|
||||
({"DIMTIH": 2}, "DIMTIH"),
|
||||
({"DIMDSEP": ",,"}, "DIMDSEP"),
|
||||
({"DIMDSEP": ""}, "DIMDSEP"),
|
||||
({"DIMLWD": 27}, "DIMLWD"),
|
||||
({"DIMLUNIT": 0}, "DIMLUNIT"),
|
||||
({"DIMZIN": 16}, "DIMZIN"),
|
||||
({"DIMTXSTY": ""}, "DIMTXSTY"),
|
||||
({"DIMTXSTY": "a/b"}, "DIMTXSTY"),
|
||||
({"DIMTXSTY": "a\nb"}, "DIMTXSTY"),
|
||||
({"DIMTXSTY": "a\tb"}, "DIMTXSTY"),
|
||||
({"DIMBLK": "a/b"}, "DIMBLK"),
|
||||
({"DIMBLK": "a,b"}, "DIMBLK"),
|
||||
({"DIMBLK1": "x\ny"}, "DIMBLK1"),
|
||||
({"DIMBLK2": " "}, "DIMBLK2"),
|
||||
({"DIMLFAC": 0}, "DIMLFAC"),
|
||||
({"DIMTFAC": 0}, "DIMTFAC"),
|
||||
({"DIMCLRT": 257}, "DIMCLRT"),
|
||||
({"DIMRND": -0.5}, "DIMRND"),
|
||||
({"DIMSCALE": float("nan")}, "DIMSCALE"),
|
||||
],
|
||||
)
|
||||
def test_out_of_range_or_unknown_overrides_are_refused_by_name(overrides, fragment):
|
||||
with pytest.raises(ValueError, match=fragment):
|
||||
resolve_dimstyle("iso-25", overrides)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "fragment"),
|
||||
[
|
||||
({"DIMTXT": "2.5"}, "DIMTXT"),
|
||||
({"DIMTAD": True}, "DIMTAD"),
|
||||
({"DIMDSEP": 44}, "DIMDSEP"),
|
||||
({"DIMBLK": 0}, "DIMBLK"),
|
||||
],
|
||||
)
|
||||
def test_wrong_types_are_a_type_error_naming_the_key(overrides, fragment):
|
||||
with pytest.raises(TypeError, match=fragment):
|
||||
resolve_dimstyle("iso-25", overrides)
|
||||
|
||||
|
||||
def test_unknown_preset_is_refused():
|
||||
with pytest.raises(ValueError, match="preset"):
|
||||
resolve_dimstyle("din", None)
|
||||
with pytest.raises(TypeError, match="overrides"):
|
||||
resolve_dimstyle("iso-25", [("DIMTXT", 2.5)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"separator",
|
||||
["\n", "\t", "\x00", "\x7f", chr(0x1F600), chr(0x8000), chr(0xFFFF)],
|
||||
ids=["newline", "tab", "nul", "del", "non-bmp", "first-above-signed-short", "u-ffff"],
|
||||
)
|
||||
def test_dimdsep_refuses_what_a_dxf_character_code_cannot_hold(separator):
|
||||
"""DIMDSEP is the one variable stored as a character code (group 278, a
|
||||
16-bit value); ``len == 1`` alone let these through, and the headless
|
||||
engine then wrote ``ord()`` unguarded — measured: ``'\\n'`` split the
|
||||
dimension text over two lines, NUL read back as a comma, and the emoji
|
||||
reached the file as 128512, which AutoCAD refuses as "not a single
|
||||
character". ezdxf's audit reports none of it."""
|
||||
with pytest.raises(ValueError, match="DIMDSEP"):
|
||||
check_dim_value("DIMDSEP", separator)
|
||||
with pytest.raises(ValueError, match="DIMDSEP"):
|
||||
resolve_dimstyle("iso-25", {"DIMDSEP": separator})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"separator",
|
||||
[",", ".", " ", chr(0xB7), chr(0x66B), chr(0x7FFF)],
|
||||
ids=["comma", "point", "space", "middle-dot", "arabic-decimal", "last-signed-short"],
|
||||
)
|
||||
def test_dimdsep_takes_any_printable_separator_a_dxf_can_hold(separator):
|
||||
"""The comma, the point, the middle dot, the Arabic decimal separator, and
|
||||
the last code a signed short holds all pass; the guard is not an ASCII gate."""
|
||||
assert check_dim_value("DIMDSEP", separator) == separator
|
||||
|
||||
|
||||
def test_validate_overrides_alone_is_what_modify_uses():
|
||||
assert validate_overrides(None) == {}
|
||||
assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text"
|
||||
assert check_dim_value("dimlwe", 25) == 25
|
||||
assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE"
|
||||
|
||||
|
||||
# ── tolerances: DIMTP / DIMTM are signed ────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fit", "expected"),
|
||||
[
|
||||
("p6", {"DIMTP": 0.035, "DIMTM": -0.022}), # double positive: -DIMTM is +0.022
|
||||
("f7", {"DIMTP": -0.02, "DIMTM": 0.041}), # double negative: DIMTP below zero
|
||||
("H7", {"DIMTP": 0.021, "DIMTM": 0.0}),
|
||||
("h6", {"DIMTP": 0.0, "DIMTM": 0.013}),
|
||||
],
|
||||
)
|
||||
def test_a_style_holds_the_same_signed_deviations_a_dimension_gets(fit, expected):
|
||||
"""``dimension_linear(fit=...)`` writes ``build_dim_override``'s DIMTP/DIMTM
|
||||
onto one dimension; a dimension style must accept the identical numbers,
|
||||
signs included, or a p6 / f7 fit can be drawn but never styled — and an
|
||||
agent obeying an "at least 0" refusal would ship a sign-flipped tolerance."""
|
||||
from engineering.fits import fit_lookup
|
||||
from engineering.tolerances import build_dim_override
|
||||
|
||||
deviation = fit_lookup(fit, 20.0)
|
||||
override, _ = build_dim_override(deviation.upper_mm, -deviation.lower_mm, "deviation", None)
|
||||
styled = {k: v for k, v in override.items() if k.upper() in DIM_VARIABLE_WHITELIST}
|
||||
assert {"dimtol", "dimtp", "dimtm"} <= set(styled)
|
||||
typed = resolve_dimstyle("iso-25", styled)
|
||||
assert typed["DIMTOL"] == 1
|
||||
assert typed["DIMTP"] == pytest.approx(expected["DIMTP"])
|
||||
assert typed["DIMTM"] == pytest.approx(expected["DIMTM"])
|
||||
assert typed["DIMTM"] == pytest.approx(override["dimtm"]), "sign preserved, not abs()"
|
||||
assert typed["DIMTP"] == pytest.approx(override["dimtp"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("var", ["DIMTP", "DIMTM"])
|
||||
def test_tolerance_limits_are_signed_finite_floats(var):
|
||||
assert DIM_VARIABLE_RANGES[var] == ("float", -1e6, 1e6)
|
||||
assert check_dim_value(var, -0.022) == -0.022
|
||||
assert check_dim_value(var, 0) == 0.0
|
||||
with pytest.raises(ValueError, match=var):
|
||||
check_dim_value(var, float("-inf"))
|
||||
with pytest.raises(ValueError, match=var):
|
||||
check_dim_value(var, -1e7)
|
||||
with pytest.raises(TypeError, match=var):
|
||||
check_dim_value(var, "-0.022")
|
||||
|
||||
|
||||
# ── arrowheads: DIMBLK / DIMBLK1 / DIMBLK2 ──────────────────────────────────
|
||||
|
||||
|
||||
def test_the_builtin_arrowheads_are_autocads_nineteen_named_blocks():
|
||||
assert ARROWHEAD_BLOCKS == frozenset(
|
||||
"_ARCHTICK _BOXBLANK _BOXFILLED _CLOSED _CLOSEDBLANK _DATUMBLANK _DATUMFILLED "
|
||||
"_DOT _DOTBLANK _DOTSMALL _INTEGRAL _NONE _OBLIQUE _OPEN _OPEN30 _OPEN90 "
|
||||
"_ORIGIN _ORIGIN2 _SMALL".split()
|
||||
)
|
||||
|
||||
|
||||
def test_the_builtin_arrowheads_are_exactly_ezdxfs_vocabulary():
|
||||
from ezdxf.render.arrows import ARROWS
|
||||
|
||||
assert ARROWHEAD_BLOCKS == {ARROWS.block_name(name) for name in ARROWS.__acad__ if name}
|
||||
assert ARROWS.block_name("") == "_CLOSEDFILLED", "closed filled is the nameless default"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("given", "canonical"),
|
||||
[
|
||||
("", ""),
|
||||
(".", ""),
|
||||
("CLOSEDFILLED", ""),
|
||||
("_ClosedFilled", ""),
|
||||
("_OBLIQUE", "_OBLIQUE"),
|
||||
("OBLIQUE", "_OBLIQUE"),
|
||||
("oblique", "_OBLIQUE"),
|
||||
(" _dot ", "_DOT"),
|
||||
("Open30", "_OPEN30"),
|
||||
("none", "_NONE"),
|
||||
("MYARROW", "MYARROW"),
|
||||
(" my_arrow-1 ", "my_arrow-1"),
|
||||
],
|
||||
)
|
||||
def test_arrowhead_names_canonicalise_to_autocads_documented_spelling(given, canonical):
|
||||
for var in ("DIMBLK", "DIMBLK1", "DIMBLK2"):
|
||||
assert check_dim_value(var, given) == canonical, var
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["a/b", "a,b", "x\ny", "x\ty", "x\rx", "a\x01b", " ", "\t"])
|
||||
def test_arrowhead_user_blocks_obey_the_symbol_name_rule(bad):
|
||||
for var in ("DIMBLK", "DIMBLK1", "DIMBLK2"):
|
||||
with pytest.raises(ValueError, match=var):
|
||||
check_dim_value(var, bad)
|
||||
|
||||
|
||||
def test_ezdxf_arrowhead_strips_the_underscore_only_for_built_ins():
|
||||
assert ezdxf_arrowhead("") == ""
|
||||
assert ezdxf_arrowhead("_OBLIQUE") == "OBLIQUE"
|
||||
assert ezdxf_arrowhead("_DOT") == "DOT"
|
||||
assert ezdxf_arrowhead("MYARROW") == "MYARROW"
|
||||
assert ezdxf_arrowhead("_MYBLOCK") == "_MYBLOCK", (
|
||||
"a user block with an underscore is not built in"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("canonical", [*sorted(ARROWHEAD_BLOCKS), ""])
|
||||
def test_every_canonical_arrowhead_exports_headlessly_and_reads_back_as_itself(canonical):
|
||||
"""The canonical spelling is what ezdxf itself reports after a load; only
|
||||
its *writer* wants the underscore-less name, and ``ezdxf_arrowhead`` is that
|
||||
one translation."""
|
||||
import io
|
||||
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.new("R2018")
|
||||
style = doc.dimstyles.new("X")
|
||||
style.dxf.dimblk = ezdxf_arrowhead(canonical)
|
||||
style.dxf.dimblk1 = ezdxf_arrowhead(canonical)
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
buffer.seek(0)
|
||||
reloaded = ezdxf.read(buffer).dimstyles.get("X")
|
||||
assert reloaded.dxf.dimblk == canonical
|
||||
assert reloaded.dxf.dimblk1 == canonical
|
||||
|
||||
|
||||
def test_the_canonical_spelling_is_not_what_the_headless_writer_takes():
|
||||
"""Why ``ezdxf_arrowhead`` exists: ``_OBLIQUE`` stored verbatim raises at
|
||||
``doc.write``, after ``dimstyle_create`` would already have said ``ok``."""
|
||||
import io
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.lldxf.const import DXFValueError
|
||||
|
||||
doc = ezdxf.new("R2018")
|
||||
doc.dimstyles.new("X").dxf.dimblk = "_OBLIQUE"
|
||||
with pytest.raises(DXFValueError, match="_OBLIQUE"):
|
||||
doc.write(io.StringIO())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "reported"),
|
||||
[
|
||||
("OBLIQUE", "_OBLIQUE"), # what ezdxf holds in-session after the writer set it
|
||||
("_OBLIQUE", "_OBLIQUE"), # what ezdxf holds after a reload; what COM reports
|
||||
("", ""),
|
||||
(None, ""),
|
||||
("_CLOSEDFILLED", ""),
|
||||
("MYARROW", "MYARROW"),
|
||||
("MY ARROW,1", "MY ARROW,1"), # a foreign drawing's name: passed through, never raised
|
||||
(" ", " "),
|
||||
],
|
||||
)
|
||||
def test_reported_arrowhead_spells_a_stored_name_one_way_and_never_raises(stored, reported):
|
||||
assert reported_arrowhead(stored) == reported
|
||||
|
||||
|
||||
@pytest.mark.parametrize("canonical", ["_OBLIQUE", "_DOT", "", "MYARROW"])
|
||||
def test_a_style_reports_the_same_arrowhead_before_and_after_a_reload(canonical):
|
||||
"""The spec §8.3 witness at the data layer: a raw read spells ``OBLIQUE``
|
||||
in-session and ``_OBLIQUE`` after reopen, so ``dimstyle_modify`` re-setting
|
||||
the same value would report a change. Through ``reported_arrowhead`` the two
|
||||
reads agree with each other and with what ``check_dim_value`` accepts."""
|
||||
import io
|
||||
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.new("R2018")
|
||||
if canonical == "MYARROW":
|
||||
doc.blocks.new("MYARROW").add_line((0, 0), (1, 1))
|
||||
style = doc.dimstyles.new("X")
|
||||
style.dxf.dimblk = ezdxf_arrowhead(canonical)
|
||||
in_session = reported_arrowhead(style.dxf.dimblk)
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
buffer.seek(0)
|
||||
reloaded = reported_arrowhead(ezdxf.read(buffer).dimstyles.get("X").dxf.dimblk)
|
||||
assert in_session == reloaded == canonical
|
||||
assert check_dim_value("DIMBLK", in_session) == canonical
|
||||
|
||||
|
||||
# ── the symbol-name rule ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_name_rule_is_the_one_security_applies():
|
||||
import security
|
||||
from engineering.standards.names import ILLEGAL_NAME_CHARS, illegal_name_chars
|
||||
|
||||
assert ILLEGAL_NAME_CHARS == frozenset(security._FORBIDDEN_SYMBOL_CHARS)
|
||||
for sample in ("a\nb", "a\tb", "a\rb", "a\x01b", "a/b", "a<b>c", "ok_name-1", "ISO-25"):
|
||||
assert illegal_name_chars(sample) == security.illegal_symbol_name_chars(sample), sample
|
||||
|
||||
|
||||
# ── text styles ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_text_presets_are_the_four_spec_fonts():
|
||||
assert TEXT_PRESETS == {
|
||||
"ISOCP": ("isocp.shx", 1.0, 0.0),
|
||||
"ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0),
|
||||
"ARIAL": ("arial.ttf", 1.0, 0.0),
|
||||
"ROMANS": ("romans.shx", 1.0, 0.0),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("font", "expected"),
|
||||
[
|
||||
("ISOCP", "isocp.shx"),
|
||||
("isocp", "isocp.shx"),
|
||||
("isocp.shx", "isocp.shx"),
|
||||
("ISOCP.SHX", "isocp.shx"),
|
||||
("ISOCPEUR", "isocpeur.ttf"),
|
||||
("arial", "arial.ttf"),
|
||||
("Arial.ttf", "arial.ttf"),
|
||||
("romans.shx", "romans.shx"),
|
||||
],
|
||||
)
|
||||
def test_a_preset_font_resolves_known(font, expected):
|
||||
assert resolve_font(font) == (expected, True)
|
||||
|
||||
|
||||
def test_an_unknown_font_passes_through_unknown_never_refused():
|
||||
assert resolve_font("simplex.shx") == ("simplex.shx", False)
|
||||
assert resolve_font(" c:/fonts/custom.ttf ") == ("c:/fonts/custom.ttf", False)
|
||||
|
||||
|
||||
def test_empty_or_control_font_names_are_refused():
|
||||
with pytest.raises(ValueError, match="font"):
|
||||
resolve_font("")
|
||||
with pytest.raises(ValueError, match="font"):
|
||||
resolve_font("bad\nfont.shx")
|
||||
with pytest.raises(TypeError, match="font"):
|
||||
resolve_font(None)
|
||||
|
||||
|
||||
def test_validate_textstyle_types_the_request():
|
||||
spec = validate_textstyle(" ISOCP ", "isocp", 0, 1, 0)
|
||||
assert spec == {
|
||||
"name": "ISOCP",
|
||||
"font_file": "isocp.shx",
|
||||
"known": True,
|
||||
"height": 0.0,
|
||||
"width_factor": 1.0,
|
||||
"oblique_deg": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "fragment"),
|
||||
[
|
||||
({"name": ""}, "name"),
|
||||
({"name": "A:B"}, "name"),
|
||||
({"name": "a\nb"}, "name"),
|
||||
({"name": "a\tb"}, "name"),
|
||||
({"name": "a\rb"}, "name"),
|
||||
({"height": -1}, "height"),
|
||||
({"height": float("nan")}, "height"),
|
||||
({"height": float("inf")}, "height"),
|
||||
({"width_factor": 0}, "width_factor"),
|
||||
({"width_factor": float("nan")}, "width_factor"),
|
||||
({"width_factor": float("inf")}, "width_factor"),
|
||||
({"oblique_deg": 86}, "oblique_deg"),
|
||||
({"oblique_deg": -90}, "oblique_deg"),
|
||||
({"oblique_deg": float("nan")}, "oblique_deg"),
|
||||
({"oblique_deg": float("-inf")}, "oblique_deg"),
|
||||
],
|
||||
)
|
||||
def test_validate_textstyle_refuses_by_field(kwargs, fragment):
|
||||
base = {
|
||||
"name": "T",
|
||||
"font": "arial.ttf",
|
||||
"height": 0.0,
|
||||
"width_factor": 1.0,
|
||||
"oblique_deg": 0.0,
|
||||
}
|
||||
base.update(kwargs)
|
||||
with pytest.raises(ValueError, match=fragment):
|
||||
validate_textstyle(**base)
|
||||
|
||||
|
||||
def test_validate_textstyle_refuses_wrong_types():
|
||||
with pytest.raises(TypeError, match="width_factor"):
|
||||
validate_textstyle("T", "arial.ttf", 0.0, "1", 0.0)
|
||||
with pytest.raises(TypeError, match="height"):
|
||||
validate_textstyle("T", "arial.ttf", True, 1.0, 0.0)
|
||||
|
||||
|
||||
# ── multileader styles ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mleader_presets_match_the_plan():
|
||||
assert MLEADER_PRESETS == {
|
||||
"iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5},
|
||||
"ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0},
|
||||
}
|
||||
assert MLEADER_KEYS == ("arrow_size", "landing_gap", "text_style", "text_height")
|
||||
|
||||
|
||||
def test_resolve_mleaderstyle_applies_overrides():
|
||||
values = resolve_mleaderstyle("ISO", {"arrow_size": 3, "text_style": " ARIAL "})
|
||||
assert values == {
|
||||
"arrow_size": 3.0,
|
||||
"landing_gap": 1.0,
|
||||
"text_style": "ARIAL",
|
||||
"text_height": 2.5,
|
||||
}
|
||||
assert resolve_mleaderstyle("ansi") == MLEADER_PRESETS["ansi"]
|
||||
assert resolve_mleaderstyle("iso", {"landing_gap": 0})["landing_gap"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preset", "overrides", "exc", "fragment"),
|
||||
[
|
||||
("din", None, ValueError, "preset"),
|
||||
(None, None, ValueError, "preset"),
|
||||
("iso", {"colour": 1}, ValueError, "colour"),
|
||||
("iso", {"arrow_size": 0}, ValueError, "arrow_size"),
|
||||
("iso", {"text_height": -2}, ValueError, "text_height"),
|
||||
("iso", {"text_style": ""}, ValueError, "text_style"),
|
||||
("iso", {"text_style": "a|b"}, ValueError, "text_style"),
|
||||
("iso", {"text_style": "a\nb"}, ValueError, "text_style"),
|
||||
("iso", {"text_style": "a\tb"}, ValueError, "text_style"),
|
||||
("iso", {"arrow_size": float("nan")}, ValueError, "arrow_size"),
|
||||
("iso", {"arrow_size": "2.5"}, TypeError, "arrow_size"),
|
||||
("iso", ["arrow_size"], TypeError, "overrides"),
|
||||
],
|
||||
)
|
||||
def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment):
|
||||
with pytest.raises(exc, match=fragment):
|
||||
resolve_mleaderstyle(preset, overrides)
|
||||
|
||||
|
||||
def test_validate_mleaderstyle_types_the_four_keys_and_refuses_by_name():
|
||||
"""What both engines' ``mleaderstyle_create`` run before any write: a
|
||||
hand-built dict is held to the same rules as a preset override."""
|
||||
assert validate_mleaderstyle(resolve_mleaderstyle("iso")) == MLEADER_PRESETS["iso"]
|
||||
typed = validate_mleaderstyle(
|
||||
{"arrow_size": 3, "landing_gap": 0, "text_style": " arial ", "text_height": 2, "extra": 1}
|
||||
)
|
||||
assert typed == {
|
||||
"arrow_size": 3.0,
|
||||
"landing_gap": 0.0,
|
||||
"text_style": "arial",
|
||||
"text_height": 2.0,
|
||||
}
|
||||
with pytest.raises(ValueError, match=r"missing \['landing_gap', 'text_style', 'text_height'\]"):
|
||||
validate_mleaderstyle({"arrow_size": 2.5})
|
||||
with pytest.raises(ValueError, match="arrow_size"):
|
||||
validate_mleaderstyle({**MLEADER_PRESETS["iso"], "arrow_size": 0})
|
||||
with pytest.raises(ValueError, match="text_style"):
|
||||
validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_style": "a|b"})
|
||||
with pytest.raises(TypeError, match="text_height"):
|
||||
validate_mleaderstyle({**MLEADER_PRESETS["iso"], "text_height": "2.5"})
|
||||
with pytest.raises(TypeError, match="values"):
|
||||
validate_mleaderstyle(["arrow_size"])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -517,8 +517,14 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
||||
joined; the `pid`
|
||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||
`pid` rather than under their secondary `query` / `create` tags. Every
|
||||
other number here has been unchanged since the snapshot was taken.
|
||||
`pid` rather than under their secondary `query` / `create` tags. `styles`
|
||||
appeared (0 -> 9) when track E's SECTION 18 opened with the dimension,
|
||||
text and multileader style tools, then 9 -> 10 when
|
||||
`drawing_apply_standard` (ISO or ANSI styles, units and layers in one
|
||||
call) joined; the `style` tag sits ahead of `layer` and `drawing` in
|
||||
`_GROUP_TAG_PRIORITY` so they never file under their secondary
|
||||
`query` / `create` / `modify` / `drawing` tags. Every other number here has been
|
||||
unchanged since the snapshot was taken.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
@@ -537,10 +543,11 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
"solids": 5,
|
||||
"styles": 10,
|
||||
"system": 7,
|
||||
"templates": 2,
|
||||
"transactions": 3,
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 166
|
||||
assert sum(sizes.values()) == 176
|
||||
|
||||
Reference in New Issue
Block a user