diff --git a/README.md b/README.md
index 6d056a1..a632df5 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
-> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
+> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+210 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index f139c03..5f0df86 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -1859,6 +1859,96 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"save a detail view",
),
),
+ # ── Mechanical parts (SECTION 21, tracks B+G group M) ───────────────────
+ "mech_part_draw": ToolAliases(
+ acad=(),
+ synonyms=(
+ "draw a shaft",
+ "stepped shaft",
+ "draw a machine part",
+ "turned part",
+ "draw a plate",
+ "flange",
+ "part from a segment list",
+ "shaft with a keyway",
+ "mil",
+ "mil ciz",
+ "kademeli mil",
+ "kama",
+ "pah",
+ "plaka",
+ "flans",
+ ),
+ ),
+ "mech_view_add": ToolAliases(
+ acad=("SECTIONPLANE",),
+ synonyms=(
+ "add a section view",
+ "section a-a",
+ "cross section",
+ "half section",
+ "offset section",
+ "detail view",
+ "blown-up detail",
+ "end view",
+ "side view",
+ "another view of the part",
+ "kesit",
+ "kesit al",
+ "gorunus",
+ "yan gorunus",
+ "detay",
+ ),
+ ),
+ "mech_dimension_part": ToolAliases(
+ acad=(),
+ synonyms=(
+ "dimension the part",
+ "put the dimensions on",
+ "chain dimensions",
+ "baseline dimensions",
+ "ordinate dimensions",
+ "hole table",
+ "iso 286 fit on a dimension",
+ "h7 bore",
+ "olculendir",
+ "olcu ver",
+ "tolerans",
+ ),
+ ),
+ "mech_hole_pattern": ToolAliases(
+ acad=("ARRAY",),
+ synonyms=(
+ "bolt circle",
+ "hole pattern",
+ "hole array",
+ "pitch circle of holes",
+ "grid of holes",
+ "delik deseni",
+ "civata dairesi",
+ "delik aynasi",
+ ),
+ ),
+ "mech_part_from_spec": ToolAliases(
+ acad=(),
+ synonyms=(
+ "draw the whole sheet",
+ "several parts at once",
+ "mechanical sheet from a spec",
+ "batch draw parts",
+ "parca sayfasi",
+ ),
+ ),
+ "mech_part_inspect": ToolAliases(
+ acad=(),
+ synonyms=(
+ "what part is this",
+ "read the part model back",
+ "part features",
+ "which views were drawn",
+ "parca bilgisi",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index c5c16f1..55bb8ab 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": 204,
+ "tools": 210,
"resources": 8,
"prompts": 5,
- "groups": 23
+ "groups": 24
},
"groups": {
"analysis": 12,
@@ -22,6 +22,7 @@
"environment": 22,
"layers": 14,
"layouts": 12,
+ "mech": 6,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -1968,6 +1969,90 @@
"readOnlyHint": false
}
},
+ {
+ "name": "mech_dimension_part",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
+ "tags": [
+ "dimension",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Dimension Part",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_hole_pattern",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Hole Pattern",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_part_draw",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw a machine part from a segment list or an outline plus typed features.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Draw Part",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_part_from_spec",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Sheet From Spec",
+ "destructiveHint": false
+ }
+ },
+ {
+ "name": "mech_part_inspect",
+ "group": "mech",
+ "cost": "read",
+ "summary": "Read the part model back off the drawing: segments, features, views, omissions.",
+ "tags": [
+ "mech",
+ "query"
+ ],
+ "annotations": {
+ "title": "Mechanical: Inspect Part",
+ "readOnlyHint": true
+ }
+ },
+ {
+ "name": "mech_view_add",
+ "group": "mech",
+ "cost": "mutate",
+ "summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
+ "tags": [
+ "create",
+ "mech"
+ ],
+ "annotations": {
+ "title": "Mechanical: Add View",
+ "destructiveHint": false
+ }
+ },
{
"name": "mleaderstyle_create",
"group": "styles",
diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py
index da0eaa4..2afd332 100644
--- a/engineering/mech/__init__.py
+++ b/engineering/mech/__init__.py
@@ -22,6 +22,16 @@ from .dimension import (
segment_key,
text_rects,
)
+from .draw import (
+ ANCHOR_LAYER,
+ add_view,
+ dimension_part,
+ draw_from_spec,
+ draw_hole_pattern,
+ draw_part,
+ draw_prims,
+ read_part,
+)
from .features import (
FEATURE_KINDS,
AxialBore,
@@ -105,6 +115,7 @@ from .views import (
)
__all__ = [
+ "ANCHOR_LAYER",
"Arc",
"AxialBore",
"Band",
@@ -153,6 +164,7 @@ __all__ = [
"Undercut",
"VIEW_KINDS",
"View",
+ "add_view",
"bbox",
"build_part",
"build_view",
@@ -160,6 +172,11 @@ __all__ = [
"detail_marker_prims",
"detail_view",
"dimension_intents",
+ "dimension_part",
+ "draw_from_spec",
+ "draw_hole_pattern",
+ "draw_part",
+ "draw_prims",
"feature_from_dict",
"hole_table_rows",
"inner_radius_at",
@@ -180,6 +197,7 @@ __all__ = [
"point_in_outline",
"points_of",
"radial_profile",
+ "read_part",
"redundant_linear",
"resolve_tolerance",
"rotate",
diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py
new file mode 100644
index 0000000..cb4a598
--- /dev/null
+++ b/engineering/mech/draw.py
@@ -0,0 +1,804 @@
+"""The only module in `engineering/mech/` that touches a backend.
+
+Everything above it is pure: the part model, the features, the view engine and
+the dimension layout take numbers and return primitive *descriptions*. This
+module turns those descriptions into `entity_create_*` / `dimension_*` calls,
+places them in WCS, and writes the part model back onto the drawing as
+`ACADMCP_MECH` XDATA so `mech_view_add` can add a view months later without the
+caller re-describing the part.
+
+It is not a contract member and neither are the tools above it - exactly like
+`gear_draw_*`, `keyway_draw_*` and `titleblock_apply_iso_a3`, it composes the
+generic members, so it is written once and works on both engines by
+construction.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+from typing import Any
+
+import anyio
+
+from engineering.mech.dimension import (
+ dimension_intents,
+ hole_table_rows,
+ intent_text,
+ layout_dimensions,
+ resolve_tolerance,
+)
+from engineering.mech.features import HolePattern
+from engineering.mech.part import build_part, part_to_dict, validate_part
+from engineering.mech.primitives import (
+ ROLE_LAYER,
+ Arc,
+ Circle,
+ DimIntent,
+ HatchArea,
+ Line,
+ Poly,
+ Text,
+ layer_for,
+ rotate,
+ translate,
+)
+from engineering.mech.standards.materials import hatch_for
+from engineering.mech.views import (
+ View,
+ build_view,
+ detail_marker_prims,
+ layout_origin,
+ normalise_plane,
+)
+from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values
+
+log = logging.getLogger(__name__)
+
+#: The layer the invisible per-part anchor POINT lives on. It carries the
+#: ACADMCP_MECH payload and nothing else, so `read_part` can find every part in
+#: a drawing with one filtered `entity_list`.
+ANCHOR_LAYER = "MECH"
+
+DEFAULT_GAP = 20.0
+
+#: How far a standalone hole pattern's ISO 128-23 centre mark reaches past the
+#: hole it marks, in drawing units. DECLARED, not standard: ISO 128-23 fixes
+#: that the mark overruns the circle, not by how much.
+CENTRE_MARK_OVERRUN = 3.0
+
+
+# -- the XDATA adapter -------------------------------------------------------
+#
+# `engineering/mech/xdata.encode` returns DXF (group code, value) rows, which is
+# the on-disk shape. `entity_set_xdata` takes plain JSON values and assigns the
+# codes itself through backends/xdata_specs.py, so the 1001 APPID row is the
+# backend's business and only the 1000 chunks travel. `to_values(payload)` is
+# that projection and it lives in `engineering/mech/xdata.py` (Task 1) - every
+# writer in this repository calls it rather than re-deriving the split, so a
+# change to the chunking is made in one place.
+
+
+def _xdata_payload(values) -> dict:
+ return decode(values)
+
+
+def _number(value, where: str, *, positive: bool = True) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{where}: expected a number, got {value!r}.") from None
+ if not math.isfinite(number):
+ raise ValueError(f"{where}: must be finite, got {value!r}.")
+ if positive and number <= 0.0:
+ raise ValueError(f"{where}: must be greater than zero, got {number:g}.")
+ return number
+
+
+# -- primitives to entities --------------------------------------------------
+
+
+async def draw_prims(
+ backend,
+ prims,
+ *,
+ at: tuple[float, float] = (0.0, 0.0),
+ rotation: float = 0.0,
+ layer_prefix: str = "",
+) -> dict:
+ """Draw primitive descriptions, placed at ``at`` and turned by ``rotation``."""
+ placed = tuple(prims)
+ if abs(float(rotation)) > 1e-12:
+ placed = rotate(placed, float(rotation), (0.0, 0.0))
+ if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12:
+ placed = translate(placed, float(at[0]), float(at[1]))
+
+ handles: list[str] = []
+ counts: dict[str, int] = {}
+
+ for prim in placed:
+ layer = f"{layer_prefix}{layer_for(prim)}"
+ if isinstance(prim, Line):
+ info = await backend.entity_create_line(
+ prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer
+ )
+ elif isinstance(prim, Arc):
+ info = await backend.entity_create_arc(
+ prim.center[0],
+ prim.center[1],
+ prim.radius,
+ prim.start_deg,
+ prim.end_deg,
+ layer=layer,
+ )
+ elif isinstance(prim, Circle):
+ info = await backend.entity_create_circle(
+ prim.center[0], prim.center[1], prim.radius, layer=layer
+ )
+ elif isinstance(prim, Poly):
+ info = await backend.entity_create_polyline(
+ [[x, y] for x, y in prim.points], prim.closed, layer=layer
+ )
+ elif isinstance(prim, Text):
+ info = await backend.entity_create_text(
+ prim.text,
+ prim.at[0],
+ prim.at[1],
+ prim.height,
+ prim.rotation,
+ layer=layer,
+ )
+ elif isinstance(prim, HatchArea):
+ style = hatch_for(prim.material)
+ info = await backend.entity_create_hatch(
+ style["pattern"],
+ [[x, y] for x, y in prim.loops[0]],
+ style["scale"],
+ style["angle"],
+ layer=layer,
+ )
+ for island in prim.loops[1:]:
+ points = list(island)
+ edges = [
+ {
+ "type": "line",
+ "start": [points[i][0], points[i][1]],
+ "end": [points[(i + 1) % len(points)][0], points[(i + 1) % len(points)][1]],
+ }
+ for i in range(len(points))
+ ]
+ await backend.hatch_add_boundary(info.handle, edges)
+ else: # pragma: no cover - the Prim union is closed
+ raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.")
+ handles.append(info.handle)
+ key = type(prim).__name__.lower()
+ counts[key] = counts.get(key, 0) + 1
+
+ return {"handles": handles, "counts": counts}
+
+
+# -- dimensions --------------------------------------------------------------
+
+
+async def _draw_dimension(backend, intent) -> tuple[str | None, dict]:
+ """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row)."""
+ resolved = resolve_tolerance(intent)
+ row = {
+ "feature": intent.feature,
+ "kind": intent.kind,
+ "text": intent_text(intent),
+ "fit": intent.fit,
+ **resolved,
+ }
+ text_at = intent.text_at or (
+ (intent.p1[0] + intent.p2[0]) / 2.0,
+ (intent.p1[1] + intent.p2[1]) / 2.0,
+ )
+ tol = {
+ "tol_upper": resolved["tol_upper"],
+ "tol_lower": resolved["tol_lower"],
+ "tol_mode": resolved["tol_mode"],
+ "text_override": resolved["text_override"],
+ }
+
+ if intent.kind == "linear":
+ horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
+ info = await backend.dimension_linear(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ text_at[0],
+ text_at[1],
+ 0.0 if horizontal else 90.0,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ elif intent.kind == "aligned":
+ if tol["tol_mode"] != "none" or tol["text_override"]:
+ angle = math.degrees(
+ math.atan2(intent.p2[1] - intent.p1[1], intent.p2[0] - intent.p1[0])
+ )
+ # dimension_aligned carries no tolerance arguments, so a toleranced
+ # aligned dimension is drawn as a rotated linear one - the same
+ # measurement, with the tolerance the caller asked for.
+ info = await backend.dimension_linear(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ text_at[0],
+ text_at[1],
+ angle,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ else:
+ info = await backend.dimension_aligned(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ text_at[0],
+ text_at[1],
+ ROLE_LAYER["dim"],
+ )
+ elif intent.kind == "diameter":
+ info = await backend.dimension_diameter(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ 10.0,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ elif intent.kind == "radius":
+ info = await backend.dimension_radius(
+ intent.p1[0],
+ intent.p1[1],
+ intent.p2[0],
+ intent.p2[1],
+ 10.0,
+ ROLE_LAYER["dim"],
+ **tol,
+ )
+ else:
+ return None, {
+ **row,
+ "skipped": "an angular intent needs a vertex, which a DimIntent does not carry; "
+ "no feature in this release emits one",
+ }
+ return info.handle, row
+
+
+# -- the part payload --------------------------------------------------------
+
+
+def _view_record(view: View, offset: tuple[float, float], kw: dict) -> dict:
+ x0, y0, x1, y1 = view.bbox
+ return {
+ "kind": view.kind,
+ "label": view.label,
+ "scale": view.scale,
+ "offset": [float(offset[0]), float(offset[1])],
+ "bbox": [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]],
+ "kw": kw,
+ }
+
+
+def _offset_for(view: View, lower_left: tuple[float, float]) -> tuple[float, float]:
+ return (lower_left[0] - view.bbox[0], lower_left[1] - view.bbox[1])
+
+
+def _placed(view: View, offset) -> View:
+ x0, y0, x1, y1 = view.bbox
+ return View(
+ kind=view.kind,
+ prims=view.prims,
+ dims=view.dims,
+ omitted=view.omitted,
+ bbox=(x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]),
+ label=view.label,
+ scale=view.scale,
+ )
+
+
+def _view_from_record(part, payload: dict, record: dict) -> View:
+ """Rebuild the view a record describes, with the keywords it was drawn with."""
+ kw = record.get("kw") or {}
+ return build_view(
+ part,
+ record["kind"],
+ side=str(kw.get("side", "right")),
+ plane=kw.get("plane"),
+ style=str(kw.get("style", "full")),
+ scale=float(kw.get("scale", 1.0)),
+ projection=str((payload.get("placement") or {}).get("projection", "first")),
+ detail=kw.get("detail"),
+ )
+
+
+async def draw_part(
+ backend,
+ part,
+ *,
+ at: tuple[float, float] = (0.0, 0.0),
+ rotation: float = 0.0,
+ views=("front",),
+ projection: str = "first",
+ dimension: bool = True,
+ style: str = "chain",
+ scale: float = 1.0,
+) -> dict:
+ """Draw a part's views, dimension them, and write the model onto the drawing."""
+ validate_part(part)
+ kinds = [str(kind).strip().lower() for kind in (views or ())]
+ if not kinds:
+ raise ValueError("views: name at least one view to draw.")
+
+ handles: list[str] = []
+ records: list[dict] = []
+ omitted: list[dict] = []
+ parent: View | None = None
+ parent_offset = (0.0, 0.0)
+
+ for index, kind in enumerate(kinds):
+ view = build_view(part, kind, projection=projection, scale=scale)
+ if index == 0:
+ offset = _offset_for(view, (float(at[0]), float(at[1])))
+ else:
+ lower_left = layout_origin(
+ _placed(parent, parent_offset),
+ kind,
+ projection=projection,
+ gap=DEFAULT_GAP,
+ child=view,
+ )
+ offset = _offset_for(view, lower_left)
+ drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation)
+ handles.extend(drawn["handles"])
+ omitted.extend(view.omitted)
+ records.append(_view_record(view, offset, {"scale": scale}))
+ if index == 0:
+ parent, parent_offset = view, offset
+
+ anchor = await backend.entity_create_point(float(at[0]), float(at[1]), layer=ANCHOR_LAYER)
+ payload = {
+ "v": PAYLOAD_VERSION,
+ "kind": "part",
+ "id": anchor.handle,
+ "view": records[0]["kind"],
+ "part": part_to_dict(part),
+ "placement": {
+ "at": [float(at[0]), float(at[1])],
+ "rotation": float(rotation),
+ "projection": projection,
+ },
+ "views": records,
+ }
+ await backend.entity_set_xdata(anchor.handle, APP_ID, to_values(payload))
+
+ result = {
+ "part_id": anchor.handle,
+ "views": records,
+ "handles": handles,
+ "omitted": omitted,
+ "backend": backend.name,
+ }
+ if dimension:
+ dimensioned = await dimension_part(backend, anchor.handle, style=style)
+ result["dimensions"] = dimensioned["count"]
+ result["handles"].extend(dimensioned["handles"])
+ return result
+
+
+async def read_part(backend, part_id: str | None = None) -> dict:
+ """The part model written onto the drawing, read back out of its anchor."""
+ if part_id:
+ raw = await backend.entity_get_xdata(part_id, APP_ID)
+ values = (raw.get("xdata") or {}).get(APP_ID) or []
+ if not values:
+ raise ValueError(f"{part_id}: carries no {APP_ID} part payload.")
+ payload = _xdata_payload(values)
+ return {"part_id": part_id, **payload}
+
+ anchors = await backend.entity_list(type_filter="POINT", layer_filter=ANCHOR_LAYER, limit=1000)
+ found: list[tuple[str, dict]] = []
+ for anchor in anchors:
+ raw = await backend.entity_get_xdata(anchor.handle, APP_ID)
+ values = (raw.get("xdata") or {}).get(APP_ID) or []
+ if values:
+ found.append((anchor.handle, _xdata_payload(values)))
+ if not found:
+ raise ValueError(
+ f"this drawing carries no {APP_ID} part payload; draw one with mech_part_draw."
+ )
+ if len(found) > 1:
+ raise ValueError(
+ "this drawing carries "
+ f"{len(found)} parts ({', '.join(handle for handle, _ in found)}); "
+ "name one with part_id."
+ )
+ handle, payload = found[0]
+ return {"part_id": handle, **payload}
+
+
+async def add_view(backend, part_id: str, kind: str, **kw) -> dict:
+ """Add another view of a part already on the drawing, laid out against its first."""
+ payload = await read_part(backend, part_id)
+ part = build_part(payload["part"])
+ records = list(payload.get("views") or [])
+ if not records:
+ raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.")
+ placement = payload.get("placement") or {}
+ projection = str(placement.get("projection", "first"))
+ rotation = float(placement.get("rotation", 0.0))
+
+ plane = kw.pop("plane", None)
+ if plane is not None:
+ plane = normalise_plane(plane)
+ detail = kw.pop("detail", None)
+ gap = float(kw.pop("gap", DEFAULT_GAP))
+ side = str(kw.pop("side", "right"))
+ style = str(kw.pop("style", "full"))
+ scale = float(kw.pop("scale", 1.0))
+ if kw:
+ raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.")
+
+ view = build_view(
+ part,
+ kind,
+ side=side,
+ plane=plane,
+ style=style,
+ scale=scale,
+ projection=projection,
+ detail=detail,
+ )
+
+ parent_record = records[0]
+ parent_view = _view_from_record(part, payload, parent_record)
+ lower_left = layout_origin(
+ _placed(parent_view, parent_record["offset"]),
+ kind,
+ side=side,
+ projection=projection,
+ gap=gap,
+ child=view,
+ )
+ # step past every view already placed, so a third view never lands on the second
+ width = view.bbox[2] - view.bbox[0]
+ for record in records[1:]:
+ x0, y0, x1, y1 = record["bbox"]
+ del y0, y1
+ if lower_left[0] < x1 + gap and lower_left[0] + width > x0 - gap:
+ lower_left = (x0 - gap - width, lower_left[1])
+ offset = _offset_for(view, lower_left)
+
+ drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation)
+ marker_handles: list[str] = []
+ if kind == "detail":
+ marker = detail_marker_prims(detail or {})
+ marker_drawn = await draw_prims(
+ backend, marker, at=parent_record["offset"], rotation=rotation
+ )
+ marker_handles = marker_drawn["handles"]
+
+ records.append(
+ _view_record(
+ view,
+ offset,
+ {
+ "side": side,
+ "style": style,
+ "scale": scale,
+ "plane": _plane_to_json(plane),
+ "detail": detail,
+ },
+ )
+ )
+ payload["views"] = records
+ await backend.entity_set_xdata(
+ part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"})
+ )
+ return {
+ "part_id": part_id,
+ "view": records[-1],
+ "handles": drawn["handles"] + marker_handles,
+ "omitted": list(view.omitted),
+ "backend": backend.name,
+ }
+
+
+def _plane_to_json(plane: dict | None) -> dict | None:
+ """`normalise_plane` answers with tuples; JSON round-trips them as lists."""
+ if plane is None:
+ return None
+ return {
+ "p1": [float(plane["p1"][0]), float(plane["p1"][1])],
+ "p2": [float(plane["p2"][0]), float(plane["p2"][1])],
+ "label": str(plane["label"]),
+ "direction": [float(plane["direction"][0]), float(plane["direction"][1])],
+ "style": str(plane["style"]),
+ "via": [[float(p[0]), float(p[1])] for p in plane.get("via", ())],
+ }
+
+
+def _fits_for_view(part, view, fits: dict, *, style: str, threshold: int) -> dict:
+ """The subset of ``fits`` this view actually dimensions.
+
+ ``dimension_intents`` refuses a key that names nothing *in the view it is
+ given*, which is right for one view and wrong for a part drawn in three:
+ a fit on ``segment[0]`` names nothing in the end view, and refusing there
+ would make a correct sheet undrawable. So the split happens here and the
+ keys nothing anywhere claims are re-offered to :func:`dimension_intents`
+ afterwards, which refuses them in its own words.
+ """
+ if not fits:
+ return {}
+ plain = dimension_intents(part, view, style=style, hole_table_threshold=threshold)
+ available = {intent.feature for intent in plain if intent.feature}
+ return {key: code for key, code in fits.items() if str(key) in available}
+
+
+async def dimension_part(
+ backend,
+ part_id: str,
+ *,
+ style: str = "chain",
+ hole_table_threshold: int = 8,
+ fits: dict | None = None,
+) -> dict:
+ """Dimension every view of a part, laid out so no two texts overlap."""
+ payload = await read_part(backend, part_id)
+ part = build_part(payload["part"])
+ records = payload.get("views") or []
+ if fits is not None and not isinstance(fits, dict):
+ raise ValueError(
+ f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
+ )
+ fits = dict(fits or {})
+ unclaimed = set(fits)
+
+ handles: list[str] = []
+ resolved_rows: list[dict] = []
+ skipped: list[dict] = []
+ table_handle: str | None = None
+
+ for record in records:
+ view = _view_from_record(part, payload, record)
+ mine = _fits_for_view(part, view, fits, style=style, threshold=int(hole_table_threshold))
+ unclaimed -= set(mine)
+ intents = dimension_intents(
+ part, view, style=style, hole_table_threshold=hole_table_threshold, fits=mine or None
+ )
+ placed = layout_dimensions(view, intents)
+ offset = record["offset"]
+ for intent in placed:
+ moved = intent._replace(
+ p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]),
+ p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]),
+ text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]),
+ )
+ handle, row = await _draw_dimension(backend, moved)
+ if handle is None:
+ skipped.append(row)
+ continue
+ handles.append(handle)
+ resolved_rows.append(row)
+
+ rows = hole_table_rows(part, view, hole_table_threshold=hole_table_threshold)
+ if rows and table_handle is None:
+ info = await backend.entity_create_table(
+ record["bbox"][2] + DEFAULT_GAP,
+ record["bbox"][3],
+ [[r["tag"], f"{r['d']:g}", str(r["qty"]), r["note"]] for r in rows],
+ ["TAG", "DIA", "QTY", "NOTE"],
+ None,
+ 7.0,
+ 2.5,
+ "HOLE TABLE",
+ ROLE_LAYER["text"],
+ )
+ table_handle = info.handle
+ handles.append(info.handle)
+
+ if unclaimed:
+ # No view claimed these keys. Hand them back to the module that owns the
+ # vocabulary so the refusal names the keys that do exist, and the hole
+ # table's own message survives.
+ dimension_intents(
+ part,
+ _view_from_record(part, payload, records[0]),
+ style=style,
+ hole_table_threshold=hole_table_threshold,
+ fits={key: fits[key] for key in sorted(unclaimed)},
+ )
+
+ return {
+ "part_id": part_id,
+ "count": len(handles) - (1 if table_handle else 0),
+ "handles": handles,
+ "resolved": resolved_rows,
+ "skipped": skipped,
+ "hole_table": table_handle,
+ "backend": backend.name,
+ }
+
+
+# -- the standalone hole pattern ---------------------------------------------
+
+
+async def draw_hole_pattern(
+ backend,
+ *,
+ pattern: str,
+ x: float,
+ y: float,
+ diameter: float,
+ count: int = 2,
+ spacing: float = 0.0,
+ count_y: int = 1,
+ spacing_y: float = 0.0,
+ pcd: float = 0.0,
+ start_angle: float = 0.0,
+ angle: float = 0.0,
+ layer: str | None = None,
+ centre_marks: bool = True,
+ dimension: bool = False,
+) -> dict:
+ """Draw a hole pattern on any drawing - no part model needed."""
+ # A standalone pattern has no part, so `Hole.validate` (which needs one) is
+ # out of reach: the size guard is made here rather than left to the drawing.
+ diameter = _number(diameter, "diameter")
+ _number(x, "x", positive=False)
+ _number(y, "y", positive=False)
+ expansion = HolePattern(
+ id="pattern",
+ pattern=pattern,
+ base={"x": float(x), "y": float(y), "diameter": diameter},
+ count=int(count),
+ spacing=float(spacing),
+ count_y=int(count_y),
+ spacing_y=float(spacing_y),
+ pcd=float(pcd),
+ start_angle=float(start_angle),
+ angle=float(angle),
+ ).expand() # validates the pattern before anything is drawn
+
+ prims: list[Any] = []
+ for hole in expansion:
+ radius = float(hole.diameter) / 2.0
+ prims.append(Circle((float(hole.x), float(hole.y)), radius, "visible"))
+ if centre_marks:
+ reach = radius + CENTRE_MARK_OVERRUN
+ prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center"))
+ prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center"))
+ drawn = await draw_prims(backend, prims)
+ if layer:
+ # the role map owns the layers; an explicit override moves the circles
+ # only, so the ISO 128-23 centre marks stay on CENTER.
+ for handle, prim in zip(drawn["handles"], prims, strict=True):
+ if isinstance(prim, Circle):
+ await backend.entity_set_properties(handle, layer=layer)
+
+ result = {
+ "count": len(expansion),
+ "handles": drawn["handles"],
+ "centers": [[float(h.x), float(h.y)] for h in expansion],
+ "backend": backend.name,
+ }
+ if dimension:
+ first = expansion[0]
+ radius = float(first.diameter) / 2.0
+ handle, _row = await _draw_dimension(
+ backend,
+ DimIntent(
+ kind="diameter",
+ p1=(first.x - radius, first.y),
+ p2=(first.x + radius, first.y),
+ text_at=(first.x + radius + 12.0, first.y + 12.0),
+ text_override=f"{len(expansion)}x ⌀{float(first.diameter):g}",
+ feature="pattern",
+ ),
+ )
+ if handle:
+ result["handles"].append(handle)
+ return result
+
+
+# -- the whole sheet ---------------------------------------------------------
+
+
+async def draw_from_spec(backend, spec: dict) -> dict:
+ """A whole mechanical sheet from one declarative spec, inside one transaction.
+
+ Every part is validated before the transaction opens, so a refusal leaves
+ the drawing untouched. The rollback is shielded for the reason
+ `engineering/pid/spec.py` documents at length: the MCP SDK cancels a request
+ by cancelling an anyio scope, anyio cancellation is level-triggered, and a
+ bare ``await`` in the handler would itself be cancelled before the rollback
+ body ran - leaving half a sheet inside an open transaction.
+ """
+ if not isinstance(spec, dict):
+ raise ValueError(f"spec: expected a dict, got {type(spec).__name__}.")
+ raw_parts = spec.get("parts")
+ if not isinstance(raw_parts, (list, tuple)) or not raw_parts:
+ raise ValueError("parts: name at least one part to draw.")
+
+ jobs: list[dict] = []
+ for index, item in enumerate(raw_parts):
+ if not isinstance(item, dict):
+ raise ValueError(f"parts[{index}]: expected a dict with a 'part' key.")
+ try:
+ part = build_part(item.get("part") or {})
+ except ValueError as exc:
+ raise ValueError(f"parts[{index}]: {exc}") from exc
+ at = item.get("at") or [0.0, 0.0]
+ jobs.append(
+ {
+ "part": part,
+ "at": (float(at[0]), float(at[1])),
+ "rotation": float(item.get("rotation", 0.0)),
+ "views": tuple(item.get("views") or ("front",)),
+ "projection": str(item.get("projection", "first")),
+ "dimension": bool(item.get("dimension", True)),
+ "style": str(item.get("style", "chain")),
+ }
+ )
+
+ sheet = spec.get("sheet") or {}
+ if backend.get_plan_spec() is None:
+ await backend.drawing_plan(
+ sheet.get("intent", "mechanical part sheet"),
+ sheet.get("sheet_size", "A3"),
+ float(sheet.get("scale", 1.0)),
+ sheet.get("layer_set", "mech"),
+ )
+
+ begun = await backend.transaction_begin()
+ if not isinstance(begun, dict) or not begun.get("ok"):
+ raise RuntimeError(f"mech_part_from_spec: could not open a transaction: {begun}")
+
+ drawn: list[dict] = []
+ try:
+ for index, job in enumerate(jobs):
+ try:
+ drawn.append(
+ await draw_part(
+ backend,
+ job["part"],
+ at=job["at"],
+ rotation=job["rotation"],
+ views=job["views"],
+ projection=job["projection"],
+ dimension=job["dimension"],
+ style=job["style"],
+ )
+ )
+ except ValueError as exc:
+ raise ValueError(f"parts[{index}]: {exc}") from exc
+ except BaseException as exc:
+ cancelled = isinstance(exc, anyio.get_cancelled_exc_class())
+ with anyio.CancelScope(shield=True):
+ try:
+ await backend.transaction_rollback()
+ except Exception as rollback_exc:
+ if not cancelled:
+ raise
+ log.error(
+ "mech_part_from_spec: rollback after a cancelled request failed (%s: %s); "
+ "the sheet may be half-drawn - check system_status and "
+ "transaction_rollback before retrying",
+ type(rollback_exc).__name__,
+ rollback_exc,
+ )
+ raise
+ await backend.transaction_commit()
+
+ return {
+ "parts": drawn,
+ "part_ids": [item["part_id"] for item in drawn],
+ "backend": backend.name,
+ }
diff --git a/server.py b/server.py
index 1635d7e..0707d87 100644
--- a/server.py
+++ b/server.py
@@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None:
# entity/drawing tags. Tools whose tags match none fall into "other".
_GROUP_TAG_PRIORITY = (
"pid",
+ "mech",
"style",
"pagesetup",
"environment",
@@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = (
# Map the winning tag to a human-readable group label for the breakdown.
_GROUP_TAG_LABELS = {
"pid": "pid",
+ "mech": "mech",
"style": "styles",
"pagesetup": "page_setup",
"environment": "environment",
@@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset(
# mechanically should not pay for the P&ID surface, and a client that never
# talks to a live seat should not pay for the environment surface. Styles and
# page setup (SECTION 18/19) are drafting essentials and stay in core.
-TOOL_PACK_NAMES = ("core", "pid", "settings")
+TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"pid": frozenset(
{
@@ -1002,6 +1004,17 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"system_prompt_message",
}
),
+ # SECTION 21 - the mechanical part model and its view engine (tracks B+G).
+ "mech": frozenset(
+ {
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "mech_hole_pattern",
+ "mech_part_from_spec",
+ "mech_part_inspect",
+ }
+ ),
}
_active_tool_profile: dict | None = None
@@ -8123,6 +8136,403 @@ async def drawing_properties_set(
return await _backend(ctx).drawing_properties_set(summary, custom)
+# ---------------------------------------------------------------------------
+# ── SECTION 21: Mechanical Parts (6 tools) ──────────────────────────────────
+# ---------------------------------------------------------------------------
+
+
+@cad_tool(
+ summary="Draw a machine part from a segment list or an outline plus typed features.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Draw Part", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_part_draw(
+ spec: Annotated[
+ dict,
+ Field(
+ description=(
+ "The part: {kind: 'revolved'|'prismatic', name, material, "
+ "segments:[{length, d_outer, d_inner, taper_to}] | outline:[[x,y]] + thickness, "
+ "features:[{kind, id, ...}]}"
+ )
+ ),
+ ],
+ at_x: Annotated[
+ float, Field(default=0.0, description="WCS X of the first view's lower-left")
+ ] = 0.0,
+ at_y: Annotated[
+ float, Field(default=0.0, description="WCS Y of the first view's lower-left")
+ ] = 0.0,
+ rotation: Annotated[
+ float, Field(default=0.0, description="Degrees CCW applied to every view")
+ ] = 0.0,
+ views: Annotated[
+ list[str] | None,
+ Field(
+ default=None,
+ description="Views to draw in order: front | side | top (default ['front'])",
+ ),
+ ] = None,
+ projection: Annotated[
+ str, Field(default="first", description="first (ISO 128-30, default) | third")
+ ] = "first",
+ dimension: Annotated[
+ bool, Field(default=True, description="Also dimension the part (ISO 129 + ISO 286 fits)")
+ ] = True,
+ style: Annotated[
+ str,
+ Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"),
+ ] = "chain",
+ ctx: Context = None,
+) -> dict:
+ """One part model - a turned profile or a plate outline plus typed features - drawn as views.
+
+ The model is written onto the drawing as `ACADMCP_MECH` XDATA on an anchor
+ POINT, so `mech_view_add` can add a section months later without the caller
+ re-describing the part, and `mech_part_inspect` reads it back.
+
+ Refused **before anything is drawn**, by path: a non-finite or non-positive
+ number (`segments[2].d_outer`), a bore that is not smaller than its outside,
+ an outline with fewer than three distinct vertices or one that crosses
+ itself, an unknown material, a feature whose placement is off the part, two
+ features that would remove the same material (both names given), a thread
+ outside the transcribed ISO 261 table, a keyway outside DIN 6885's bore
+ range, gear teeth whose tip diameter disagrees with their segment. A DIN 509
+ undercut, a DIN 471/472 groove, a DIN 332 centre hole and an ISO 3601-2
+ O-ring groove need explicit dimensions in this build: those tables are not
+ transcribed and the lookup refuses by name rather than interpolate. A
+ feature that cannot be projected into a requested view is listed in
+ `omitted`, never dropped.
+ """
+ from engineering.mech.draw import draw_part
+ from engineering.mech.part import build_part
+
+ try:
+ part = build_part(spec)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+ await ctx.info(f"mech_part_draw: {part.name} at ({at_x}, {at_y})")
+ try:
+ return await draw_part(
+ _backend(ctx),
+ part,
+ at=(at_x, at_y),
+ rotation=rotation,
+ views=tuple(views or ("front",)),
+ projection=projection,
+ dimension=dimension,
+ style=style,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Add View", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_view_add(
+ part_id: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
+ ),
+ ] = None,
+ kind: Annotated[
+ str, Field(default="section", description="front | side | top | section | detail")
+ ] = "section",
+ side: Annotated[
+ str, Field(default="right", description="Which end an end view looks from: right | left")
+ ] = "right",
+ style: Annotated[
+ str,
+ Field(default="full", description="Section style: full | half | offset | revolved"),
+ ] = "full",
+ plane: Annotated[
+ dict | None,
+ Field(
+ default=None,
+ description="Cutting plane {p1:[x,y], p2:[x,y], label, direction:[dx,dy], via:[[x,y]]} - required for a section",
+ ),
+ ] = None,
+ detail: Annotated[
+ dict | None,
+ Field(
+ default=None,
+ description="Detail region {center:[x,y], radius, scale, label, of:'front'} - required for a detail",
+ ),
+ ] = None,
+ gap: Annotated[
+ float, Field(default=20.0, description="Gap between this view and its parent (mm)")
+ ] = 20.0,
+ scale: Annotated[
+ float, Field(default=1.0, description="View scale recorded on the view")
+ ] = 1.0,
+ ctx: Context = None,
+) -> dict:
+ """Another view of a part already on the drawing, laid out on its projection axis.
+
+ The part model comes back out of the drawing's `ACADMCP_MECH` XDATA, so the
+ caller never re-describes it, and the placement follows the projection angle
+ recorded when the part was drawn (first angle by default: the view from the
+ right goes to the left).
+
+ Refused by name: a section without a plane, or with a plane of zero length,
+ a plane that misses the part, or one that grazes an outline vertex so its
+ crossings cannot be paired; a half or offset section on the wrong kind of
+ plane (the message names the style that does apply); a detail without a
+ radius or with a scale of zero; an unknown part_id, and `part_id=None` on a
+ drawing that holds more than one part.
+ """
+ from engineering.mech.draw import add_view, read_part
+
+ backend = _backend(ctx)
+ try:
+ resolved = part_id or (await read_part(backend))["part_id"]
+ await ctx.info(f"mech_view_add: {kind} on {resolved}")
+ return await add_view(
+ backend,
+ resolved,
+ kind,
+ side=side,
+ style=style,
+ plane=plane,
+ detail=detail,
+ gap=gap,
+ scale=scale,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Dimension Part", "destructiveHint": False},
+ tags={"mech", "dimension"},
+)
+async def mech_dimension_part(
+ part_id: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
+ ),
+ ] = None,
+ style: Annotated[
+ str,
+ Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"),
+ ] = "chain",
+ hole_table_threshold: Annotated[
+ int,
+ Field(default=8, description="Above this many holes the diameters move into a hole table"),
+ ] = 8,
+ fits: Annotated[
+ dict | None,
+ Field(
+ default=None,
+ description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}",
+ ),
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """Every dimension the part and its features ask for, laid out without overlaps.
+
+ Axial lengths are chained, stacked from a baseline or measured from an
+ origin; diameters stack outside the silhouette; fillets get a radius. Each
+ measurement appears once (ISO 129-1). `fits` resolves an ISO 286 code
+ against the measured nominal through the repository's authored tables and
+ appends the code to the dimension text; an explicit `tol` on a feature's own
+ intent does the same through the ISO 129 path.
+
+ Refused by name: a fit together with an explicit tolerance, a fit outside
+ the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an
+ intent with no measurable nominal, an unknown style, an unknown part_id, and
+ `part_id=None` on a drawing that holds more than one part. An angular intent
+ is reported in `skipped` rather than drawn, because a DimIntent carries no
+ vertex.
+ """
+ from engineering.mech.draw import dimension_part, read_part
+
+ backend = _backend(ctx)
+ try:
+ resolved = part_id or (await read_part(backend))["part_id"]
+ await ctx.info(f"mech_dimension_part: {resolved} ({style})")
+ return await dimension_part(
+ backend,
+ resolved,
+ style=style,
+ hole_table_threshold=hole_table_threshold,
+ fits=fits,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Hole Pattern", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_hole_pattern(
+ pattern: Annotated[str, Field(description="linear | grid | polar")],
+ x: Annotated[float, Field(description="WCS X of the first hole (polar: the circle centre)")],
+ y: Annotated[float, Field(description="WCS Y of the first hole (polar: the circle centre)")],
+ diameter: Annotated[float, Field(description="Hole diameter in drawing units")],
+ count: Annotated[
+ int, Field(default=2, description="Holes along the pattern (polar: around the circle)")
+ ] = 2,
+ spacing: Annotated[
+ float, Field(default=0.0, description="Pitch between holes (linear, grid columns)")
+ ] = 0.0,
+ count_y: Annotated[int, Field(default=1, description="Rows, for a grid")] = 1,
+ spacing_y: Annotated[float, Field(default=0.0, description="Row pitch, for a grid")] = 0.0,
+ pcd: Annotated[
+ float, Field(default=0.0, description="Pitch-circle diameter, for a polar pattern")
+ ] = 0.0,
+ start_angle: Annotated[
+ float, Field(default=0.0, description="Angle of the first hole (polar), degrees CCW")
+ ] = 0.0,
+ angle: Annotated[
+ float, Field(default=0.0, description="Direction of a linear pattern, degrees CCW")
+ ] = 0.0,
+ layer: Annotated[
+ str | None, Field(default=None, description="Override the GEOMETRY layer for the circles")
+ ] = None,
+ centre_marks: Annotated[
+ bool, Field(default=True, description="Draw ISO 128-23 centre marks")
+ ] = True,
+ dimension: Annotated[
+ bool, Field(default=False, description="Add a '6x ⌀8' diameter callout")
+ ] = False,
+ ctx: Context = None,
+) -> dict:
+ """A hole pattern on any drawing - the part model is not required.
+
+ The same expansion `HolePattern` performs inside a part, exposed for a
+ drawing that was not built from the model.
+
+ Refused before anything is drawn: an unknown pattern, a count below 1 (below
+ 2 for polar), a linear pattern with no spacing, a grid with more than one
+ column or row and no pitch for it, a polar pattern with no pitch-circle
+ diameter, and a non-finite or non-positive diameter.
+ """
+ from engineering.mech.draw import draw_hole_pattern
+
+ await ctx.info(f"mech_hole_pattern: {pattern} x{count}")
+ try:
+ return await draw_hole_pattern(
+ _backend(ctx),
+ pattern=pattern,
+ x=x,
+ y=y,
+ diameter=diameter,
+ count=count,
+ spacing=spacing,
+ count_y=count_y,
+ spacing_y=spacing_y,
+ pcd=pcd,
+ start_angle=start_angle,
+ angle=angle,
+ layer=layer,
+ centre_marks=centre_marks,
+ dimension=dimension,
+ )
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Draw a whole mechanical sheet - several parts and their views - in one transaction.",
+ cost="mutate",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Sheet From Spec", "destructiveHint": False},
+ tags={"mech", "create"},
+)
+async def mech_part_from_spec(
+ spec: Annotated[
+ dict,
+ Field(
+ description=(
+ "{sheet: {intent, sheet_size, scale, layer_set}, "
+ "parts: [{part: {...}, at: [x, y], rotation, views: ['front','side'], "
+ "projection, dimension, style}]}"
+ )
+ ),
+ ],
+ ctx: Context = None,
+) -> dict:
+ """Several parts, their views and their dimensions, drawn inside one transaction.
+
+ Every part is validated before the transaction opens, so a refusal leaves
+ the drawing untouched and names the offending path (`parts[1]:
+ segments[0].d_outer: ...`). If a draw fails or the request is cancelled
+ part-way, the transaction is rolled back under an anyio shield, so a
+ cancelled call cannot leave half a sheet inside an open transaction.
+
+ Refused by name: an empty `parts` list, any part refusal from
+ `mech_part_draw`, and a transaction that cannot be opened.
+ """
+ from engineering.mech.draw import draw_from_spec
+
+ await ctx.info(f"mech_part_from_spec: {len((spec or {}).get('parts') or [])} part(s)")
+ try:
+ return await draw_from_spec(_backend(ctx), spec)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Read the part model back off the drawing: segments, features, views, omissions.",
+ cost="read",
+)
+@mcp.tool(
+ annotations={"title": "Mechanical: Inspect Part", "readOnlyHint": True},
+ tags={"mech", "query"},
+)
+async def mech_part_inspect(
+ part_id: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
+ ),
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """The `ACADMCP_MECH` payload a drawn part carries, decoded.
+
+ Never modifies the drawing. Returns the part model exactly as
+ `mech_part_draw` accepts it (`part`), the views drawn so far with their
+ placements, and the payload version.
+
+ Refused by name: an unknown part_id, a handle that carries no
+ `ACADMCP_MECH` payload, a corrupt or foreign payload, and `part_id=None` on
+ a drawing that holds no part or more than one (the message lists the
+ handles).
+ """
+ from engineering.mech.draw import read_part
+
+ try:
+ return await read_part(_backend(ctx), part_id)
+ except ValueError as exc:
+ raise ToolError(str(exc)) from exc
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_mech_draw.py b/tests/test_mech_draw.py
new file mode 100644
index 0000000..6340e2b
--- /dev/null
+++ b/tests/test_mech_draw.py
@@ -0,0 +1,283 @@
+"""The async layer: primitives to entities, the part payload, and the sheet.
+
+Headless ezdxf against a real document - the `backend` fixture in
+tests/conftest.py. The COM paths are the same code (draw.py composes the
+generic contract members), which is the whole point of the pattern
+`gear_draw_*` and `titleblock_apply_iso_a3` already use.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.mech.draw import (
+ ANCHOR_LAYER,
+ add_view,
+ dimension_part,
+ draw_from_spec,
+ draw_hole_pattern,
+ draw_part,
+ draw_prims,
+ read_part,
+)
+from engineering.mech.part import build_part
+from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Poly, Text
+
+pytestmark = pytest.mark.asyncio
+
+SHAFT = {
+ "kind": "revolved",
+ "name": "shaft",
+ "material": "steel",
+ "segments": [
+ {"length": 20.0, "d_outer": 25.0},
+ {"length": 40.0, "d_outer": 30.0},
+ {"length": 15.0, "d_outer": 20.0},
+ ],
+ "features": [{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}],
+}
+
+PLATE = {
+ "kind": "prismatic",
+ "name": "plate",
+ "material": "aluminium",
+ "thickness": 8.0,
+ "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
+ "features": [{"kind": "hole", "id": "h1", "x": 40.0, "y": 25.0, "diameter": 10.0}],
+}
+
+
+async def entities(backend, type_filter=None, layer=None):
+ return await backend.entity_list(type_filter=type_filter, layer_filter=layer, limit=2000)
+
+
+async def test_draw_prims_puts_every_role_on_its_own_layer(backend):
+ result = await draw_prims(
+ backend,
+ [
+ Line((0.0, 0.0), (10.0, 0.0), "visible"),
+ Line((0.0, 5.0), (10.0, 5.0), "hidden"),
+ Line((-2.0, 2.5), (12.0, 2.5), "center"),
+ Circle((5.0, 2.5), 1.0, "visible"),
+ Arc((5.0, 2.5), 2.0, 0.0, 90.0, "visible"),
+ Poly(((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), True, "visible"),
+ Text((5.0, 8.0), "A", 3.5, 0.0, "text"),
+ HatchArea((((0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)),), "steel"),
+ ],
+ )
+ assert len(result["handles"]) == 8
+ layers = {e.layer for e in await entities(backend)}
+ assert {"GEOMETRY", "HIDDEN", "CENTER", "TEXT", "HATCH"} <= layers
+
+
+async def test_draw_prims_places_and_rotates_into_wcs(backend):
+ await draw_prims(backend, [Line((0.0, 0.0), (10.0, 0.0), "visible")], at=(100.0, 50.0))
+ (line,) = await entities(backend, type_filter="LINE")
+ start = line.properties["start"]
+ assert (round(start[0], 6), round(start[1], 6)) == (100.0, 50.0)
+
+
+async def test_draw_part_writes_the_model_back_onto_the_drawing(backend):
+ part = build_part(SHAFT)
+ result = await draw_part(backend, part, at=(50.0, 100.0))
+ assert result["part_id"]
+ assert result["views"][0]["kind"] == "front"
+ assert result["handles"]
+
+ anchors = await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+ assert len(anchors) == 1
+ assert anchors[0].handle == result["part_id"]
+
+ read = await read_part(backend, result["part_id"])
+ assert read["part"]["name"] == "shaft"
+ assert [f["id"] for f in read["part"]["features"]] == ["c1"]
+ assert build_part(read["part"]) == part
+
+
+async def test_read_part_without_an_id_finds_the_only_part(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part)
+ read = await read_part(backend)
+ assert read["part_id"] == drawn["part_id"]
+
+
+async def test_read_part_refuses_when_there_is_more_than_one_and_no_id(backend):
+ await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0))
+ await draw_part(backend, build_part(PLATE), at=(200.0, 0.0))
+ with pytest.raises(ValueError) as excinfo:
+ await read_part(backend)
+ assert "part_id" in str(excinfo.value)
+
+
+async def test_two_views_are_placed_in_first_angle_positions(backend):
+ part = build_part(SHAFT)
+ result = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"))
+ front, end = result["views"]
+ assert end["kind"] == "side"
+ # first angle: the view from the right sits to the LEFT of the front view
+ assert end["bbox"][2] <= front["bbox"][0] + 1e-6
+
+
+async def test_third_angle_puts_the_end_view_on_the_other_side(backend):
+ part = build_part(SHAFT)
+ result = await draw_part(
+ backend, part, at=(0.0, 0.0), views=("front", "side"), projection="third"
+ )
+ front, end = result["views"]
+ assert end["bbox"][0] >= front["bbox"][2] - 1e-6
+
+
+async def test_add_view_reads_the_model_back_and_appends_a_section(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0))
+ added = await add_view(
+ backend,
+ drawn["part_id"],
+ "section",
+ plane={"p1": [0.0, 0.0], "p2": [100.0, 0.0], "label": "A"},
+ )
+ assert added["view"]["kind"] == "section"
+ assert added["view"]["label"] == "A-A"
+ assert len(await entities(backend, type_filter="HATCH")) == 2
+ read = await read_part(backend, drawn["part_id"])
+ assert [v["kind"] for v in read["views"]] == ["front", "section"]
+
+
+async def test_a_detail_view_also_marks_its_circle_on_the_parent(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0))
+ added = await add_view(
+ backend,
+ drawn["part_id"],
+ "detail",
+ detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"},
+ )
+ assert added["view"]["label"] == "D (5:1)"
+ texts = [e for e in await entities(backend, type_filter="TEXT")]
+ assert any(e.properties.get("text") == "D" for e in texts)
+
+
+async def test_dimension_part_creates_real_dimension_entities(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False)
+ assert not await entities(backend, type_filter="DIMENSION")
+ result = await dimension_part(backend, drawn["part_id"])
+ assert result["count"] > 0
+ dims = await entities(backend, type_filter="DIMENSION")
+ assert len(dims) == result["count"]
+ assert {e.layer for e in dims} == {"DIM"}
+
+
+async def test_an_iso_286_fit_reaches_the_dimension_as_a_real_tolerance(backend):
+ """`fits` names a dimension, and 0.025 is H7 on the 40 mm bore.
+
+ The key is the one `engineering/mech/dimension.segment_key` mints, not the
+ part's name: `dimension_intents` addresses the part's own diameters one at
+ a time (`segment[0]`, `segment[0].bore`) precisely so a fit lands on the
+ bore rather than on every dimension the part emits. Measured: H7 is
+ +0.025/0 at 40 mm, +0.030/0 at 60 mm and +0.021/0 at the 30 mm length.
+ """
+ spec = {
+ "kind": "revolved",
+ "name": "bore",
+ "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}],
+ "features": [],
+ }
+ part = build_part(spec)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False)
+ result = await dimension_part(backend, drawn["part_id"], fits={"segment[0].bore": "H7"})
+ assert result["count"] > 0
+ assert any(
+ row.get("fit") == "H7"
+ and row["tol_upper"] == pytest.approx(0.025)
+ and row["tol_lower"] == pytest.approx(0.0)
+ and row["tol_mode"] == "deviation"
+ and "H7" in row["text_override"]
+ for row in result["resolved"]
+ )
+ # and the outside diameter of the same segment is untouched
+ assert all(
+ row["tol_mode"] == "none"
+ for row in result["resolved"]
+ if row["feature"] != "segment[0].bore"
+ )
+
+
+async def test_a_fit_that_names_nothing_is_refused_with_the_names_that_exist(backend):
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False)
+ with pytest.raises(ValueError) as excinfo:
+ await dimension_part(backend, drawn["part_id"], fits={"bearing_seat": "k6"})
+ assert "bearing_seat" in str(excinfo.value)
+ assert "segment[0]" in str(excinfo.value)
+
+
+async def test_a_fit_on_one_view_is_not_refused_because_another_view_lacks_it(backend):
+ """A part drawn front + side: `segment[0]` is dimensioned in the front view
+ only, and the end view (which dimensions the *last* segment) must not turn
+ a correct sheet into a refusal."""
+ part = build_part(SHAFT)
+ drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False)
+ result = await dimension_part(backend, drawn["part_id"], fits={"segment[0]": "k6"})
+ assert any(row.get("fit") == "k6" for row in result["resolved"])
+
+
+async def test_a_hole_pattern_draws_its_holes_and_centre_marks(backend):
+ result = await draw_hole_pattern(
+ backend, pattern="polar", x=0.0, y=0.0, diameter=8.0, count=6, pcd=80.0
+ )
+ assert result["count"] == 6
+ circles = await entities(backend, type_filter="CIRCLE")
+ assert len(circles) == 6
+ assert len(await entities(backend, layer="CENTER")) >= 12
+
+
+async def test_a_hole_pattern_with_no_spacing_is_refused_before_anything_is_drawn(backend):
+ with pytest.raises(ValueError, match="spacing"):
+ await draw_hole_pattern(backend, pattern="linear", x=0.0, y=0.0, diameter=8.0, count=4)
+ assert not await entities(backend, type_filter="CIRCLE")
+
+
+async def test_draw_from_spec_draws_a_whole_sheet_in_one_transaction(backend):
+ result = await draw_from_spec(
+ backend,
+ {
+ "sheet": {"intent": "mechanical sheet", "sheet_size": "A3", "scale": 1.0},
+ "parts": [
+ {"part": SHAFT, "at": [40.0, 200.0], "views": ["front", "side"]},
+ {"part": PLATE, "at": [40.0, 60.0], "views": ["front"]},
+ ],
+ },
+ )
+ assert len(result["parts"]) == 2
+ assert len(await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)) == 2
+
+
+async def test_draw_from_spec_validates_every_part_before_it_draws_anything(backend):
+ bad = dict(SHAFT, segments=[{"length": 20.0, "d_outer": float("inf")}])
+ with pytest.raises(ValueError) as excinfo:
+ await draw_from_spec(
+ backend,
+ {"parts": [{"part": PLATE, "at": [0.0, 0.0]}, {"part": bad, "at": [200.0, 0.0]}]},
+ )
+ assert "parts[1]" in str(excinfo.value)
+ assert "segments[0].d_outer" in str(excinfo.value)
+ assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+
+
+async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monkeypatch):
+ calls = {"n": 0}
+ real = backend.entity_create_line
+
+ async def explode(*args, **kwargs):
+ calls["n"] += 1
+ if calls["n"] > 3:
+ raise RuntimeError("the seat went away")
+ return await real(*args, **kwargs)
+
+ monkeypatch.setattr(backend, "entity_create_line", explode)
+ with pytest.raises(RuntimeError, match="the seat went away"):
+ await draw_from_spec(backend, {"parts": [{"part": SHAFT, "at": [0.0, 0.0]}]})
+ monkeypatch.undo()
+ assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
+ assert not await entities(backend, type_filter="LINE")
diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py
index fc370c2..e423f98 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -86,7 +86,16 @@ LEAN_SETTINGS_ESSENTIALS = {
"textstyle_set_current",
}
-ALL_PACKS = ["core", "pid", "settings"]
+ALL_PACKS = ["core", "pid", "settings", "mech"]
+ALL_PACKS_SORTED = sorted(ALL_PACKS)
+MECH_TOOLS = {
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "mech_hole_pattern",
+ "mech_part_from_spec",
+ "mech_part_inspect",
+}
@pytest_asyncio.fixture(autouse=True)
@@ -97,8 +106,9 @@ async def _restore(monkeypatch):
def test_pack_registry_names_real_tools_and_only_them():
- assert server.TOOL_PACK_NAMES == ("core", "pid", "settings")
+ assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")
assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
+ assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
assert len(SETTINGS_TOOLS) == 22
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
@@ -137,7 +147,7 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
info = await server._apply_tool_profile("full")
disabled = set(info["disabled_tools"])
assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
- assert info["tool_packs"]["enabled"] == ALL_PACKS
+ assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED
async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
@@ -184,7 +194,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["styles"]) == STYLE_TOOLS
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
assert set(groups["environment"]) == SETTINGS_TOOLS
- assert len(groups) == 23
+ assert set(groups["mech"]) == MECH_TOOLS
+ assert len(groups) == 24
async def test_system_about_reports_packs():
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 4b55428..36edea0 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -527,8 +527,15 @@ async def test_tool_group_sizes_are_unchanged():
merge task tagged them, the four groups had filed those 28 tools under
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
their secondary tags; those counts returned to their pre-track-E values
- when the section tags landed.) Every other number here has been unchanged
- since the snapshot was taken.
+ when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's
+ SECTION 21 opened with the part model and its view engine
+ (`mech_part_draw` / `mech_view_add` / `mech_dimension_part` /
+ `mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the
+ `mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so
+ those tools file under `mech` rather than under their secondary `create` /
+ `dimension` / `query` tags. This snapshot is branch-local: the merge task
+ recomputes it once all four track B+G branches have landed. 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 == {
@@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged():
"environment": 22,
"layers": 14,
"layouts": 12,
+ "mech": 6,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 204
+ assert sum(sizes.values()) == 210