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>
|
</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`,
|
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from backends.contracts import (
|
|||||||
PremiumContract,
|
PremiumContract,
|
||||||
SettingsContract,
|
SettingsContract,
|
||||||
SolidContract,
|
SolidContract,
|
||||||
|
StylesContract,
|
||||||
SystemContract,
|
SystemContract,
|
||||||
TransactionContract,
|
TransactionContract,
|
||||||
ViewContract,
|
ViewContract,
|
||||||
@@ -184,6 +185,7 @@ class AutoCADBackend(
|
|||||||
PremiumContract,
|
PremiumContract,
|
||||||
GdtContract,
|
GdtContract,
|
||||||
SettingsContract,
|
SettingsContract,
|
||||||
|
StylesContract,
|
||||||
):
|
):
|
||||||
"""All backends implement this interface.
|
"""All backends implement this interface.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import asyncio
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -318,6 +319,451 @@ def _regen():
|
|||||||
log.debug("Regen failed: %s", exc)
|
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):
|
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.
|
"""Best-effort ISO 129 tolerance rendering on a live COM dimension.
|
||||||
|
|
||||||
@@ -4581,6 +5027,328 @@ class ComBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._run(_sync)
|
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 ──────────────────────────────────────────────────────────
|
# ── corner ops ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from backends.contracts.linetypes import LinetypeContract
|
|||||||
from backends.contracts.premium import PremiumContract
|
from backends.contracts.premium import PremiumContract
|
||||||
from backends.contracts.settings import SettingsContract
|
from backends.contracts.settings import SettingsContract
|
||||||
from backends.contracts.solids import SolidContract
|
from backends.contracts.solids import SolidContract
|
||||||
|
from backends.contracts.styles import StylesContract
|
||||||
from backends.contracts.system import SystemContract
|
from backends.contracts.system import SystemContract
|
||||||
from backends.contracts.transactions import TransactionContract
|
from backends.contracts.transactions import TransactionContract
|
||||||
from backends.contracts.view import ViewContract
|
from backends.contracts.view import ViewContract
|
||||||
@@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"PremiumContract",
|
"PremiumContract",
|
||||||
"SettingsContract",
|
"SettingsContract",
|
||||||
"SolidContract",
|
"SolidContract",
|
||||||
|
"StylesContract",
|
||||||
"SystemContract",
|
"SystemContract",
|
||||||
"TransactionContract",
|
"TransactionContract",
|
||||||
"ViewContract",
|
"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
|
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.
|
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
|
Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm
|
||||||
rounding keeps it.
|
rounding keeps it.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
style = doc.dimstyles.get("Standard")
|
styles = list(doc.dimstyles)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
|
for style in styles:
|
||||||
style.dxf.discard("dimrnd")
|
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:
|
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):
|
def _style_dimvar(doc, attribute: str, fallback, coerce):
|
||||||
"""What the renderer would use with no header and no override in play."""
|
"""What the renderer would use with no header and no override in play."""
|
||||||
try:
|
try:
|
||||||
style = doc.dimstyles.get(_DIMSTYLE_NAME)
|
style = doc.dimstyles.get(_current_dimstyle_name(doc))
|
||||||
if style.dxf.hasattr(attribute):
|
if style.dxf.hasattr(attribute):
|
||||||
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
|
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
|
||||||
if stored is not None:
|
if stored is not None:
|
||||||
@@ -662,6 +673,291 @@ def _with_header_dimvars(doc, override: dict | None) -> dict | None:
|
|||||||
return merged
|
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:
|
def _round_measure(result: dict) -> dict:
|
||||||
"""Six decimal places, matching analysis_measure_distance."""
|
"""Six decimal places, matching analysis_measure_distance."""
|
||||||
result["area"] = round(float(result["area"]), 6)
|
result["area"] = round(float(result["area"]), 6)
|
||||||
@@ -2369,17 +2665,31 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
raise self._solid_unsupported("solid_boolean")
|
raise self._solid_unsupported("solid_boolean")
|
||||||
|
|
||||||
async def drawing_purge(self) -> dict:
|
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():
|
def _sync():
|
||||||
doc = self._require_doc()
|
doc = self._require_doc()
|
||||||
purged = {"blocks": 0, "layers": 0, "linetypes": 0, "text_styles": 0}
|
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():
|
for ent in doc.entitydb.values():
|
||||||
if ent.dxftype() == "INSERT":
|
if ent.dxftype() == "INSERT":
|
||||||
used_blocks.add(ent.dxf.name)
|
used_blocks.add(str(ent.dxf.name).lower())
|
||||||
for blk in list(doc.blocks):
|
for blk in list(doc.blocks):
|
||||||
name = blk.name
|
name = blk.name
|
||||||
if name.startswith("*") or name in used_blocks:
|
if name.startswith("*") or name.lower() in used_blocks:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
doc.blocks.delete_block(name, safe=True)
|
doc.blocks.delete_block(name, safe=True)
|
||||||
@@ -2417,13 +2727,12 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
used_styles: set[str] = {"Standard"}
|
|
||||||
for ent in doc.entitydb.values():
|
for ent in doc.entitydb.values():
|
||||||
if hasattr(ent.dxf, "style"):
|
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):
|
for st in list(doc.styles):
|
||||||
name = st.dxf.name
|
name = st.dxf.name
|
||||||
if name in used_styles:
|
if name.lower() in used_styles:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
doc.styles.remove(name)
|
doc.styles.remove(name)
|
||||||
@@ -2748,6 +3057,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
ent = msp.add_text(
|
ent = msp.add_text(
|
||||||
text,
|
text,
|
||||||
dxfattribs={
|
dxfattribs={
|
||||||
|
"style": _current_textstyle_name(self._require_doc()),
|
||||||
"height": float(height),
|
"height": float(height),
|
||||||
"rotation": float(rotation),
|
"rotation": float(rotation),
|
||||||
"insert": (float(x), float(y)),
|
"insert": (float(x), float(y)),
|
||||||
@@ -2774,7 +3084,11 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
msp = self._msp()
|
msp = self._msp()
|
||||||
ent = msp.add_mtext(
|
ent = msp.add_mtext(
|
||||||
text,
|
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)
|
ent.dxf.insert = (float(x), float(y), 0.0)
|
||||||
if rotation:
|
if rotation:
|
||||||
@@ -3361,7 +3675,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
p2=(float(x2), float(y2)),
|
p2=(float(x2), float(y2)),
|
||||||
angle=float(rotation),
|
angle=float(rotation),
|
||||||
text=text if text is not None else "<>",
|
text=text if text is not None else "<>",
|
||||||
dimstyle=_DIMSTYLE_NAME,
|
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||||
override=override or None,
|
override=override or None,
|
||||||
)
|
)
|
||||||
dim.render()
|
dim.render()
|
||||||
@@ -3391,7 +3705,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
distance=math.sqrt(
|
distance=math.sqrt(
|
||||||
(float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2
|
(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),
|
override=_with_header_dimvars(self._require_doc(), None),
|
||||||
)
|
)
|
||||||
dim.render()
|
dim.render()
|
||||||
@@ -3423,7 +3737,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
line1=((vxf, vyf), (float(x1), float(y1))),
|
line1=((vxf, vyf), (float(x1), float(y1))),
|
||||||
line2=((vxf, vyf), (float(x2), float(y2))),
|
line2=((vxf, vyf), (float(x2), float(y2))),
|
||||||
location=(float(tx), float(ty)),
|
location=(float(tx), float(ty)),
|
||||||
dimstyle=_DIMSTYLE_NAME,
|
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||||
override=_with_header_dimvars(self._require_doc(), None),
|
override=_with_header_dimvars(self._require_doc(), None),
|
||||||
)
|
)
|
||||||
dim.render()
|
dim.render()
|
||||||
@@ -3468,7 +3782,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
cyf + (radius + leader) * math.sin(angle_rad),
|
cyf + (radius + leader) * math.sin(angle_rad),
|
||||||
),
|
),
|
||||||
text=text if text is not None else "<>",
|
text=text if text is not None else "<>",
|
||||||
dimstyle=_DIMSTYLE_NAME,
|
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||||
override=override or None,
|
override=override or None,
|
||||||
)
|
)
|
||||||
dim.render()
|
dim.render()
|
||||||
@@ -3518,7 +3832,7 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
cy + (radius + leader) * math.sin(angle_rad),
|
cy + (radius + leader) * math.sin(angle_rad),
|
||||||
),
|
),
|
||||||
text=text if text is not None else "<>",
|
text=text if text is not None else "<>",
|
||||||
dimstyle=_DIMSTYLE_NAME,
|
dimstyle=_current_dimstyle_name(self._require_doc()),
|
||||||
override=override or None,
|
override=override or None,
|
||||||
)
|
)
|
||||||
dim.render()
|
dim.render()
|
||||||
@@ -5958,6 +6272,262 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"error": "system_run_lisp not supported in ezdxf backend (no live AutoCAD)",
|
"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 ──────────────────────────────────────────────────────────
|
# ── corner ops ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -72,20 +72,27 @@ class ToolAliases:
|
|||||||
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
|
# 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
|
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
|
||||||
# class, a number and markers; a drafter typing it could mean either.
|
# 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.
|
# Every other AutoCAD command must map to exactly one tool.
|
||||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"ARRAY",
|
"ARRAY",
|
||||||
"BLOCK",
|
"BLOCK",
|
||||||
|
"DIMSTYLE",
|
||||||
"ERASE",
|
"ERASE",
|
||||||
"INSERT",
|
"INSERT",
|
||||||
"LAYER",
|
"LAYER",
|
||||||
"LAYOUT",
|
"LAYOUT",
|
||||||
"MEASUREGEOM",
|
"MEASUREGEOM",
|
||||||
|
"MLEADERSTYLE",
|
||||||
"PLINE",
|
"PLINE",
|
||||||
"QSELECT",
|
"QSELECT",
|
||||||
"SETVAR",
|
"SETVAR",
|
||||||
|
"STYLE",
|
||||||
"ZOOM",
|
"ZOOM",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1431,6 +1438,100 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"loop number",
|
"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`
|
- Test: `tests/test_standards_presets.py`
|
||||||
|
|
||||||
**Interfaces:**
|
**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.
|
- 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.
|
- 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).
|
#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted).
|
||||||
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
|
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
|
||||||
#: * ``("char",)`` — exactly one character.
|
#: * ``("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.
|
#: * ``("name",)`` — a non-empty DXF symbol-table name.
|
||||||
#: * a ``set`` — one of its members.
|
#: * a ``set`` — one of its members.
|
||||||
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
||||||
@@ -3838,15 +3840,20 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
|||||||
"DIMDSEP": ("char",),
|
"DIMDSEP": ("char",),
|
||||||
"DIMLUNIT": ("int", 1, 6),
|
"DIMLUNIT": ("int", 1, 6),
|
||||||
"DIMZIN": ("int", 0, 15), # a bitmask
|
"DIMZIN": ("int", 0, 15), # a bitmask
|
||||||
"DIMBLK": ("str",),
|
"DIMBLK": ("arrow",),
|
||||||
"DIMTXSTY": ("name",),
|
"DIMTXSTY": ("name",),
|
||||||
"DIMLWD": LINEWEIGHT_CODES,
|
"DIMLWD": LINEWEIGHT_CODES,
|
||||||
"DIMLWE": LINEWEIGHT_CODES,
|
"DIMLWE": LINEWEIGHT_CODES,
|
||||||
"DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport"
|
"DIMSCALE": ("float", 0.0, 1e6), # 0 means "scale to the viewport"
|
||||||
# tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist
|
# tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist
|
||||||
"DIMTOL": {0, 1},
|
"DIMTOL": {0, 1},
|
||||||
"DIMTP": ("float", 0.0, 1e6),
|
# Both signed, as AutoCAD documents them: the displayed lower deviation is
|
||||||
"DIMTM": ("float", 0.0, 1e6),
|
# -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),
|
"DIMTOLJ": ("int", 0, 2),
|
||||||
"DIMTFAC": ("float", 1e-9, 1e6),
|
"DIMTFAC": ("float", 1e-9, 1e6),
|
||||||
"DIMLFAC": ("nonzero_float", -1e6, 1e6),
|
"DIMLFAC": ("nonzero_float", -1e6, 1e6),
|
||||||
@@ -3857,8 +3864,8 @@ DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
|
|||||||
"DIMCLRE": ("int", 0, 256),
|
"DIMCLRE": ("int", 0, 256),
|
||||||
"DIMCLRT": ("int", 0, 256),
|
"DIMCLRT": ("int", 0, 256),
|
||||||
"DIMSAH": {0, 1},
|
"DIMSAH": {0, 1},
|
||||||
"DIMBLK1": ("str",),
|
"DIMBLK1": ("arrow",),
|
||||||
"DIMBLK2": ("str",),
|
"DIMBLK2": ("arrow",),
|
||||||
"DIMCEN": ("float", -1e6, 1e6), # negative draws centre lines, 0 none
|
"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`
|
- Test: `tests/test_styles.py`
|
||||||
|
|
||||||
**Interfaces:**
|
**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.
|
- 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`.
|
- 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
|
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):
|
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))
|
await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
|
||||||
result = await backend.dimstyle_set_current("iso-25")
|
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
|
return None if attrib is None else attrib.default
|
||||||
|
|
||||||
|
|
||||||
|
_ARROWHEAD_VARS = ("DIMBLK", "DIMBLK1", "DIMBLK2")
|
||||||
|
|
||||||
|
|
||||||
def _dimstyle_value(style, var: str):
|
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()
|
attr = var.lower()
|
||||||
raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None
|
raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None
|
||||||
if raw is None:
|
if raw is None:
|
||||||
@@ -5030,6 +5083,12 @@ def _dimstyle_value(style, var: str):
|
|||||||
return "," if code == 0 else chr(code)
|
return "," if code == 0 else chr(code)
|
||||||
if var == "DIMTXSTY":
|
if var == "DIMTXSTY":
|
||||||
return str(raw) if raw else "Standard"
|
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
|
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)}
|
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:
|
def _write_dimstyle_values(style, values: dict[str, Any]) -> None:
|
||||||
"""Store typed values (from ``validate_overrides``) on a DIMSTYLE entry.
|
"""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
|
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
|
*stored* 0.0 and rounds every dimension to a whole unit, so it is
|
||||||
discarded rather than stored (the `_normalise_dimstyle_rounding` lesson).
|
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():
|
for var, value in values.items():
|
||||||
attr = var.lower()
|
attr = var.lower()
|
||||||
if var == "DIMRND" and float(value) == 0.0:
|
if var == "DIMRND" and float(value) == 0.0:
|
||||||
style.dxf.discard("dimrnd")
|
style.dxf.discard("dimrnd")
|
||||||
continue
|
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:
|
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"
|
"use dimstyle_modify to change it"
|
||||||
)
|
)
|
||||||
previous = _current_dimstyle_name(doc)
|
previous = _current_dimstyle_name(doc)
|
||||||
|
_require_arrowhead_blocks(doc, typed)
|
||||||
txsty, textstyle_created = _ensure_textstyle(
|
txsty, textstyle_created = _ensure_textstyle(
|
||||||
doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
|
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; "
|
f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
|
||||||
"create it first with textstyle_create"
|
"create it first with textstyle_create"
|
||||||
)
|
)
|
||||||
|
_require_arrowhead_blocks(doc, typed)
|
||||||
before = _read_dimstyle_values(style, tuple(typed))
|
before = _read_dimstyle_values(style, tuple(typed))
|
||||||
_write_dimstyle_values(style, typed)
|
_write_dimstyle_values(style, typed)
|
||||||
after = _read_dimstyle_values(style, tuple(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)
|
return "," if raw == 0 else chr(raw)
|
||||||
text = str(raw)
|
text = str(raw)
|
||||||
return text[:1] if text else ","
|
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 str(raw)
|
||||||
return 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_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).
|
- `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.
|
- `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.
|
- `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)
|
## 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)
|
### 8.1 Capability keys (added to **both** maps)
|
||||||
|
|
||||||
`mleaderstyle` (COM false), `dwgprops` (ezdxf false: summary fields), `dwt_write`
|
`dwgprops` (ezdxf false: summary fields), `dwt_write` (ezdxf false),
|
||||||
(ezdxf false), `live_application`, `preferences`, `interactive_prompt` (ezdxf
|
`live_application`, `preferences`, `interactive_prompt` (ezdxf false).
|
||||||
false). `tests/test_capability_contract.py` enforces both maps.
|
`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
|
### 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
|
(A0 841×1189 … A4 210×297; ANSI A 216×279 … E 864×1118), every catalog
|
||||||
sysvar has type+meaning+engines.
|
sysvar has type+meaning+engines.
|
||||||
- Dual-engine tests for every tool (ezdxf real; COM fake-ActiveX asserting call
|
- 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`,
|
`Documents.Item().Activate`, `Dictionaries.Add`+`AddXRecord`, `Views.Add`,
|
||||||
`UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`,
|
`UserCoordinateSystems.Add`, `Preferences.OpenSave.*`, `SummaryInfo.*`,
|
||||||
`Utility.GetPoint`).
|
`Utility.GetPoint`).
|
||||||
|
|||||||
+143
-2
@@ -3,10 +3,10 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 166,
|
"tools": 176,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 20
|
"groups": 21
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
|
"styles": 10,
|
||||||
"system": 7,
|
"system": 7,
|
||||||
"templates": 2,
|
"templates": 2,
|
||||||
"transactions": 3,
|
"transactions": 3,
|
||||||
@@ -450,6 +451,62 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "drawing_apply_iso_layers",
|
||||||
"group": "premium",
|
"group": "premium",
|
||||||
@@ -464,6 +521,20 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "drawing_audit",
|
||||||
"group": "drawing",
|
"group": "drawing",
|
||||||
@@ -1717,6 +1788,34 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "pid_equipment_list",
|
||||||
"group": "pid",
|
"group": "pid",
|
||||||
@@ -2155,6 +2254,48 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "titleblock_apply_iso_a3",
|
||||||
"group": "engineering",
|
"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",
|
"validation",
|
||||||
"analysis",
|
"analysis",
|
||||||
"block",
|
"block",
|
||||||
|
"style",
|
||||||
"layer",
|
"layer",
|
||||||
"linetype",
|
"linetype",
|
||||||
"create",
|
"create",
|
||||||
@@ -728,6 +729,7 @@ _GROUP_TAG_LABELS = {
|
|||||||
"analysis": "analysis",
|
"analysis": "analysis",
|
||||||
"validation": "validation",
|
"validation": "validation",
|
||||||
"block": "blocks",
|
"block": "blocks",
|
||||||
|
"style": "styles",
|
||||||
"layer": "layers",
|
"layer": "layers",
|
||||||
"linetype": "layers",
|
"linetype": "layers",
|
||||||
"create": "entity_creation",
|
"create": "entity_creation",
|
||||||
@@ -930,6 +932,12 @@ LEAN_TOOL_NAMES = frozenset(
|
|||||||
"pid_symbol_insert",
|
"pid_symbol_insert",
|
||||||
"pid_line_draw",
|
"pid_line_draw",
|
||||||
"pid_graph",
|
"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)
|
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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Golden query set for tool discovery: English in, one right tool out.
|
"""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
|
**holdout** set. The split is
|
||||||
the point of the file. Ranking work is measured against
|
the point of the file. Ranking work is measured against
|
||||||
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
|
||||||
@@ -143,6 +143,18 @@ TUNING_CASES: tuple[GoldenCase, ...] = (
|
|||||||
"counting",
|
"counting",
|
||||||
risk="read",
|
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)
|
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
||||||
joined; the `pid`
|
joined; the `pid`
|
||||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||||
`pid` rather than under their secondary `query` / `create` tags. Every
|
`pid` rather than under their secondary `query` / `create` tags. `styles`
|
||||||
other number here has been unchanged since the snapshot was taken.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -537,10 +543,11 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"pid": 9,
|
"pid": 9,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
"solids": 5,
|
"solids": 5,
|
||||||
|
"styles": 10,
|
||||||
"system": 7,
|
"system": 7,
|
||||||
"templates": 2,
|
"templates": 2,
|
||||||
"transactions": 3,
|
"transactions": 3,
|
||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 166
|
assert sum(sizes.values()) == 176
|
||||||
|
|||||||
Reference in New Issue
Block a user