From a60f801205e8296d527a30c994d23486b8fb8d23 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:44:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(server):=20SECTION=2018=20styles=20?= =?UTF-8?q?=E2=80=94=20dimstyle/textstyle/mleaderstyle=20tools=20with=20DI?= =?UTF-8?q?MSTYLE/STYLE/MLEADERSTYLE=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- discovery/aliases.py | 90 +++++++++++++ docs/tool-inventory.json | 131 ++++++++++++++++++- server.py | 249 ++++++++++++++++++++++++++++++++++++ tests/test_styles.py | 80 ++++++++++++ tests/test_tool_registry.py | 11 +- 6 files changed, 557 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0864612..2779664 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:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 175 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 e8b9af9..c560d82 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -72,20 +72,27 @@ class ToolAliases: # PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run # between two ports (pid_line_draw), which is an LWPOLYLINE with a # class, a number and markers; a drafter typing it could mean either. +# DIMSTYLE, STYLE, MLEADERSTYLE -- one dialog each in AutoCAD for +# list / create / modify / set-current; this server splits each into +# discrete style tools (track E), so the command is a correct +# destination for every one of them. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( { "ARRAY", "BLOCK", + "DIMSTYLE", "ERASE", "INSERT", "LAYER", "LAYOUT", "MEASUREGEOM", + "MLEADERSTYLE", "PLINE", "QSELECT", "SETVAR", + "STYLE", "ZOOM", } ) @@ -1427,6 +1434,89 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "loop number", ), ), + # ── Styles (track E, group S) ─────────────────────────────────────────── + "dimstyle_create": ToolAliases( + acad=("DIMSTYLE", "DDIM"), + synonyms=( + "new dimension style", + "create a dimension style", + "iso-25 dimension style", + "ansi dimension style", + "dimension style from a preset", + "set up dimension text height and arrows", + ), + ), + "dimstyle_list": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "list dimension styles", + "dimension styles in the drawing", + "dimension style table", + "current dimstyle", + "dim styles", + ), + ), + "dimstyle_modify": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "change a dimension style", + "edit the dimension style", + "change dimension text height for the whole drawing", + "set dimdec on a style", + "modify dimstyle variables", + ), + ), + "dimstyle_set_current": ToolAliases( + acad=("DIMSTYLE",), + synonyms=( + "current dimension style", + "make this dimension style current", + "switch dimension style", + "use iso-25 for new dimensions", + "activate a dimstyle", + ), + ), + "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", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 264451c..8543e5b 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,10 +3,10 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 166, + "tools": 175, "resources": 8, "prompts": 5, - "groups": 20 + "groups": 21 }, "groups": { "analysis": 12, @@ -24,6 +24,7 @@ "pid": 9, "premium": 12, "solids": 5, + "styles": 9, "system": 7, "templates": 2, "transactions": 3, @@ -450,6 +451,62 @@ "readOnlyHint": false } }, + { + "name": "dimstyle_create", + "group": "styles", + "cost": "mutate", + "summary": "Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.", + "tags": [ + "create", + "style" + ], + "annotations": { + "title": "Styles: Create Dimension Style", + "readOnlyHint": false + } + }, + { + "name": "dimstyle_list", + "group": "styles", + "cost": "read", + "summary": "List dimension styles with their ISO-25/ANSI variables and the current one.", + "tags": [ + "query", + "style" + ], + "annotations": { + "title": "Styles: List Dimension Styles", + "readOnlyHint": true + } + }, + { + "name": "dimstyle_modify", + "group": "styles", + "cost": "mutate", + "summary": "Change DIM* variables on an existing dimension style; reports only what moved.", + "tags": [ + "modify", + "style" + ], + "annotations": { + "title": "Styles: Modify Dimension Style", + "readOnlyHint": false + } + }, + { + "name": "dimstyle_set_current", + "group": "styles", + "cost": "safe", + "summary": "Make a dimension style current so new dimensions use it.", + "tags": [ + "modify", + "style" + ], + "annotations": { + "title": "Styles: Set Current Dimension Style", + "readOnlyHint": false + } + }, { "name": "drawing_apply_iso_layers", "group": "premium", @@ -1717,6 +1774,34 @@ "readOnlyHint": false } }, + { + "name": "mleaderstyle_create", + "group": "styles", + "cost": "mutate", + "summary": "Create a multileader style from the ISO or ANSI preset, on both engines.", + "tags": [ + "create", + "style" + ], + "annotations": { + "title": "Styles: Create Multileader Style", + "readOnlyHint": false + } + }, + { + "name": "mleaderstyle_list", + "group": "styles", + "cost": "read", + "summary": "List multileader styles: arrow size, landing gap, text style and height.", + "tags": [ + "query", + "style" + ], + "annotations": { + "title": "Styles: List Multileader Styles", + "readOnlyHint": true + } + }, { "name": "pid_equipment_list", "group": "pid", @@ -2155,6 +2240,48 @@ "destructiveHint": false } }, + { + "name": "textstyle_create", + "group": "styles", + "cost": "mutate", + "summary": "Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.", + "tags": [ + "create", + "style" + ], + "annotations": { + "title": "Styles: Create Text Style", + "readOnlyHint": false + } + }, + { + "name": "textstyle_list", + "group": "styles", + "cost": "read", + "summary": "List text styles: font file, height, width factor, oblique angle.", + "tags": [ + "query", + "style" + ], + "annotations": { + "title": "Styles: List Text Styles", + "readOnlyHint": true + } + }, + { + "name": "textstyle_set_current", + "group": "styles", + "cost": "safe", + "summary": "Make a text style current so new text uses it.", + "tags": [ + "modify", + "style" + ], + "annotations": { + "title": "Styles: Set Current Text Style", + "readOnlyHint": false + } + }, { "name": "titleblock_apply_iso_a3", "group": "engineering", diff --git a/server.py b/server.py index 27a06b2..9e22520 100644 --- a/server.py +++ b/server.py @@ -703,6 +703,7 @@ _GROUP_TAG_PRIORITY = ( "validation", "analysis", "block", + "style", "layer", "linetype", "create", @@ -728,6 +729,7 @@ _GROUP_TAG_LABELS = { "analysis": "analysis", "validation": "validation", "block": "blocks", + "style": "styles", "layer": "layers", "linetype": "layers", "create": "entity_creation", @@ -6815,6 +6817,253 @@ async def pid_tag_parse( return parse_tag(tag, kind, equipment_prefixes) +# --------------------------------------------------------------------------- +# ── SECTION 18: Styles (9 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 + DIMTXSTY that is neither an existing text style nor a bundled preset + (ISOCP, ISOCPEUR, ARIAL, ROMANS — those are created, `textstyle_created`). + 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 — 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 (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. + + A font that is neither a bundled preset nor on the engine's font path is + *written* and reported `font_resolved: false` (AutoCAD substitutes at + open; the headless renderer falls back) — never refused, because a DXF + stores only the name. Refuses, before any write: 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, `textstyle_created`). + """ + 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) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_styles.py b/tests/test_styles.py index 82594ba..7bb8eaa 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -1007,3 +1007,83 @@ async def test_com_mleaderstyle_create_adds_the_dictionary_to_a_drawing_without_ assert [obj.Name for obj in document.mleader_dictionary.objects] == ["ISO"] rows = await backend.mleaderstyle_list() assert [row["name"] for row in rows] == ["ISO"] and rows[0]["arrow_size"] == 2.5 + + +# ── the MCP tools (SECTION 18) ────────────────────────────────────────────── + +STYLE_TOOLS = { + "dimstyle_list", + "dimstyle_create", + "dimstyle_modify", + "dimstyle_set_current", + "textstyle_list", + "textstyle_create", + "textstyle_set_current", + "mleaderstyle_list", + "mleaderstyle_create", +} + + +class _Ctx: + """Enough of a fastmcp Context for a direct tool call (tests/test_capabilities.py).""" + + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *_args, **_kwargs): + return None + + +async def test_style_tools_are_registered_and_grouped_under_styles(): + import server + + names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)} + assert STYLE_TOOLS <= names + groups = await server._tool_groups() + assert STYLE_TOOLS <= set(groups["styles"]), "the style tag must win over query/create/modify" + for other in ("layers", "drawing", "entity_query", "entity_creation", "entity_modification"): + assert not STYLE_TOOLS & set(groups.get(other, [])), other + + +async def test_dimstyle_tools_resolve_the_preset_then_write(backend): + import server + + ctx = _Ctx(backend) + created = await server.dimstyle_create( + name="ISO-25", preset="iso-25", overrides={"dimdec": 3}, set_current=True, ctx=ctx + ) + assert created["current"] is True and created["values"]["DIMDEC"] == 3 + listed = await server.dimstyle_list(ctx=ctx) + assert listed["count"] == 2 and listed["current"] == "ISO-25" + modified = await server.dimstyle_modify(name="ISO-25", overrides={"DIMTXT": 3.5}, ctx=ctx) + assert modified["changed"] == {"DIMTXT": [2.5, 3.5]} + switched = await server.dimstyle_set_current(name="Standard", ctx=ctx) + assert switched["previous"] == "ISO-25" and switched["current"] == "Standard" + + +async def test_dimstyle_create_tool_refuses_an_unknown_override_before_writing(backend): + import server + + ctx = _Ctx(backend) + with pytest.raises(ValueError, match="DIMFOO"): + await server.dimstyle_create(name="X", overrides={"DIMFOO": 1}, ctx=ctx) + with pytest.raises(ValueError, match="preset"): + await server.dimstyle_create(name="X", preset="din", ctx=ctx) + assert (await server.dimstyle_list(ctx=ctx))["count"] == 1 + + +async def test_textstyle_and_mleaderstyle_tools_round_trip(backend): + import server + + ctx = _Ctx(backend) + made = await server.textstyle_create(name="ISOCP", font="isocp", set_current=True, ctx=ctx) + assert made["font_resolved"] is True + assert (await server.textstyle_list(ctx=ctx))["current"] == "ISOCP" + assert (await server.textstyle_set_current(name="Standard", ctx=ctx))["previous"] == "ISOCP" + leader = await server.mleaderstyle_create( + name="ISO", preset="iso", overrides={"arrow_size": 3}, ctx=ctx + ) + assert leader["values"]["arrow_size"] == 3.0 and leader["textstyle_created"] is False + assert (await server.mleaderstyle_list(ctx=ctx))["count"] == 2 + with pytest.raises(ValueError, match="preset"): + await server.mleaderstyle_create(name="X", preset="din", ctx=ctx) diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 13ab419..9402ac1 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -517,8 +517,12 @@ async def test_tool_group_sizes_are_unchanged(): when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool) joined; the `pid` tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under - `pid` rather than under their secondary `query` / `create` tags. Every - other number here has been unchanged since the snapshot was taken. + `pid` rather than under their secondary `query` / `create` tags. `styles` + appeared (0 -> 9) when track E's SECTION 18 opened with the dimension, + text and multileader style tools; 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 + unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -537,10 +541,11 @@ async def test_tool_group_sizes_are_unchanged(): "pid": 9, "premium": 12, "solids": 5, + "styles": 9, "system": 7, "templates": 2, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 166 + assert sum(sizes.values()) == 175