diff --git a/README.md b/README.md index 2779664..dd53dd3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 175 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 176 tools · 8 resources · 5 prompt templates · 2423 collected tests. > 166 is the **registered** count; a default install advertises 161 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/discovery/aliases.py b/discovery/aliases.py index c560d82..8b2be49 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1476,6 +1476,17 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "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=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 8543e5b..4f90917 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 175, + "tools": 176, "resources": 8, "prompts": 5, "groups": 21 @@ -24,7 +24,7 @@ "pid": 9, "premium": 12, "solids": 5, - "styles": 9, + "styles": 10, "system": 7, "templates": 2, "transactions": 3, @@ -521,6 +521,20 @@ "destructiveHint": false } }, + { + "name": "drawing_apply_standard", + "group": "styles", + "cost": "mutate", + "summary": "Put the drawing on ISO or ANSI in one call: dimstyle, text style, units, mech layers.", + "tags": [ + "drawing", + "style" + ], + "annotations": { + "title": "Drawing: Apply Drafting Standard", + "readOnlyHint": false + } + }, { "name": "drawing_audit", "group": "drawing", diff --git a/engineering/standards/apply.py b/engineering/standards/apply.py new file mode 100644 index 0000000..dd6b30d --- /dev/null +++ b/engineering/standards/apply.py @@ -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, + } diff --git a/server.py b/server.py index 5e0723a..e98d8aa 100644 --- a/server.py +++ b/server.py @@ -6818,7 +6818,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 18: Styles (9 tools) ──────────────────────────────────────────── +# ── SECTION 18: Styles (10 tools) ─────────────────────────────────────────── # --------------------------------------------------------------------------- # # Pack: core (drafting essentials). Lean profile: dimstyle_set_current and @@ -7076,6 +7076,42 @@ async def mleaderstyle_create( 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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_styles.py b/tests/test_styles.py index c81be91..c4995cc 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -999,7 +999,9 @@ def com_backend(monkeypatch, tmp_path): monkeypatch.setattr(_FakeStyle, "support_folder", support) document = _FakeDocument() app = types.SimpleNamespace( - Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=str(support))) + Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=str(support))), + GetVariable=document.GetVariable, + SetVariable=document.SetVariable, ) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) @@ -1626,3 +1628,91 @@ async def test_truetype_face_reads_family_and_subfamily(tmp_path): assert _truetype_face(windows_arial) == ("Arial", False, False) assert _com_font_path(str(regular)) == regular assert _com_font_path(str(tmp_path / "missing.ttf")) is None + + +# ── drawing_apply_standard ────────────────────────────────────────────────── + + +async def test_apply_iso_then_dimension_linear_carries_iso25(backend): + from engineering.standards.apply import apply_standard + + result = await apply_standard(backend, "iso") + assert result["standard"] == "iso" + assert result["dimstyle"] == {"name": "ISO-25", "created": True, "current": True} + assert result["textstyle"] == {"name": "ISOCP", "created": True, "current": True} + assert result["settings"]["changed"] == {"INSUNITS": [6, 4]}, "ezdxf.new() starts in metres" + assert result["layers"]["layer_set"] == "mech" + assert result["layers"]["layers"]["DIM"] in ("created", "exists") + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5]) + assert backend._doc.header["$TEXTSTYLE"] == "ISOCP" + assert await backend.system_get_variable("LUNITS") == 2 + assert await backend.system_get_variable("INSUNITS") == 4 + + +async def test_apply_ansi_then_dimension_linear_carries_ansi(backend): + from engineering.standards.apply import apply_standard + + result = await apply_standard(backend, "ANSI", layers=False) + assert result["dimstyle"]["name"] == "ANSI" and result["textstyle"]["name"] == "ROMANS" + assert result["layers"] is None + dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30) + assert _rendered(backend, dim.handle) == ("33.33", 3.0, [3.0]) + + +async def test_second_apply_is_idempotent_and_reports_nothing_created(backend): + from engineering.standards.apply import apply_standard + + await apply_standard(backend, "iso") + again = await apply_standard(backend, "iso") + assert again["dimstyle"]["created"] is False and again["textstyle"]["created"] is False + assert again["settings"]["changed"] == {} + assert set(again["layers"]["layers"].values()) == {"exists"} + swapped = await apply_standard(backend, "ansi", layers=False) + assert swapped["dimstyle"]["created"] is True + assert (await backend.dimstyle_list())[0]["name"] == "ANSI" + back = await apply_standard(backend, "iso", layers=False, units=False) + assert back["dimstyle"]["created"] is False and back["settings"] == { + "changed": {}, + "applied": False, + } + assert backend._doc.header["$DIMSTYLE"] == "ISO-25" + + +async def test_apply_refuses_an_unknown_standard_before_writing(backend): + from engineering.standards.apply import apply_standard + + with pytest.raises(ValueError, match="standard"): + await apply_standard(backend, "din") + with pytest.raises(TypeError, match="layers"): + await apply_standard(backend, "iso", layers="yes") + assert [r["name"] for r in await backend.dimstyle_list()] == ["Standard"] + assert await backend.system_get_variable("INSUNITS") == 6 + + +async def test_apply_standard_tool_is_registered_in_the_styles_group(backend): + import server + + names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)} + assert "drawing_apply_standard" in names + styles_group = set((await server._tool_groups())["styles"]) + assert styles_group == STYLE_TOOLS | {"drawing_apply_standard"}, "SECTION 18 is exactly ten" + result = await server.drawing_apply_standard(standard="iso", layers=False, ctx=_Ctx(backend)) + assert result["dimstyle"]["created"] is True + + +async def test_com_apply_standard_creates_both_styles_and_sets_units(com_backend): + from engineering.standards.apply import apply_standard + + backend, document = com_backend + document.variables.update( + {"INSUNITS": 1, "LUNITS": 4, "AUNITS": 0, "LTSCALE": 1.0, "DIMSCALE": 1.0} + ) + result = await apply_standard(backend, "iso", layers=False) + assert result["dimstyle"]["created"] is True and result["textstyle"]["created"] is True + assert result["settings"]["changed"] == {"INSUNITS": [1, 4], "LUNITS": [4, 2]} + names = [call[0] for call in document.calls] + assert names[0] == "TextStyles.Add" and "DimStyles.Add" in names + assert ("ActiveTextStyle", "ISOCP") in document.calls + assert document.ActiveDimStyle.Name == "ISO-25" + assert document.variables["DIMDSEP"] == "," and document.variables["INSUNITS"] == 4 diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 9402ac1..6a25e04 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -519,9 +519,11 @@ async def test_tool_group_sizes_are_unchanged(): tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. `styles` appeared (0 -> 9) when track E's SECTION 18 opened with the dimension, - text and multileader style tools; the `style` tag sits ahead of `layer` - in `_GROUP_TAG_PRIORITY` so they never file under their secondary - `query` / `create` / `modify` tags. Every other number here has been + 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()} @@ -541,11 +543,11 @@ async def test_tool_group_sizes_are_unchanged(): "pid": 9, "premium": 12, "solids": 5, - "styles": 9, + "styles": 10, "system": 7, "templates": 2, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 175 + assert sum(sizes.values()) == 176