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,