diff --git a/README.md b/README.md
index 51b854b..1471067 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:** 229 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3976 collected tests.
> 229 is the **registered** count; a default install advertises 224 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
@@ -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 |
-229 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.
+229 tools in 25 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools and the 14 mechanical tools (TOOL_PACKS=core,mech keeps the last) from a client that needs none of them. 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.
@@ -227,7 +227,7 @@ Nothing loads a `.env` file — export these, or set them in your MCP client's `
|---|---|---|
| `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` |
| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to |
-| `TOOL_PROFILE` | `full` | `lean` (55 curated tools, 52 with `TOOL_PACKS=core`) or `full` |
+| `TOOL_PROFILE` | `full` | `lean` (60 curated tools, 54 with `TOOL_PACKS=core`) or `full` |
| `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings` (`core` always on) |
| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` |
| `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM) |
diff --git a/discovery/aliases.py b/discovery/aliases.py
index 0af6c0a..5099993 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -369,6 +369,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"machining symbol",
"yuzey puruzlulugu",
"yuzey isleme sembolu",
+ "yüzey pürüzlülüğü",
),
),
"weld_symbol": ToolAliases(
@@ -382,6 +383,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"weld all around",
"kaynak sembolu",
"kose kaynagi",
+ "kaynak sembolü",
),
),
"centre_marks": ToolAliases(
@@ -1077,6 +1079,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"materials table",
"iso 7573 table",
"parca listesi tablosu",
+ "draw the parts list table on the sheet",
),
),
"balloon_add": ToolAliases(
@@ -1149,6 +1152,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"autocad 2018 format",
"downgrade to r2000",
"dwg olarak kaydet",
+ "save this drawing as a dwg",
),
),
# ── Layers & linetypes ──────────────────────────────────────────────────
@@ -2202,6 +2206,7 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"somun",
"rondela",
"rulman",
+ "put an m12 hex bolt on the drawing",
),
),
"std_feature_draw": ToolAliases(
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 81229f3..50d7a81 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -6,26 +6,27 @@
"tools": 229,
"resources": 8,
"prompts": 5,
- "groups": 24
+ "groups": 25
},
"groups": {
"analysis": 12,
"batch": 3,
- "blocks": 11,
+ "blocks": 9,
"corner_ops": 4,
"dimensions": 5,
- "drawing": 12,
- "engineering": 17,
- "entity_creation": 19,
+ "drawing": 11,
+ "engineering": 10,
+ "entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
"environment": 22,
"layers": 14,
"layouts": 12,
- "mech": 14,
+ "mechanical": 14,
"page_setup": 6,
"pid": 9,
"premium": 12,
+ "sheet": 11,
"solids": 5,
"styles": 10,
"system": 7,
@@ -181,7 +182,7 @@
},
{
"name": "balloon_add",
- "group": "engineering",
+ "group": "sheet",
"cost": "mutate",
"summary": "Add an ISO 6433 balloon linked to its item row.",
"tags": [
@@ -320,7 +321,7 @@
},
{
"name": "bom_extract",
- "group": "engineering",
+ "group": "sheet",
"cost": "read",
"summary": "Read the bill of materials out of the drawing's block references.",
"tags": [
@@ -335,7 +336,7 @@
},
{
"name": "bom_table",
- "group": "engineering",
+ "group": "sheet",
"cost": "mutate",
"summary": "Draw the parts list as a real TABLE above the title block.",
"tags": [
@@ -389,7 +390,7 @@
},
{
"name": "centre_marks",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw ISO 128-23 centre marks or centre lines on circles, read from the drawing.",
"tags": [
@@ -431,7 +432,7 @@
},
{
"name": "data_extract",
- "group": "engineering",
+ "group": "sheet",
"cost": "safe",
"summary": "Write the bill of materials to a CSV file, or to XLSX when openpyxl is present.",
"tags": [
@@ -729,11 +730,12 @@
},
{
"name": "drawing_export_dwg",
- "group": "drawing",
+ "group": "sheet",
"cost": "safe",
"summary": "Write the drawing as a real DWG at a chosen AutoCAD version.",
"tags": [
- "drawing"
+ "drawing",
+ "sheet"
],
"annotations": {
"title": "Drawing: Export DWG",
@@ -1680,7 +1682,7 @@
},
{
"name": "hatch_material",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Hatch a region with the ISO 128-50 section pattern for a named material.",
"tags": [
@@ -1708,12 +1710,13 @@
},
{
"name": "image_attach",
- "group": "entity_creation",
+ "group": "sheet",
"cost": "mutate",
"summary": "Attach a raster image (PNG/JPG) as an underlay.",
"tags": [
"create",
- "drawing"
+ "drawing",
+ "sheet"
],
"annotations": {
"title": "Sheet: Attach Image",
@@ -2084,7 +2087,7 @@
},
{
"name": "mech_dimension_part",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
"tags": [
@@ -2098,7 +2101,7 @@
},
{
"name": "mech_hole_pattern",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
"tags": [
@@ -2112,7 +2115,7 @@
},
{
"name": "mech_part_draw",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw a machine part from a segment list or an outline plus typed features.",
"tags": [
@@ -2126,7 +2129,7 @@
},
{
"name": "mech_part_from_spec",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.",
"tags": [
@@ -2140,7 +2143,7 @@
},
{
"name": "mech_part_inspect",
- "group": "mech",
+ "group": "mechanical",
"cost": "read",
"summary": "Read the part model back off the drawing: segments, features, views, omissions.",
"tags": [
@@ -2154,7 +2157,7 @@
},
{
"name": "mech_view_add",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
"tags": [
@@ -2412,7 +2415,7 @@
},
{
"name": "revision_add",
- "group": "engineering",
+ "group": "sheet",
"cost": "mutate",
"summary": "Append a revision row, with optional revision clouds and triangular tags.",
"tags": [
@@ -2426,7 +2429,7 @@
},
{
"name": "section_line",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw an ISO 128-40 cutting-plane line and return the plane the section view uses.",
"tags": [
@@ -2496,7 +2499,7 @@
},
{
"name": "sheet_frame",
- "group": "engineering",
+ "group": "sheet",
"cost": "mutate",
"summary": "Draw an ISO 5457 sheet frame with the zone grid and centring marks.",
"tags": [
@@ -2575,7 +2578,7 @@
},
{
"name": "std_feature_draw",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.",
"tags": [
@@ -2589,7 +2592,7 @@
},
{
"name": "std_part_insert",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.",
"tags": [
@@ -2603,7 +2606,7 @@
},
{
"name": "std_part_list",
- "group": "mech",
+ "group": "mechanical",
"cost": "read",
"summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
"tags": [
@@ -2617,7 +2620,7 @@
},
{
"name": "surface_texture",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw an ISO 21920-1 surface-texture symbol: Ra/Rz, process, lay, allowance.",
"tags": [
@@ -2895,7 +2898,7 @@
},
{
"name": "titleblock_apply",
- "group": "engineering",
+ "group": "sheet",
"cost": "mutate",
"summary": "Stamp an ISO 7200 title block (and frame) on any sheet from A4 to A0.",
"tags": [
@@ -3216,7 +3219,7 @@
},
{
"name": "weld_symbol",
- "group": "mech",
+ "group": "mechanical",
"cost": "mutate",
"summary": "Draw an ISO 2553 weld symbol: reference line, identification line, size and pitch.",
"tags": [
@@ -3230,12 +3233,13 @@
},
{
"name": "xref_attach",
- "group": "blocks",
+ "group": "sheet",
"cost": "mutate",
"summary": "Attach a DWG/DXF as an external reference (attachment or overlay).",
"tags": [
"block",
- "drawing"
+ "drawing",
+ "sheet"
],
"annotations": {
"title": "Sheet: Attach Xref",
@@ -3244,12 +3248,13 @@
},
{
"name": "xref_manage",
- "group": "blocks",
+ "group": "sheet",
"cost": "mutate",
"summary": "List, reload, bind, detach or repath an external reference.",
"tags": [
"block",
- "drawing"
+ "drawing",
+ "sheet"
],
"annotations": {
"title": "Sheet: Manage Xrefs",
diff --git a/engineering/critique.py b/engineering/critique.py
index 5705b99..efa3848 100644
--- a/engineering/critique.py
+++ b/engineering/critique.py
@@ -412,6 +412,10 @@ from .pid.critique import PID_DISPATCH # noqa: E402 (registered after the mech
_FOCUS_DISPATCH.update(PID_DISPATCH)
+from .mech.critique import MECH_DISPATCH # noqa: E402 (same reason: after the base focuses)
+
+_FOCUS_DISPATCH.update(MECH_DISPATCH)
+
async def run_critique(
backend: AutoCADBackend,
diff --git a/engineering/mech/critique.py b/engineering/mech/critique.py
new file mode 100644
index 0000000..0894ff8
--- /dev/null
+++ b/engineering/mech/critique.py
@@ -0,0 +1,666 @@
+"""The six mechanical critique focuses (spec §13).
+
+Every check is pure over one index built once per critique run and shared
+through ``run_critique``'s context, so six focuses cost one read of the
+drawing. A drawing with no ``ACADMCP_MECH`` payload on it yields no issues at
+all: a P&ID sheet or a plain DXF must not be scored against mechanical
+conventions, and ``focus=None`` has to stay cheap for them.
+
+What the index reads, and why only that: every mechanical payload this server
+writes lands on a POINT (``mech_part_draw``'s part anchor, on layer ``MECH``),
+an INSERT (a standard part; BLOCKREFERENCE on the live engine) or a CIRCLE (a
+balloon is a circle with a leader), so XDATA is read for those types only. The
+rest of the drawing comes from a paged ``entity_list`` -- every entity, not the
+first 200 -- whose ``EntityInfo`` already carries each entity's WCS
+``bounding_box`` on both engines.
+
+A part payload written by ``mech_part_draw`` lists every view it drew under
+``views``, each with its WCS ``bbox`` and its ``frame_bbox`` (the same box in
+the unrotated sheet frame the views are laid out in). The index expands that
+list into one view entry per drawn view, so the section check looks inside the
+section's own box and the alignment check compares boxes in the frame the
+projection rule is stated in. A payload with no ``views`` list stands for one
+view (its ``view`` key) at its anchor.
+
+Two narrowings are deliberate, and neither is hidden:
+
+* ``mech_view_misaligned`` checks the projection *axis*, not the quadrant. The
+ pinned part payload records the view kind and the part id — not the
+ projection angle, and not which side a side view was taken from — so the
+ quadrant rule has nothing true to read. Guessing it would report a correct
+ drawing as wrong, or the reverse.
+* ``mech_duplicate_dimension`` reports two dimensions of the same type that
+ occupy the same place: the failure a second run of ``dimension_auto``, or a
+ manual dimension over an automatic one, produces. A semantic restatement (a
+ chain that also carries the overall length) is not detected, because no
+ engine exposes a dimension's measured feature.
+
+Where a box has to be guessed at all — the region of a view — it is guessed
+*generously*. A check that misses a bad drawing costs one review; a check that
+calls a correct drawing bad gets switched off.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+from typing import TYPE_CHECKING
+
+from engineering.plan_spec import Issue
+
+from .primitives import ROLE_LAYER
+from .standards.threads import MINOR_RATIO
+from .xdata import APP_ID, decode
+
+if TYPE_CHECKING:
+ from backends.base import AutoCADBackend
+
+MECH_FOCUSES = (
+ "mech_missing_centreline",
+ "mech_unhatched_section",
+ "mech_view_misaligned",
+ "mech_duplicate_dimension",
+ "mech_thread_unrepresented",
+ "mech_bom_balloon_mismatch",
+)
+_KEY = "mech_index"
+
+#: The entity types that can carry an ACADMCP_MECH payload. The live engine
+#: names a type from its ActiveX ObjectName (``AcDb`` stripped, upper-cased), so
+#: an INSERT reads back as BLOCKREFERENCE there -- measured on AutoCAD 2026.
+_XDATA_TYPES = ("POINT", "INSERT", "BLOCKREFERENCE", "CIRCLE")
+#: One ``entity_list`` page; the index pages until a short page.
+PAGE_SIZE = 1000
+#: Half a millimetre is visible on a printed sheet.
+ALIGN_TOL_MM = 0.5
+#: How close a CENTER line must pass to a circle's centre to be its mark.
+CENTRE_TOL_MM = 0.5
+#: ISO 6410 draws a thread's minor diameter at 0.8 x the nominal -- the same
+#: constant the view engine draws it with, so the check and the drawing agree.
+THREAD_MINOR_RATIO = MINOR_RATIO
+#: Relative tolerance when matching drawn geometry against a computed diameter.
+DIAMETER_TOL = 0.02
+#: Padding added to a view's computed extents before looking inside it.
+VIEW_PAD_MM = 10.0
+
+_DIM_TYPES = {
+ "DIMENSION",
+ "DIMLINEAR",
+ "DIMALIGNED",
+ "DIMANGULAR",
+ "DIMRADIUS",
+ "DIMDIAMETER",
+ "DIMORDINATE",
+}
+
+
+def _is_dimension(ent) -> bool:
+ """A DIMENSION on either engine: ezdxf says ``DIMENSION``; the live engine
+ says ``ROTATEDDIMENSION`` / ``ALIGNEDDIMENSION`` / ``RADIALDIMENSION`` ...
+ (the ObjectName, measured on AutoCAD 2026)."""
+ kind = str(ent.type).upper()
+ return kind in _DIM_TYPES or kind.endswith("DIMENSION")
+
+
+_METRIC_THREAD = re.compile(r"^M\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE)
+
+
+# ── the index ───────────────────────────────────────────────────────────────
+
+
+def _anchor_point(ent) -> tuple[float, float] | None:
+ props = getattr(ent, "properties", None) or {}
+ for key in ("insertion", "center"):
+ value = props.get(key)
+ if value and len(value) >= 2:
+ return (float(value[0]), float(value[1]))
+ box = props.get("bounding_box")
+ if box:
+ return (
+ (float(box["min"][0]) + float(box["max"][0])) / 2.0,
+ (float(box["min"][1]) + float(box["max"][1])) / 2.0,
+ )
+ return None
+
+
+def _bbox_center(ent) -> tuple[float, float] | None:
+ box = (getattr(ent, "properties", None) or {}).get("bounding_box")
+ if not box:
+ return None
+ return (
+ (float(box["min"][0]) + float(box["max"][0])) / 2.0,
+ (float(box["min"][1]) + float(box["max"][1])) / 2.0,
+ )
+
+
+async def _all_entities(backend: AutoCADBackend) -> list:
+ """Every entity of the current space, paged -- ``entity_list`` defaults to 200.
+
+ Stops on a short page, and on a page that brings nothing new (a backend
+ that ignored ``offset`` would otherwise return the same page for ever).
+ """
+ found: list = []
+ seen: set[str] = set()
+ offset = 0
+ while True:
+ page = await backend.entity_list(limit=PAGE_SIZE, offset=offset)
+ fresh = [ent for ent in page if ent.handle not in seen]
+ if not fresh:
+ return found
+ seen.update(ent.handle for ent in fresh)
+ found.extend(fresh)
+ if len(page) < PAGE_SIZE:
+ return found
+ offset += len(page)
+
+
+def _box(value) -> tuple[float, float, float, float] | None:
+ try:
+ x0, y0, x1, y1 = (float(v) for v in value)
+ except (TypeError, ValueError):
+ return None
+ return (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
+
+
+def _centre(box) -> tuple[float, float]:
+ return ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
+
+
+def _views_of(record: dict) -> list[dict]:
+ """The views one part payload stands for (see the module docstring)."""
+ payload = record["payload"]
+ part_id = str(payload.get("id") or record["handle"])
+ drawn = payload.get("views")
+ if not (isinstance(drawn, list) and drawn):
+ return [
+ {
+ "handle": record["handle"],
+ "part_id": part_id,
+ "kind": _view_kind(record),
+ "index": 0,
+ "at": record["at"],
+ "box": _view_box(record),
+ }
+ ]
+ out: list[dict] = []
+ for position, view in enumerate(drawn):
+ if not isinstance(view, dict):
+ continue
+ wcs = _box(view.get("bbox"))
+ frame = _box(view.get("frame_bbox")) or wcs
+ if wcs is None or frame is None:
+ continue
+ out.append(
+ {
+ "handle": record["handle"],
+ "part_id": part_id,
+ "kind": str(view.get("kind") or "").split(":")[0].strip().lower(),
+ "index": position,
+ "at": _centre(frame),
+ "box": (
+ wcs[0] - VIEW_PAD_MM,
+ wcs[1] - VIEW_PAD_MM,
+ wcs[2] + VIEW_PAD_MM,
+ wcs[3] + VIEW_PAD_MM,
+ ),
+ }
+ )
+ return out
+
+
+async def build_index(backend: AutoCADBackend) -> dict:
+ """One read of the drawing: every entity, plus every ACADMCP_MECH payload."""
+ try:
+ entities = await _all_entities(backend)
+ except Exception:
+ entities = []
+ records: list[dict] = []
+ for ent in entities:
+ if str(ent.type).upper() not in _XDATA_TYPES:
+ continue
+ try:
+ raw = await backend.entity_get_xdata(ent.handle, APP_ID)
+ except Exception:
+ continue
+ values = (raw.get("xdata") or {}).get(APP_ID) or []
+ if not values:
+ continue
+ try:
+ payload = decode(values)
+ except ValueError:
+ # A foreign or corrupt payload is not mechanical content; the codec
+ # already refused it, and guessing at it here would undo that.
+ continue
+ at = _anchor_point(ent)
+ if at is None:
+ continue
+ records.append({"handle": ent.handle, "payload": payload, "at": at})
+ views = [
+ view
+ for record in records
+ if record["payload"].get("kind") == "part"
+ for view in _views_of(record)
+ ]
+ return {"entities": entities, "records": records, "views": views}
+
+
+def has_mech_content(index: dict) -> bool:
+ """True when at least one entity carries an ACADMCP_MECH payload."""
+ return bool(index.get("records"))
+
+
+def _of_kind(index: dict, kind: str) -> list[dict]:
+ return [r for r in index["records"] if r["payload"].get("kind") == kind]
+
+
+def _view_kind(record: dict) -> str:
+ return str(record["payload"].get("view") or "").split(":")[0].strip().lower()
+
+
+def _extents(part: dict) -> tuple[float, float] | None:
+ """``(length, height)`` of a part model, read defensively.
+
+ The pinned ``Segment`` is a NamedTuple ``(length, d_outer, d_inner,
+ taper_to)``, so a serialised segment is either a mapping or that sequence.
+ Both are read rather than assuming which one ``part_to_dict`` chose.
+ """
+ if not isinstance(part, dict):
+ return None
+ segments = part.get("segments")
+ if segments:
+ length = 0.0
+ dmax = 0.0
+ for seg in segments:
+ if isinstance(seg, dict):
+ run = float(seg.get("length", 0.0) or 0.0)
+ outer = float(seg.get("d_outer", 0.0) or 0.0)
+ taper = seg.get("taper_to")
+ else:
+ row = list(seg)
+ run = float(row[0]) if row else 0.0
+ outer = float(row[1]) if len(row) > 1 else 0.0
+ taper = row[3] if len(row) > 3 else None
+ length += run
+ dmax = max(dmax, outer, float(taper) if taper else 0.0)
+ return (length, dmax)
+ outline = part.get("outline")
+ if outline:
+ xs = [float(p[0]) for p in outline]
+ ys = [float(p[1]) for p in outline]
+ return (max(xs) - min(xs), max(ys) - min(ys))
+ return None
+
+
+def _view_box(record: dict) -> tuple[float, float, float, float] | None:
+ """A deliberately generous box around a view's anchor.
+
+ The anchor may be the view's origin or its centre depending on how the
+ view was placed, so the box spans the part's full extents on *both* sides
+ plus a pad. It answers only "is there any hatch near this section", which
+ is the question that matters.
+ """
+ extents = _extents(record["payload"].get("part") or {})
+ if extents is None:
+ return None
+ x, y = record["at"]
+ half_w = extents[0] + VIEW_PAD_MM
+ half_h = max(extents[1], extents[0]) + VIEW_PAD_MM
+ return (x - half_w, y - half_h, x + half_w, y + half_h)
+
+
+def _inside(point, box) -> bool:
+ if point is None or box is None:
+ return False
+ return box[0] <= point[0] <= box[2] and box[1] <= point[1] <= box[3]
+
+
+def _distance_to_segment(point, start, end) -> float:
+ if not start or not end:
+ return math.inf
+ px, py = point
+ ax, ay = float(start[0]), float(start[1])
+ bx, by = float(end[0]), float(end[1])
+ dx, dy = bx - ax, by - ay
+ length_sq = dx * dx + dy * dy
+ if length_sq <= 0.0:
+ return math.hypot(px - ax, py - ay)
+ t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length_sq))
+ return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
+
+
+# ── the six checks ──────────────────────────────────────────────────────────
+
+
+def _missing_centreline(index: dict) -> list[Issue]:
+ """ISO 128-23: a circular feature carries a centre mark."""
+ geometry = {ROLE_LAYER["visible"].upper(), ROLE_LAYER["hidden"].upper()}
+ centre_layer = ROLE_LAYER["center"].upper()
+ marks = [
+ e
+ for e in index["entities"]
+ if str(e.type).upper() == "LINE" and str(e.layer).upper() == centre_layer
+ ]
+ out: list[Issue] = []
+ for ent in index["entities"]:
+ if str(ent.type).upper() != "CIRCLE" or str(ent.layer).upper() not in geometry:
+ continue
+ centre = (ent.properties or {}).get("center")
+ if not centre:
+ continue
+ point = (float(centre[0]), float(centre[1]))
+ crossing = [
+ m
+ for m in marks
+ if _distance_to_segment(
+ point, (m.properties or {}).get("start"), (m.properties or {}).get("end")
+ )
+ <= CENTRE_TOL_MM
+ ]
+ if len(crossing) < 2:
+ out.append(
+ Issue(
+ "warning",
+ "mech_missing_centreline",
+ f"Circle {ent.handle} at ({point[0]:.3f}, {point[1]:.3f}) has no ISO "
+ f"128-23 centre mark ({len(crossing)} of the two crossing "
+ f"{centre_layer} lines found).",
+ [ent.handle],
+ {"hint": "centre_marks(handles=[...]) draws them", "found": len(crossing)},
+ )
+ )
+ return out
+
+
+def _unhatched_section(index: dict) -> list[Issue]:
+ hatches = [e for e in index["entities"] if str(e.type).upper() == "HATCH"]
+ out: list[Issue] = []
+ for view in index.get("views", ()):
+ if view["kind"] != "section":
+ continue
+ if any(_inside(_bbox_center(h), view["box"]) for h in hatches):
+ continue
+ out.append(
+ Issue(
+ "error",
+ "mech_unhatched_section",
+ f"Section view {view['index']} of part {view['part_id']!r} "
+ f"({view['handle']}) has no hatched cut face; ISO 128-50 hatches every "
+ "cut surface.",
+ [view["handle"]],
+ {
+ "hint": "hatch_material(handles=[...], material=...)",
+ "hatches": len(hatches),
+ "view": view["index"],
+ },
+ )
+ )
+ return out
+
+
+def _view_misaligned(index: dict) -> list[Issue]:
+ out: list[Issue] = []
+ by_part: dict[str, list[dict]] = {}
+ for view in index.get("views", ()):
+ by_part.setdefault(view["part_id"], []).append(view)
+ for part_id, records in sorted(by_part.items()):
+ fronts = [r for r in records if r["kind"] == "front"]
+ if not fronts:
+ continue
+ front = fronts[0]
+ fx, fy = front["at"]
+ for record in records:
+ kind = record["kind"]
+ x, y = record["at"]
+ if kind in ("side", "section"):
+ delta = abs(y - fy)
+ axis = "y"
+ elif kind == "top":
+ delta = abs(x - fx)
+ axis = "x"
+ else:
+ continue
+ if delta <= ALIGN_TOL_MM:
+ continue
+ out.append(
+ Issue(
+ "warning",
+ "mech_view_misaligned",
+ f"The {kind} view {record['handle']} of part {part_id!r} sits "
+ f"{delta:.3f} mm off the projection axis of its front view "
+ f"{front['handle']} (view {record['index']} of the part).",
+ list(dict.fromkeys((record["handle"], front["handle"]))),
+ {
+ "axis": axis,
+ "delta_mm": round(delta, 4),
+ "tolerance_mm": ALIGN_TOL_MM,
+ },
+ )
+ )
+ return out
+
+
+def _duplicate_dimension(index: dict) -> list[Issue]:
+ seen: dict[tuple, str] = {}
+ out: list[Issue] = []
+ for ent in index["entities"]:
+ if not _is_dimension(ent):
+ continue
+ props = ent.properties or {}
+ box = props.get("bounding_box")
+ if not box:
+ continue
+ defpoint = props.get("defpoint")
+ key = (
+ str(ent.type).upper(),
+ tuple(
+ round(float(v), 3)
+ for v in (box["min"][0], box["min"][1], box["max"][0], box["max"][1])
+ ),
+ tuple(round(float(v), 3) for v in defpoint[:2]) if defpoint else None,
+ )
+ if key in seen:
+ out.append(
+ Issue(
+ "warning",
+ "mech_duplicate_dimension",
+ f"Dimensions {seen[key]} and {ent.handle} occupy the same place and "
+ "measure the same feature; ISO 129-1 gives each feature one dimension.",
+ [seen[key], ent.handle],
+ {"hint": "delete one, or dimension the feature in a single view"},
+ )
+ )
+ else:
+ seen[key] = ent.handle
+ return out
+
+
+def _thread_diameter(feature: dict) -> float | None:
+ for key in ("d", "diameter", "nominal", "nominal_mm"):
+ value = feature.get(key)
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ return float(value)
+ match = _METRIC_THREAD.match(str(feature.get("designation") or ""))
+ return float(match.group(1)) if match else None
+
+
+def _has_diameter(index: dict, target: float) -> bool:
+ """Is anything on the sheet drawn at this diameter?
+
+ A round feature answers as a CIRCLE or ARC of half the diameter; the axial
+ view answers as two horizontal lines that far apart.
+ """
+ tol = max(DIAMETER_TOL * target, 1e-6)
+ horizontals: list[float] = []
+ for ent in index["entities"]:
+ kind = str(ent.type).upper()
+ props = ent.properties or {}
+ if kind in ("CIRCLE", "ARC"):
+ radius = props.get("radius")
+ if radius is not None and abs(2.0 * float(radius) - target) <= tol:
+ return True
+ elif kind == "LINE":
+ start, end = props.get("start"), props.get("end")
+ if start and end and abs(float(start[1]) - float(end[1])) <= 1e-6:
+ horizontals.append(float(start[1]))
+ for i, y1 in enumerate(horizontals[:500]):
+ for y2 in horizontals[i + 1 : 500]:
+ if abs(abs(y1 - y2) - target) <= tol:
+ return True
+ return False
+
+
+def _thread_unrepresented(index: dict) -> list[Issue]:
+ out: list[Issue] = []
+ for record in _of_kind(index, "part"):
+ part = record["payload"].get("part") or {}
+ for feature in part.get("features") or ():
+ if not isinstance(feature, dict):
+ continue
+ if not str(feature.get("kind") or "").lower().startswith("thread"):
+ continue
+ nominal = _thread_diameter(feature)
+ if nominal is None:
+ continue
+ minor = THREAD_MINOR_RATIO * nominal
+ # ISO 6410: the thin line is the minor diameter of an external
+ # thread and the major diameter of an internal one (a tapped
+ # hole's drilled bore is the minor, drawn thick).
+ internal = bool(feature.get("internal"))
+ thin = nominal if internal else minor
+ if _has_diameter(index, thin):
+ continue
+ name = feature.get("id") or feature.get("designation") or "thread"
+ which = (
+ f"major diameter ({nominal:.3f} mm)"
+ if internal
+ else f"minor diameter ({minor:.3f} mm = {THREAD_MINOR_RATIO:g} x {nominal:.3f} mm)"
+ )
+ out.append(
+ Issue(
+ "error",
+ "mech_thread_unrepresented",
+ f"Thread {name!r} on part {record['payload'].get('id')!r} is drawn "
+ f"without its ISO 6410 thin-line {which}.",
+ [record["handle"]],
+ {
+ "nominal_mm": round(nominal, 4),
+ "minor_mm": round(minor, 4),
+ "internal": internal,
+ },
+ )
+ )
+ return out
+
+
+def _bom_balloon_mismatch(index: dict) -> list[Issue]:
+ out: list[Issue] = []
+ balloons = _of_kind(index, "balloon")
+ parts = _of_kind(index, "std_part")
+ if not balloons and not parts:
+ return out
+ part_handles = {r["handle"] for r in parts} | {r["handle"] for r in _of_kind(index, "part")}
+ balloon_targets: set[str] = set()
+ by_item: dict[str, list[dict]] = {}
+ for record in balloons:
+ item = record["payload"].get("item")
+ targets = [str(t) for t in (record["payload"].get("targets") or [])]
+ by_item.setdefault(str(item), []).append(record)
+ resolved = [t for t in targets if t in part_handles]
+ balloon_targets.update(resolved)
+ if not resolved:
+ out.append(
+ Issue(
+ "error",
+ "mech_bom_balloon_mismatch",
+ f"Balloon {item!r} ({record['handle']}) points at "
+ f"{targets or 'nothing'}, which is not a part on this sheet.",
+ [record["handle"]],
+ {"targets": targets},
+ )
+ )
+ for item, records in sorted(by_item.items()):
+ if len(records) > 1:
+ out.append(
+ Issue(
+ "warning",
+ "mech_bom_balloon_mismatch",
+ f"Item number {item} is carried by {len(records)} balloons; ISO 6433 "
+ "gives one item reference per parts-list row.",
+ [r["handle"] for r in records],
+ {"item": item, "count": len(records)},
+ )
+ )
+ if balloons:
+ # ISO 6433 balloons a parts-list *row*, not every instance: four
+ # identical bolts share one item reference, so a part is covered when
+ # any balloon points at a part of the same designation.
+ covered = {
+ str(r["payload"].get("designation")) for r in parts if r["handle"] in balloon_targets
+ }
+ for record in parts:
+ if record["handle"] in balloon_targets:
+ continue
+ if str(record["payload"].get("designation")) in covered:
+ continue
+ out.append(
+ Issue(
+ "warning",
+ "mech_bom_balloon_mismatch",
+ f"{record['payload'].get('designation')} ({record['handle']}) has no "
+ "balloon; ISO 7573 gives every parts-list row an item reference.",
+ [record["handle"]],
+ {"designation": record["payload"].get("designation")},
+ )
+ )
+ inserted: dict[str, int] = {}
+ declared: dict[str, int] = {}
+ for record in parts:
+ designation = str(record["payload"].get("designation"))
+ inserted[designation] = inserted.get(designation, 0) + 1
+ declared[designation] = declared.get(designation, 0) + int(
+ record["payload"].get("qty") or 1
+ )
+ for designation, count in sorted(inserted.items()):
+ if declared[designation] != count:
+ out.append(
+ Issue(
+ "warning",
+ "mech_bom_balloon_mismatch",
+ f"{designation} is inserted {count} time(s) but its XDATA declares "
+ f"{declared[designation]}.",
+ [r["handle"] for r in parts if r["payload"].get("designation") == designation],
+ {"inserted": count, "declared": declared[designation]},
+ )
+ )
+ return out
+
+
+_CHECKS = {
+ "mech_missing_centreline": _missing_centreline,
+ "mech_unhatched_section": _unhatched_section,
+ "mech_view_misaligned": _view_misaligned,
+ "mech_duplicate_dimension": _duplicate_dimension,
+ "mech_thread_unrepresented": _thread_unrepresented,
+ "mech_bom_balloon_mismatch": _bom_balloon_mismatch,
+}
+
+
+def issues_for(focus: str, index: dict) -> list[Issue]:
+ if not has_mech_content(index):
+ return []
+ return _CHECKS[focus](index)
+
+
+def _make(focus: str):
+ async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
+ index = shared.get(_KEY)
+ if index is None:
+ index = shared[_KEY] = await build_index(backend)
+ return issues_for(focus, index)
+
+ check.needs_shared = True # type: ignore[attr-defined]
+ check.__name__ = f"check_{focus}"
+ return check
+
+
+MECH_DISPATCH = {focus: _make(focus) for focus in MECH_FOCUSES}
diff --git a/engineering/plan_spec.py b/engineering/plan_spec.py
index 1b1d7bb..b4253c1 100644
--- a/engineering/plan_spec.py
+++ b/engineering/plan_spec.py
@@ -27,6 +27,12 @@ CritiqueFocus = Literal[
"pid_untagged_instrument",
"pid_illegal_tag",
"pid_unconnected_equipment",
+ "mech_missing_centreline",
+ "mech_unhatched_section",
+ "mech_view_misaligned",
+ "mech_duplicate_dimension",
+ "mech_thread_unrepresented",
+ "mech_bom_balloon_mismatch",
]
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
@@ -44,6 +50,12 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
"pid_untagged_instrument",
"pid_illegal_tag",
"pid_unconnected_equipment",
+ "mech_missing_centreline",
+ "mech_unhatched_section",
+ "mech_view_misaligned",
+ "mech_duplicate_dimension",
+ "mech_thread_unrepresented",
+ "mech_bom_balloon_mismatch",
)
diff --git a/server.py b/server.py
index 964a787..40d5fc9 100644
--- a/server.py
+++ b/server.py
@@ -691,6 +691,7 @@ async def _registered_tool_count() -> int | None:
_GROUP_TAG_PRIORITY = (
"pid",
"mech",
+ "sheet",
"style",
"pagesetup",
"environment",
@@ -719,7 +720,8 @@ _GROUP_TAG_PRIORITY = (
# Map the winning tag to a human-readable group label for the breakdown.
_GROUP_TAG_LABELS = {
"pid": "pid",
- "mech": "mech",
+ "mech": "mechanical",
+ "sheet": "sheet",
"style": "styles",
"pagesetup": "page_setup",
"environment": "environment",
@@ -949,6 +951,17 @@ LEAN_TOOL_NAMES = frozenset(
# them on the lean surface; with S's three above, lean is 55.
"page_setup_apply",
"batch_plot",
+ # Mechanical + sheet (tracks B/G) — the five a lean client needs to
+ # draw a part and put it on a sheet. The catalogue, the annotation
+ # symbols and the delivery tools stay full-profile: a lean client
+ # draws the part and leaves the paperwork to a full one. The first
+ # three are `mech`-pack tools, so TOOL_PACKS=core hides them from a
+ # lean surface too; sheet_frame and titleblock_apply are core.
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "sheet_frame",
+ "titleblock_apply",
}
)
@@ -1004,8 +1017,10 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"system_prompt_message",
}
),
- # SECTIONS 21-23 - the mechanical part model, standard parts and
- # mechanical annotation (tracks B+G).
+ # SECTIONS 21-23 — the mechanical part drawer, the standard-parts catalogue
+ # and the ISO annotation symbols (spec §11). SECTION 24 (the sheet, the
+ # parts list, xrefs and DWG) stays in `core`: a frame, a title block, a
+ # parts list and an xref are universal drafting, not a vertical.
"mech": frozenset(
{
"mech_part_draw",
@@ -5692,7 +5707,10 @@ async def drawing_finalize(
(iso128, layer_color, dim_overlap, untrimmed_corner, duplicate_entities, construction_left,
gdt, plus the P&ID focuses pid_dangling_line, pid_duplicate_tag,
pid_incompatible_connection, pid_untagged_instrument, pid_illegal_tag,
- pid_unconnected_equipment — silent on a sheet with no P&ID symbols),
+ pid_unconnected_equipment — silent on a sheet with no P&ID symbols, plus the
+ mechanical focuses mech_missing_centreline, mech_unhatched_section,
+ mech_view_misaligned, mech_duplicate_dimension, mech_thread_unrepresented,
+ mech_bom_balloon_mismatch — silent on a sheet with no mechanical part),
then saves to disk, exports a screenshot, and returns the DWG path.
Raises ToolError if any validator 'error' finding is present, or if critique reports an
@@ -5947,10 +5965,14 @@ async def drawing_critique(
Field(
default=None,
description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, "
- "duplicate_entities, construction_left, gdt, and the P&ID focuses "
+ "duplicate_entities, construction_left, gdt, the P&ID focuses "
"pid_dangling_line, pid_duplicate_tag, pid_incompatible_connection, "
"pid_untagged_instrument, pid_illegal_tag, pid_unconnected_equipment "
- "(silent on a drawing with no P&ID symbols). None = run all.",
+ "(silent on a drawing with no P&ID symbols), and the mechanical focuses "
+ "mech_missing_centreline, mech_unhatched_section, mech_view_misaligned, "
+ "mech_duplicate_dimension, mech_thread_unrepresented, "
+ "mech_bom_balloon_mismatch (silent on a drawing with no mechanical part). "
+ "None = run all.",
),
] = None,
ctx: Context = None,
@@ -9576,7 +9598,7 @@ async def balloon_add(
)
@mcp.tool(
annotations={"title": "Sheet: Attach Xref", "destructiveHint": False},
- tags={"drawing", "block"},
+ tags={"sheet", "drawing", "block"},
)
async def xref_attach(
path: Annotated[str, Field(description="Full path of the drawing to reference.")],
@@ -9612,7 +9634,7 @@ async def xref_attach(
@cad_tool(summary="List, reload, bind, detach or repath an external reference.", cost="mutate")
@mcp.tool(
annotations={"title": "Sheet: Manage Xrefs", "destructiveHint": True},
- tags={"drawing", "block"},
+ tags={"sheet", "drawing", "block"},
)
async def xref_manage(
action: Annotated[
@@ -9646,7 +9668,7 @@ async def xref_manage(
@cad_tool(summary="Attach a raster image (PNG/JPG) as an underlay.", cost="mutate")
@mcp.tool(
annotations={"title": "Sheet: Attach Image", "destructiveHint": False},
- tags={"drawing", "create"},
+ tags={"sheet", "drawing", "create"},
)
async def image_attach(
path: Annotated[str, Field(description="Full path of the raster image.")],
@@ -9720,7 +9742,7 @@ async def data_extract(
@cad_tool(summary="Write the drawing as a real DWG at a chosen AutoCAD version.", cost="safe")
@mcp.tool(
annotations={"title": "Drawing: Export DWG", "destructiveHint": False},
- tags={"drawing"},
+ tags={"sheet", "drawing"},
)
async def drawing_export_dwg(
path: Annotated[str, Field(description="Full path of the .dwg to write.")],
diff --git a/tests/data/discovery_golden.py b/tests/data/discovery_golden.py
index 3556bd1..e85aabc 100644
--- a/tests/data/discovery_golden.py
+++ b/tests/data/discovery_golden.py
@@ -1,6 +1,6 @@
"""Golden query set for tool discovery: English in, one right tool out.
-Eighty-one cases (66 tuning + 15 holdout) split into a **tuning** set and a
+Ninety-one cases (76 tuning + 15 holdout) split into a **tuning** set and a
**holdout** set. The split is
the point of the file. Ranking work is measured against
:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time,
@@ -174,6 +174,22 @@ TUNING_CASES: tuple[GoldenCase, ...] = (
"counting",
risk="read",
),
+ # v1.6 tracks B + G — the mechanical part drawer and the sheet standard.
+ # Written before the merged alias records were measured against them; a
+ # miss here is fixed by adding vocabulary to that tool's record in
+ # discovery/aliases.py, never by moving the expectation. Two Turkish
+ # queries are in deliberately: this shop searches in Turkish, and the
+ # alias corpus is the only place that vocabulary can live.
+ GoldenCase("XATTACH", "xref_attach", "command"),
+ GoldenCase("IMAGEATTACH", "image_attach", "command"),
+ GoldenCase("draw a stepped shaft from a segment list", "mech_part_draw", "paraphrase"),
+ GoldenCase("add a section view of the part", "mech_view_add", "paraphrase"),
+ GoldenCase("kaynak sembolü", "weld_symbol", "synonym"),
+ GoldenCase("yüzey pürüzlülüğü", "surface_texture", "synonym"),
+ GoldenCase("iso 5457 sheet frame", "sheet_frame", "synonym"),
+ GoldenCase("put an m12 hex bolt on the drawing", "std_part_insert", "paraphrase"),
+ GoldenCase("draw the parts list table on the sheet", "bom_table", "paraphrase"),
+ GoldenCase("save this drawing as a dwg", "drawing_export_dwg", "paraphrase"),
)
diff --git a/tests/test_mech_critique.py b/tests/test_mech_critique.py
new file mode 100644
index 0000000..d23cb5b
--- /dev/null
+++ b/tests/test_mech_critique.py
@@ -0,0 +1,361 @@
+"""Six mechanical focuses, each with a positive and a negative fixture.
+
+All six are silent on a drawing with no ACADMCP_MECH payload on it, so
+`focus=None` stays cheap for a P&ID sheet or a plain DXF.
+
+A fixture writes the payload onto a small CIRCLE on layer 0 rather than onto a
+block reference. The index reads INSERT and CIRCLE alike (a balloon is a circle
+with a leader), and a circle keeps the fixtures free of block definitions —
+what is under test here is the reading, not the drawing.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.layers import ensure_engineering_layers
+from engineering.mech.critique import (
+ MECH_FOCUSES,
+ build_index,
+ has_mech_content,
+ issues_for,
+)
+from engineering.mech.xdata import APP_ID, to_values
+from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
+
+pytestmark = pytest.mark.asyncio
+
+SHAFT = {
+ "name": "shaft",
+ "material": "steel",
+ "segments": [{"length": 60.0, "d_outer": 40.0, "d_inner": 20.0}],
+ "features": [],
+}
+
+
+async def _anchor(backend, payload, x=0.0, y=0.0):
+ ent = await backend.entity_create_circle(x, y, 0.5, layer="0")
+ await backend.entity_set_xdata(ent.handle, APP_ID, to_values(payload))
+ return ent.handle
+
+
+def _focus(index, name):
+ return issues_for(name, index)
+
+
+async def test_the_six_focuses_are_the_spec_ones_and_are_in_the_closed_enum():
+ assert MECH_FOCUSES == (
+ "mech_missing_centreline",
+ "mech_unhatched_section",
+ "mech_view_misaligned",
+ "mech_duplicate_dimension",
+ "mech_thread_unrepresented",
+ "mech_bom_balloon_mismatch",
+ )
+ for focus in MECH_FOCUSES:
+ assert focus in ALL_CRITIQUE_FOCUSES
+
+
+async def test_every_focus_is_silent_on_a_drawing_with_no_mechanical_content(backend):
+ """A circle with no centre mark, two identical dimensions and no hatch —
+ every trigger present, and not one issue, because nothing on the sheet
+ claims to be a mechanical part."""
+ await ensure_engineering_layers(backend)
+ await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
+ await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
+ await backend.dimension_linear(0, 0, 100, 0, 50, 20, layer="DIM")
+ index = await build_index(backend)
+ assert has_mech_content(index) is False
+ for focus in MECH_FOCUSES:
+ assert _focus(index, focus) == []
+
+
+async def test_missing_centreline_fires_on_a_bare_circle(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
+ await backend.entity_create_circle(100.0, 100.0, 10.0, layer="GEOMETRY")
+ issues = _focus(await build_index(backend), "mech_missing_centreline")
+ assert len(issues) == 1
+ assert issues[0].focus == "mech_missing_centreline"
+ assert "centre mark" in issues[0].message
+
+
+async def test_missing_centreline_is_quiet_once_the_cross_is_drawn(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
+ await backend.entity_create_circle(100.0, 100.0, 10.0, layer="GEOMETRY")
+ await backend.entity_create_line(87.0, 100.0, 113.0, 100.0, layer="CENTER")
+ await backend.entity_create_line(100.0, 87.0, 100.0, 113.0, layer="CENTER")
+ assert _focus(await build_index(backend), "mech_missing_centreline") == []
+
+
+async def test_unhatched_section_fires_on_a_section_view_with_no_hatch(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(
+ backend,
+ {"v": 1, "kind": "part", "id": "P1", "view": "section", "part": SHAFT},
+ x=0.0,
+ y=0.0,
+ )
+ issues = _focus(await build_index(backend), "mech_unhatched_section")
+ assert len(issues) == 1
+ assert issues[0].severity == "error"
+ assert "hatched cut face" in issues[0].message
+
+
+async def test_unhatched_section_is_quiet_when_the_cut_face_is_hatched(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(
+ backend,
+ {"v": 1, "kind": "part", "id": "P1", "view": "section", "part": SHAFT},
+ x=0.0,
+ y=0.0,
+ )
+ await backend.entity_create_hatch(
+ "ANSI31", [[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0]], layer="HATCH"
+ )
+ assert _focus(await build_index(backend), "mech_unhatched_section") == []
+
+
+async def test_view_misaligned_fires_when_a_side_view_leaves_the_projection_axis(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(
+ backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}, 0.0, 0.0
+ )
+ await _anchor(
+ backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": SHAFT}, 150.0, 5.0
+ )
+ issues = _focus(await build_index(backend), "mech_view_misaligned")
+ assert len(issues) == 1
+ assert issues[0].detail["axis"] == "y"
+ assert issues[0].detail["delta_mm"] == 5.0
+
+
+async def test_view_misaligned_is_quiet_on_an_aligned_pair(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(
+ backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT}, 0.0, 0.0
+ )
+ await _anchor(
+ backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": SHAFT}, 150.0, 0.0
+ )
+ assert _focus(await build_index(backend), "mech_view_misaligned") == []
+
+
+async def test_duplicate_dimension_fires_on_the_same_dimension_twice(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
+ await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
+ await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
+ issues = _focus(await build_index(backend), "mech_duplicate_dimension")
+ assert len(issues) == 1
+ assert len(issues[0].handles) == 2
+
+
+async def test_duplicate_dimension_is_quiet_on_two_different_dimensions(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
+ await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
+ await backend.dimension_linear(0, 0, 40, 0, 20, -35, layer="DIM")
+ assert _focus(await build_index(backend), "mech_duplicate_dimension") == []
+
+
+THREADED = {
+ "name": "stud",
+ "material": "steel",
+ "segments": [{"length": 60.0, "d_outer": 20.0}],
+ "features": [{"kind": "thread", "id": "t1", "designation": "M20x1.5", "segment": 0}],
+}
+
+
+async def test_thread_unrepresented_fires_when_only_the_major_diameter_is_drawn(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": THREADED})
+ await backend.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY")
+ issues = _focus(await build_index(backend), "mech_thread_unrepresented")
+ assert len(issues) == 1
+ assert issues[0].detail["minor_mm"] == 16.0
+ assert "ISO 6410" in issues[0].message
+
+
+async def test_thread_unrepresented_is_quiet_once_the_minor_circle_is_drawn(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "side", "part": THREADED})
+ await backend.entity_create_circle(0.0, 0.0, 10.0, layer="GEOMETRY")
+ await backend.entity_create_arc(0.0, 0.0, 8.0, 0.0, 270.0, layer="GEOMETRY")
+ assert _focus(await build_index(backend), "mech_thread_unrepresented") == []
+
+
+BOLT = {
+ "v": 1,
+ "kind": "std_part",
+ "designation": "ISO 4014 - M12x60",
+ "standard": "ISO 4014",
+ "size": "M12x60",
+ "qty": 1,
+ "material": "8.8",
+}
+
+
+async def test_bom_balloon_mismatch_fires_on_a_balloon_pointing_at_nothing(backend):
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, BOLT, 0.0, 0.0)
+ await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": ["DEAD"]}, 40.0, 40.0)
+ issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch")
+ assert any("not a part on this sheet" in issue.message for issue in issues)
+
+
+async def test_bom_balloon_mismatch_fires_on_a_part_with_no_balloon(backend):
+ await ensure_engineering_layers(backend)
+ bolt = await _anchor(backend, BOLT, 0.0, 0.0)
+ await _anchor(
+ backend, {**BOLT, "size": "M16x60", "designation": "ISO 4014 - M16x60"}, 60.0, 0.0
+ )
+ await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [bolt]}, 40.0, 40.0)
+ issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch")
+ assert any("has no balloon" in issue.message for issue in issues)
+
+
+async def test_bom_balloon_mismatch_fires_when_the_declared_quantity_disagrees(backend):
+ await ensure_engineering_layers(backend)
+ first = await _anchor(backend, {**BOLT, "qty": 4}, 0.0, 0.0)
+ await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [first]}, 40.0, 40.0)
+ issues = _focus(await build_index(backend), "mech_bom_balloon_mismatch")
+ assert any("declares 4" in issue.message for issue in issues)
+
+
+async def test_bom_balloon_mismatch_is_quiet_on_a_matched_sheet(backend):
+ await ensure_engineering_layers(backend)
+ bolt = await _anchor(backend, BOLT, 0.0, 0.0)
+ await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [bolt]}, 40.0, 40.0)
+ assert _focus(await build_index(backend), "mech_bom_balloon_mismatch") == []
+
+
+async def test_the_dispatch_entries_share_one_index_per_run(backend):
+ """Six focuses must cost one read of the drawing, like the P&ID ones."""
+ from engineering.mech.critique import MECH_DISPATCH
+
+ await ensure_engineering_layers(backend)
+ await _anchor(backend, {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": SHAFT})
+ shared: dict = {}
+ for focus in MECH_FOCUSES:
+ check = MECH_DISPATCH[focus]
+ assert check.needs_shared is True
+ await check(backend, shared)
+ assert list(shared) == ["mech_index"]
+
+
+# ── the payloads the real writers put on the drawing ────────────────────────
+#
+# The fixtures above anchor a hand-written payload on a CIRCLE. The tools do
+# not: mech_part_draw anchors its part payload on a POINT (layer MECH) and
+# lists every view it drew under "views", std_part_insert writes onto the
+# INSERT, balloon_add onto the balloon's circle. An index that read only the
+# fixtures' shape would be silent on every drawing the server actually makes.
+
+REAL_SHAFT = {
+ "kind": "revolved",
+ "name": "stud",
+ "material": "steel",
+ "segments": [
+ {"length": 30.0, "d_outer": 20.0},
+ {"length": 50.0, "d_outer": 30.0, "d_inner": 10.0},
+ ],
+ "features": [
+ {"kind": "thread", "id": "t1", "designation": "M20x1.5", "segment": 0, "length": 20.0}
+ ],
+}
+CROSS_SECTION = {"p1": [50.0, -30.0], "p2": [50.0, 30.0]}
+
+
+async def _real_part(backend):
+ from engineering.mech.draw import add_view, draw_part
+ from engineering.mech.part import build_part
+
+ drawn = await draw_part(backend, build_part(REAL_SHAFT), views=("front", "side"))
+ await add_view(backend, drawn["part_id"], "section", plane=CROSS_SECTION)
+ return drawn["part_id"]
+
+
+async def test_a_part_drawn_by_the_tools_is_read_and_passes_every_focus(backend):
+ part_id = await _real_part(backend)
+ index = await build_index(backend)
+ assert has_mech_content(index) is True
+ assert [r["handle"] for r in index["records"]] == [part_id]
+ assert [v["kind"] for v in index["views"]] == ["front", "side", "section"]
+ for focus in MECH_FOCUSES:
+ assert _focus(index, focus) == [], focus
+
+
+async def test_a_real_section_whose_hatch_was_erased_is_reported(backend):
+ part_id = await _real_part(backend)
+ for hatch in await backend.entity_list(type_filter="HATCH"):
+ await backend.entity_delete(hatch.handle)
+ issues = _focus(await build_index(backend), "mech_unhatched_section")
+ assert len(issues) == 1
+ assert issues[0].handles == [part_id]
+ assert issues[0].detail["view"] == 2
+
+
+async def test_identical_parts_share_one_balloon(backend):
+ """ISO 6433 balloons a parts-list row: four identical bolts, one item."""
+ await ensure_engineering_layers(backend)
+ first = await _anchor(backend, BOLT, 0.0, 0.0)
+ await _anchor(backend, BOLT, 60.0, 0.0)
+ await _anchor(backend, {"v": 1, "kind": "balloon", "item": 1, "targets": [first]}, 40.0, 40.0)
+ assert _focus(await build_index(backend), "mech_bom_balloon_mismatch") == []
+
+
+async def test_the_index_reads_past_the_first_page_of_entities(backend):
+ """entity_list defaults to 200; a payload on entity 251 must still count."""
+ await ensure_engineering_layers(backend)
+ for i in range(250):
+ await backend.entity_create_line(float(i), 0.0, float(i), 5.0, layer="GEOMETRY")
+ await _anchor(backend, BOLT, 0.0, 50.0)
+ assert has_mech_content(await build_index(backend)) is True
+
+
+class _LiveTypeNames:
+ """The ezdxf backend reporting entity types the way the live engine does.
+
+ ComBackend names a type from its ActiveX ObjectName with ``AcDb`` stripped
+ and upper-cased; measured on AutoCAD 2026 (2026-09-23), a block reference
+ reads back as BLOCKREFERENCE and a linear dimension as ROTATEDDIMENSION.
+ Only those two renames are applied -- POINT, CIRCLE, LINE and HATCH are
+ already the same on both engines.
+ """
+
+ _RENAME = {"INSERT": "BLOCKREFERENCE", "DIMENSION": "ROTATEDDIMENSION"}
+
+ def __init__(self, inner):
+ self._inner = inner
+
+ def __getattr__(self, name):
+ return getattr(self._inner, name)
+
+ async def entity_list(self, *args, **kwargs):
+ import dataclasses
+
+ return [
+ dataclasses.replace(ent, type=self._RENAME.get(ent.type, ent.type))
+ for ent in await self._inner.entity_list(*args, **kwargs)
+ ]
+
+
+async def test_the_live_engine_type_names_are_read(backend):
+ """A standard part on an INSERT and two stacked dimensions, seen through
+ the live engine's type names: the payload is still read and the duplicate
+ still found."""
+ await ensure_engineering_layers(backend)
+ ref = await backend.entity_create_circle(0.0, 0.0, 1.0, layer="0")
+ await backend.block_create_from_entities("T15_BOLT", [ref.handle], 0.0, 0.0)
+ bolt = await backend.block_insert("T15_BOLT", 0.0, 0.0, 1.0, 1.0, 0.0, None, "GEOMETRY")
+ await backend.entity_set_xdata(bolt.handle, APP_ID, to_values(BOLT))
+ await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
+ await backend.dimension_linear(0, 0, 60, 0, 30, -20, layer="DIM")
+
+ index = await build_index(_LiveTypeNames(backend))
+ assert [r["handle"] for r in index["records"]] == [bolt.handle]
+ assert {e.type for e in index["entities"]} >= {"BLOCKREFERENCE", "ROTATEDDIMENSION"}
+ duplicates = _focus(index, "mech_duplicate_dimension")
+ assert len(duplicates) == 1 and len(duplicates[0].handles) == 2
diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py
index 0eeef2c..601afae 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -8,7 +8,9 @@ surface and nothing else.
v1.6 tracks B+G add the `mech` pack: SECTION 21's six part-model tools and
SECTION 22's three standard-parts tools and SECTION 23's five annotation
-symbols - 14 in all.
+symbols - 14 in all. SECTION 24 (the sheet: frames, title blocks, revisions,
+the parts list, balloons, xrefs, images, DWG) stays in `core` - universal
+drafting, not a vertical - and files under its own `sheet` group.
"""
from __future__ import annotations
@@ -99,6 +101,28 @@ MECH_TOOLS = {
"hatch_material",
}
+SHEET_TOOLS = {
+ "sheet_frame",
+ "titleblock_apply",
+ "revision_add",
+ "bom_extract",
+ "bom_table",
+ "balloon_add",
+ "data_extract",
+ "xref_attach",
+ "xref_manage",
+ "image_attach",
+ "drawing_export_dwg",
+}
+
+LEAN_MECH_ESSENTIALS = {
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "sheet_frame",
+ "titleblock_apply",
+}
+
LEAN_SETTINGS_ESSENTIALS = {
"drawing_apply_standard",
"page_setup_apply",
@@ -130,6 +154,9 @@ def test_pack_registry_names_real_tools_and_only_them():
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
+ assert not (SHEET_TOOLS & set().union(*server.PACK_TOOL_NAMES.values())), (
+ "SECTION 24 is core drafting, not a vertical pack"
+ )
async def test_every_pack_member_is_a_registered_tool():
@@ -146,6 +173,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(
assert PID_TOOLS <= disabled
assert SETTINGS_TOOLS <= disabled
assert MECH_TOOLS <= disabled
+ assert not (SHEET_TOOLS & disabled)
assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
assert "block_define" not in disabled and "entity_set_xdata" not in disabled
@@ -194,16 +222,19 @@ async def test_lean_intersects_with_packs(monkeypatch):
async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack(
monkeypatch,
):
- """Spec §8.2: lean = 55. The five are core-pack tools, so `TOOL_PACKS=core`
- leaves them on a lean surface; no environment tool is lean."""
+ """Spec §8.2: lean = 60 (55 + the five mechanical/sheet essentials). The
+ five settings essentials are core-pack tools, so `TOOL_PACKS=core` leaves
+ them on a lean surface; no environment tool is lean."""
assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES
+ assert LEAN_MECH_ESSENTIALS <= server.LEAN_TOOL_NAMES
assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES)
- assert len(server.LEAN_TOOL_NAMES) == 55
+ assert len(server.LEAN_TOOL_NAMES) == 60
monkeypatch.setattr(config.settings, "tool_packs", "core")
info = await server._apply_tool_profile("lean")
disabled = set(info["disabled_tools"])
assert not (LEAN_SETTINGS_ESSENTIALS & disabled)
- assert info["enabled_count"] == 55 - 3, "lean minus the three pid tools"
+ assert not ({"sheet_frame", "titleblock_apply"} & disabled), "sheet tools are core"
+ assert info["enabled_count"] == 60 - 6, "lean minus the three pid and three mech tools"
async def test_the_three_track_e_sections_file_under_their_own_groups():
@@ -213,8 +244,9 @@ 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 set(groups["mech"]) == MECH_TOOLS
- assert len(groups) == 24
+ assert set(groups["mechanical"]) == MECH_TOOLS
+ assert set(groups["sheet"]) == SHEET_TOOLS
+ assert len(groups) == 25
async def test_system_about_reports_packs():
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index bafc58a..aa37309 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
- """Frozen snapshot of the surface (229 tools, 24 groups).
+ """Frozen snapshot of the surface (229 tools, 25 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -565,26 +565,43 @@ async def test_tool_group_sizes_are_unchanged():
are tagged `engineering` and `mech`; with `mech` ranked ahead of
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched.
+
+ Two groups appeared with v1.6's tracks B and G — `mechanical` (14,
+ SECTIONS 21-23: the part drawer, the standard-parts catalogue and the ISO
+ annotation symbols) and `sheet` (11, SECTION 24: frames, title blocks,
+ revisions, the parts list, balloons, xrefs, images and DWG). Both tags are
+ ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so a part tool that
+ also carries `create` and an annotation tool that also carries `dimension`
+ file under their own section. `mechanical` is exactly
+ `PACK_TOOL_NAMES["mech"]` (the `mech` label above was renamed when the
+ integration task gave the section its full name); `sheet` is core-pack,
+ because a frame, a title block, a parts list and an xref are universal
+ drafting. The integration also tagged SECTION 24's last four tools
+ (`xref_attach`, `xref_manage`, `image_attach`, `drawing_export_dwg`) with
+ `sheet`, so pack, group and SECTION are one list again: `engineering`
+ returned 17 -> 10, `blocks` 11 -> 9, `entity_creation` 19 -> 18 and
+ `drawing` 12 -> 11 — every one of them its pre-SECTION-24 value.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
"batch": 3,
- "blocks": 11,
+ "blocks": 9,
"corner_ops": 4,
"dimensions": 5,
- "drawing": 12,
- "engineering": 17,
- "entity_creation": 19,
+ "drawing": 11,
+ "engineering": 10,
+ "entity_creation": 18,
"entity_modification": 16,
"entity_query": 9,
"environment": 22,
"layers": 14,
"layouts": 12,
- "mech": 14,
+ "mechanical": 14,
"page_setup": 6,
"pid": 9,
"premium": 12,
+ "sheet": 11,
"solids": 5,
"styles": 10,
"system": 7,