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