feat(server): SECTION 18 styles — dimstyle/textstyle/mleaderstyle tools with DIMSTYLE/STYLE/MLEADERSTYLE aliases

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 21:44:02 +03:00
co-authored by Claude Opus 5
parent 266b6fb838
commit a60f801205
6 changed files with 557 additions and 6 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
> **v1.5 release snapshot:** 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.
+90
View File
@@ -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",
),
),
}
+129 -2
View File
@@ -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",
+249
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+80
View File
@@ -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)
+8 -3
View File
@@ -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