diff --git a/README.md b/README.md index 6d056a1..6bb65af 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:** 207 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 207 is the **registered** count; a default install advertises 202 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. +207 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..12e049e 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1859,6 +1859,61 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "save a detail view", ), ), + # ── Standard parts (SECTION 22, tracks B+G group S) ───────────────────── + "std_part_list": ToolAliases( + acad=(), + synonyms=( + "standard parts catalogue", + "what bolts do you have", + "bolt sizes", + "hex bolt table", + "bearing list", + "iso 4014 sizes", + "find a washer", + "civata listesi", + "rulman listesi", + ), + ), + "std_part_insert": ToolAliases( + acad=("INSERT",), + synonyms=( + "insert a bolt", + "place a screw", + "draw a nut", + "add a washer", + "put a bearing in", + "hex bolt", + "socket head cap screw", + "allen screw", + "deep groove ball bearing", + "standard part", + "civata", + "somun", + "rondela", + "rulman", + ), + ), + "std_feature_draw": ToolAliases( + acad=(), + synonyms=( + "draw a thread", + "thread representation", + "undercut", + "relief groove", + "retaining ring groove", + "circlip groove", + "snap ring groove", + "centre hole", + "center drill", + "o-ring groove", + "din 509", + "din 471", + "din 332", + "vida disi", + "segman kanali", + "punta deligi", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index c5c16f1..4e1500b 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": 207, "resources": 8, "prompts": 5, - "groups": 23 + "groups": 24 }, "groups": { "analysis": 12, @@ -22,6 +22,7 @@ "environment": 22, "layers": 14, "layouts": 12, + "mech": 3, "page_setup": 6, "pid": 9, "premium": 12, @@ -2333,6 +2334,48 @@ "destructiveHint": false } }, + { + "name": "std_feature_draw", + "group": "mech", + "cost": "mutate", + "summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Standard Parts: Draw Feature", + "readOnlyHint": false + } + }, + { + "name": "std_part_insert", + "group": "mech", + "cost": "mutate", + "summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Standard Parts: Insert", + "readOnlyHint": false + } + }, + { + "name": "std_part_list", + "group": "mech", + "cost": "read", + "summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.", + "tags": [ + "mech", + "query" + ], + "annotations": { + "title": "Standard Parts: Catalogue", + "readOnlyHint": true + } + }, { "name": "system_about", "group": "system", diff --git a/engineering/mech/stdparts.py b/engineering/mech/stdparts.py new file mode 100644 index 0000000..4f969c3 --- /dev/null +++ b/engineering/mech/stdparts.py @@ -0,0 +1,1022 @@ +"""The standard-parts catalogue: tables in, real blocks with attributes out. + +Two halves live here. + +**Standard parts.** :func:`catalogue` searches the authored tables of +``standards/fasteners.py`` and ``standards/bearings.py``; :func:`block_spec` +turns one designation and one view into the typed primitives and ATTDEFs +``block_define`` takes; :func:`insert_std_part` defines the block once, inserts +it and writes the ``std_part`` ACADMCP_MECH payload, so a parts list is a read +of the drawing rather than a naming convention. This is the Track A (P&ID) +pattern applied to fasteners and bearings. + +**Standard features.** :func:`draw_std_feature` adds a thread, a DIN 509 +undercut, a retaining-ring groove, a DIN 332 centre hole or an ISO 3601-2 +O-ring groove to geometry that already exists - a foreign drawing being +finished. The thread is the ISO 6410 representation and its pitch comes from +ISO 261; every other feature takes its dimensions from the shared standards +registry and is refused by name when the table is absent, when the size is +outside its coverage, or when the row lacks a dimension the feature needs. +Nothing here reconstructs a standard profile it was not handed. + +Primitives are the ``block_define`` spec dicts of ``backends/block_specs.py`` +(``{"type": "line", "x1": ..., "layer": ...}``) with an explicit ``layer``, so +one vocabulary serves both the block definitions and the feature drawing, and +the pure half is testable by comparing emitted dicts. +""" + +from __future__ import annotations + +import difflib +import math +import re +from typing import TYPE_CHECKING + +from engineering.mech.standards import lookup as standards_lookup +from engineering.mech.standards.bearings import ( + BALL_SYMBOL_FRACTION, + CROSS_FRACTION, + SOURCE_ISO_15, + bearing, + list_bearings, +) +from engineering.mech.standards.fasteners import ( + SOURCE_ISO_4014, + SOURCE_ISO_4017, + SOURCE_ISO_4032, + SOURCE_ISO_4762, + SOURCE_ISO_7089, + across_corners, + hex_head, + hex_nut, + sizes, + socket_head, + thread_length, + thread_pitch, + washer, +) +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, to_values + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +LAYER_VISIBLE = "GEOMETRY" +LAYER_CENTER = "CENTER" +LAYER_HIDDEN = "HIDDEN" +TEXT_HEIGHT = 2.5 +AXIS_OVERRUN = 3.0 +DEFAULT_VIEW = "side" + +#: ISO 6410 draws the minor diameter of a thread at 0.8 x the major diameter. +THREAD_MINOR_RATIO = 0.8 + +SOURCE_ISO_6410 = ( + "ISO 6410-1:1993 - technical drawings, screw threads and threaded parts, general conventions" +) + +FAMILY_STANDARDS: dict[str, str] = { + "ISO 4014": "hex_bolt", + "ISO 4017": "hex_screw", + "ISO 4032": "hex_nut", + "ISO 7089": "washer", + "ISO 4762": "socket_head_screw", + "ISO 15": "deep_groove_bearing", +} +STANDARD_SOURCE: dict[str, str] = { + "ISO 4014": SOURCE_ISO_4014, + "ISO 4017": SOURCE_ISO_4017, + "ISO 4032": SOURCE_ISO_4032, + "ISO 7089": SOURCE_ISO_7089, + "ISO 4762": SOURCE_ISO_4762, + "ISO 15": SOURCE_ISO_15, +} +FAMILY_VIEWS: dict[str, tuple[str, ...]] = { + "hex_bolt": ("side", "top"), + "hex_screw": ("side", "top"), + "hex_nut": ("side", "top"), + "washer": ("side", "top"), + "socket_head_screw": ("side", "top"), + "deep_groove_bearing": ("simplified", "detailed"), +} +LENGTH_REQUIRED = frozenset({"hex_bolt", "hex_screw", "socket_head_screw"}) + +_THREAD_RE = re.compile(r"^(M\d{1,3})(?:\s*[X*]\s*(\d{1,4}(?:\.\d+)?))?$", re.IGNORECASE) +_BEARING_RE = re.compile(r"^\d{4}$") + + +# ── primitive helpers (block_define spec dicts, block-local mm) ───────────── + + +def _line(x1, y1, x2, y2, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "line", + "x1": float(x1), + "y1": float(y1), + "x2": float(x2), + "y2": float(y2), + "layer": layer, + } + + +def _circle(cx, cy, r, layer: str = LAYER_VISIBLE) -> dict: + return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": layer} + + +def _arc(cx, cy, r, start_deg, end_deg, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "arc", + "cx": float(cx), + "cy": float(cy), + "r": float(r), + "start_deg": float(start_deg), + "end_deg": float(end_deg), + "layer": layer, + } + + +def _poly(points, closed: bool = True, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "polyline", + "points": [[float(x), float(y)] for x, y in points], + "closed": bool(closed), + "layer": layer, + } + + +def _hex_points(radius: float, start_deg: float = 0.0): + return [ + ( + radius * math.cos(math.radians(start_deg + 60.0 * i)), + radius * math.sin(math.radians(start_deg + 60.0 * i)), + ) + for i in range(6) + ] + + +def _centre_mark(cx: float, cy: float, extent: float) -> list[dict]: + return [ + _line(cx - extent, cy, cx + extent, cy, LAYER_CENTER), + _line(cx, cy - extent, cx, cy + extent, LAYER_CENTER), + ] + + +# ── designations and the catalogue ────────────────────────────────────────── + + +def _normalise_standard(text: str) -> str: + return re.sub(r"^(ISO)\s*", r"\1 ", " ".join(str(text).split()).upper()) + + +def parse_designation(designation: str) -> dict: + """Split a catalogue designation into standard, family, size, thread and length. + + Accepts ``'ISO 4014 - M12x60'``, ``'iso4014-m12x60'`` and a bare four-digit + bearing number (``'6205'`` becomes ``'ISO 15 - 6205'``). Returns + ``{"designation", "standard", "family", "size", "thread", "length"}`` with + a canonical designation. Every refusal names what was wrong. + """ + text = " ".join(str(designation).split()) + if not text: + raise ValueError( + "std part designation must be a non-empty string, e.g. 'ISO 4014 - M12x60'" + ) + if _BEARING_RE.match(text): + text = f"ISO 15 - {text}" + head, separator, tail = text.partition("-") + if not separator: + raise ValueError( + f"{designation!r} is not a catalogue designation: expected " + "' - ', e.g. 'ISO 4014 - M12x60' or 'ISO 15 - 6205'." + ) + standard = _normalise_standard(head) + size = " ".join(tail.split()) + if standard not in FAMILY_STANDARDS: + raise ValueError( + f"{designation!r}: {standard!r} is not a catalogue standard. " + f"The catalogue holds {', '.join(FAMILY_STANDARDS)}." + ) + family = FAMILY_STANDARDS[standard] + if family == "deep_groove_bearing": + if not _BEARING_RE.match(size): + raise ValueError( + f"{designation!r}: an ISO 15 size is a four-digit bearing designation " + f"such as '6205', got {size!r}." + ) + return { + "designation": f"{standard} - {size}", + "standard": standard, + "family": family, + "size": size, + "thread": None, + "length": None, + } + match = _THREAD_RE.match(size) + if not match: + shape = "'M12' or 'M12x60'" if family in LENGTH_REQUIRED else "'M12'" + raise ValueError( + f"{designation!r}: a {standard} size is a metric thread such as {shape}, got {size!r}." + ) + thread = match.group(1).upper() + length = float(match.group(2)) if match.group(2) else None + if family in LENGTH_REQUIRED and length is None: + raise ValueError( + f"{designation!r}: {standard} needs a nominal length - write 'M12x60'. The " + "per-size length range is not transcribed, so any finite positive length is " + "accepted and the thread length follows the standard's own b rule." + ) + if family not in LENGTH_REQUIRED and length is not None: + raise ValueError(f"{designation!r}: {standard} takes no length, got {size!r}.") + canonical = thread if length is None else f"{thread}x{length:g}" + return { + "designation": f"{standard} - {canonical}", + "standard": standard, + "family": family, + "size": canonical, + "thread": thread, + "length": length, + } + + +def catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...]: + """Every transcribed standard part, optionally filtered. + + ``standard`` restricts to one catalogue standard (refused by name when it + is not one). ``query`` is matched, case-insensitively, as a substring of + the designation, standard, size and family, so ``'M12'``, ``'ISO 4014'``, + ``'6205'`` and ``'washer'`` all work. + """ + rows: list[dict] = [] + getters = { + "hex_bolt": hex_head, + "hex_screw": hex_head, + "hex_nut": hex_nut, + "washer": washer, + "socket_head_screw": socket_head, + } + for name, family in FAMILY_STANDARDS.items(): + if family == "deep_groove_bearing": + for row in list_bearings(): + rows.append( + { + "designation": f"{name} - {row['designation']}", + "standard": name, + "family": family, + "size": row["designation"], + "views": list(FAMILY_VIEWS[family]), + "length_required": False, + "dims": {key: row[key] for key in ("d", "D", "B")}, + "source": row["source"], + } + ) + continue + getter = getters[family] + for size in sizes(name): + dims = getter(size) + rows.append( + { + "designation": f"{name} - {size}", + "standard": name, + "family": family, + "size": size, + "views": list(FAMILY_VIEWS[family]), + "length_required": family in LENGTH_REQUIRED, + "dims": {key: value for key, value in dims.items() if key != "size"}, + "source": STANDARD_SOURCE[name], + } + ) + if standard is not None: + want = _normalise_standard(standard) + if want not in FAMILY_STANDARDS: + raise ValueError( + f"catalogue: {standard!r} is not a catalogue standard; " + f"have {', '.join(FAMILY_STANDARDS)}." + ) + rows = [row for row in rows if row["standard"] == want] + if query: + needle = " ".join(str(query).split()).lower() + rows = [ + row + for row in rows + if needle + in f"{row['designation']} {row['standard']} {row['size']} {row['family']}".lower() + ] + return tuple(rows) + + +def _numeric_key(family: str, size: str) -> float | None: + if family == "deep_groove_bearing": + return float(size) if size.isdigit() else None + match = _THREAD_RE.match(size) + if not match: + return None + try: + return float(match.group(1)[1:]) + except ValueError: # pragma: no cover - the regex already fixed the shape + return None + + +def _suggest(parsed: dict) -> str: + rows = list(catalogue(standard=parsed["standard"])) + target = _numeric_key(parsed["family"], parsed["thread"] or parsed["size"]) + if target is not None: + ranked = sorted( + rows, + key=lambda row: abs((_numeric_key(row["family"], row["size"]) or 0.0) - target), + ) + else: + names = [row["designation"] for row in rows] + close = difflib.get_close_matches(parsed["designation"], names, n=3, cutoff=0.3) + ranked = [row for row in rows if row["designation"] in close] + return ", ".join(row["designation"] for row in ranked[:3]) + + +def _resolve_dims(parsed: dict) -> dict: + family = parsed["family"] + try: + if family in ("hex_bolt", "hex_screw"): + dims = hex_head(parsed["thread"]) + elif family == "hex_nut": + dims = hex_nut(parsed["thread"]) + elif family == "washer": + dims = washer(parsed["thread"]) + elif family == "socket_head_screw": + dims = socket_head(parsed["thread"]) + else: + dims = bearing(parsed["size"]) + except ValueError as exc: + raise ValueError( + f"{parsed['designation']!r} is not in the standard-parts catalogue. {exc} " + f"Nearest entries: {_suggest(parsed)}. Nothing is drawn approximately." + ) from exc + if family in ("hex_bolt", "hex_screw", "hex_nut"): + dims["e"] = across_corners(dims["s"]) + if family in LENGTH_REQUIRED: + if family == "socket_head_screw": + dims["b_table"] = dims["b"] + dims["l"] = parsed["length"] + dims["b"] = thread_length(parsed["thread"], parsed["length"], parsed["standard"]) + return dims + + +def _resolve_view(family: str, view) -> str: + valid = FAMILY_VIEWS[family] + name = " ".join(str(view).split()).lower() + if name in valid: + return name + if name == DEFAULT_VIEW: + return valid[0] + raise ValueError(f"{family}: view must be one of {', '.join(valid)}, got {view!r}.") + + +def _block_name(standard: str, size: str, view: str) -> str: + raw = f"STD_{standard}_{size}_{view}".upper() + return re.sub(r"[^A-Z0-9]+", "_", raw).strip("_") + + +def _attdefs(parsed: dict, bbox: tuple[float, float, float, float]) -> list[dict]: + top = bbox[1] - 2.0 * TEXT_HEIGHT + rows = ( + ("DESIG", "Designation", parsed["designation"], False), + ("STD", "Standard", parsed["standard"], True), + ("SIZE", "Size", parsed["size"], True), + ("MAT", "Material or property class", "", True), + ) + return [ + { + "tag": tag, + "prompt": prompt, + "default": default, + "x": 0.0, + "y": top - index * TEXT_HEIGHT * 1.6, + "height": TEXT_HEIGHT, + "align": "center", + "invisible": invisible, + } + for index, (tag, prompt, default, invisible) in enumerate(rows) + ] + + +# ── the view builders: (entities, bbox) in block-local mm ─────────────────── + + +def _hex_fastener_side(dims: dict, *, threaded_to_head: bool): + d, s, k = dims["d"], dims["s"], dims["k"] + length, b = dims["l"], dims["b"] + r = across_corners(s) / 2.0 + minor = THREAD_MINOR_RATIO * d / 2.0 + x0 = length - b + ents = [ + _poly([(-k, -r), (0.0, -r), (0.0, r), (-k, r)]), + _line(-k, r / 2.0, 0.0, r / 2.0), + _line(-k, -r / 2.0, 0.0, -r / 2.0), + _line(0.0, d / 2.0, length, d / 2.0), + _line(0.0, -d / 2.0, length, -d / 2.0), + _line(length, -d / 2.0, length, d / 2.0), + _line(x0, minor, length, minor), + _line(x0, -minor, length, -minor), + ] + if not threaded_to_head and x0 > 1e-9: + ents.append(_line(x0, -d / 2.0, x0, d / 2.0)) + ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-k - AXIS_OVERRUN, -r, length + AXIS_OVERRUN, r) + + +def _hex_fastener_top(dims: dict): + d, s = dims["d"], dims["s"] + r = across_corners(s) / 2.0 + ents = [ + _poly(_hex_points(r)), + _circle(0.0, 0.0, d / 2.0), + _arc(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0, 90.0, 360.0), + ] + ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN) + extent = r + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _hex_nut_side(dims: dict): + d, s, m = dims["d"], dims["s"], dims["m"] + r = across_corners(s) / 2.0 + minor = THREAD_MINOR_RATIO * d / 2.0 + ents = [ + _poly([(0.0, -r), (m, -r), (m, r), (0.0, r)]), + _line(0.0, r / 2.0, m, r / 2.0), + _line(0.0, -r / 2.0, m, -r / 2.0), + _line(0.0, d / 2.0, m, d / 2.0, LAYER_HIDDEN), + _line(0.0, -d / 2.0, m, -d / 2.0, LAYER_HIDDEN), + _line(0.0, minor, m, minor, LAYER_HIDDEN), + _line(0.0, -minor, m, -minor, LAYER_HIDDEN), + _line(-AXIS_OVERRUN, 0.0, m + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-AXIS_OVERRUN, -r, m + AXIS_OVERRUN, r) + + +def _hex_nut_top(dims: dict): + d, s = dims["d"], dims["s"] + r = across_corners(s) / 2.0 + ents = [ + _poly(_hex_points(r)), + _circle(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0), + _arc(0.0, 0.0, d / 2.0, 90.0, 360.0), + ] + ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN) + extent = r + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _washer_side(dims: dict): + d1, d2, h = dims["d1"], dims["d2"], dims["h"] + ents = [ + _poly([(0.0, d1 / 2.0), (h, d1 / 2.0), (h, d2 / 2.0), (0.0, d2 / 2.0)]), + _poly([(0.0, -d1 / 2.0), (h, -d1 / 2.0), (h, -d2 / 2.0), (0.0, -d2 / 2.0)]), + _line(-AXIS_OVERRUN, 0.0, h + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-AXIS_OVERRUN, -d2 / 2.0, h + AXIS_OVERRUN, d2 / 2.0) + + +def _washer_top(dims: dict): + d1, d2 = dims["d1"], dims["d2"] + ents = [_circle(0.0, 0.0, d2 / 2.0), _circle(0.0, 0.0, d1 / 2.0)] + ents += _centre_mark(0.0, 0.0, d2 / 2.0 + AXIS_OVERRUN) + extent = d2 / 2.0 + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _socket_head_side(dims: dict): + d, dk, k = dims["d"], dims["dk"], dims["k"] + length, b = dims["l"], dims["b"] + minor = THREAD_MINOR_RATIO * d / 2.0 + x0 = length - b + ents = [ + _poly([(-k, -dk / 2.0), (0.0, -dk / 2.0), (0.0, dk / 2.0), (-k, dk / 2.0)]), + _line(0.0, d / 2.0, length, d / 2.0), + _line(0.0, -d / 2.0, length, -d / 2.0), + _line(length, -d / 2.0, length, d / 2.0), + _line(x0, minor, length, minor), + _line(x0, -minor, length, -minor), + ] + if x0 > 1e-9: + ents.append(_line(x0, -d / 2.0, x0, d / 2.0)) + ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-k - AXIS_OVERRUN, -dk / 2.0, length + AXIS_OVERRUN, dk / 2.0) + + +def _socket_head_top(dims: dict): + dk, s = dims["dk"], dims["s"] + ents = [_circle(0.0, 0.0, dk / 2.0), _poly(_hex_points(across_corners(s) / 2.0))] + ents += _centre_mark(0.0, 0.0, dk / 2.0 + AXIS_OVERRUN) + extent = dk / 2.0 + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _bearing_simplified(dims: dict): + inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0 + ents = [ + _poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]), + _poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]), + ] + arm_x = CROSS_FRACTION * half + arm_y = CROSS_FRACTION * (outer - inner) / 2.0 + middle = (inner + outer) / 2.0 + for centre in (middle, -middle): + ents.append(_line(-arm_x, centre, arm_x, centre)) + ents.append(_line(0.0, centre - arm_y, 0.0, centre + arm_y)) + ents.append(_line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer) + + +def _bearing_detailed(dims: dict): + inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0 + radius = BALL_SYMBOL_FRACTION * (outer - inner) / 2.0 + middle = (inner + outer) / 2.0 + ents = [ + _poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]), + _poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]), + _circle(0.0, middle, radius), + _circle(0.0, -middle, radius), + _line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer) + + +_BUILDERS = { + ("hex_bolt", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=False), + ("hex_bolt", "top"): _hex_fastener_top, + ("hex_screw", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=True), + ("hex_screw", "top"): _hex_fastener_top, + ("hex_nut", "side"): _hex_nut_side, + ("hex_nut", "top"): _hex_nut_top, + ("washer", "side"): _washer_side, + ("washer", "top"): _washer_top, + ("socket_head_screw", "side"): _socket_head_side, + ("socket_head_screw", "top"): _socket_head_top, + ("deep_groove_bearing", "simplified"): _bearing_simplified, + ("deep_groove_bearing", "detailed"): _bearing_detailed, +} + + +def block_spec(designation: str, view: str) -> dict: + """Typed primitives and ATTDEFs for ``block_define``, in block-local mm. + + The block's origin is the part's own datum: the head bearing face for a + bolt or cap screw, the bearing face for a nut or washer, the centre for a + bearing, with the axis along +X. Every refusal fires before any geometry + is built. + """ + parsed = parse_designation(designation) + kind = _resolve_view(parsed["family"], view) + dims = _resolve_dims(parsed) + entities, bbox = _BUILDERS[(parsed["family"], kind)](dims) + return { + "name": _block_name(parsed["standard"], parsed["size"], kind), + "entities": entities, + "attdefs": _attdefs(parsed, bbox), + "base_x": 0.0, + "base_y": 0.0, + "bbox": bbox, + "view": kind, + "designation": parsed["designation"], + "standard": parsed["standard"], + "size": parsed["size"], + "family": parsed["family"], + "dims": dims, + "source": STANDARD_SOURCE[parsed["standard"]], + } + + +async def insert_std_part( + backend: AutoCADBackend, + designation: str, + *, + at, + view: str = DEFAULT_VIEW, + rotation: float = 0.0, + layer: str = LAYER_VISIBLE, + attributes: dict | None = None, + material: str | None = None, +) -> dict: + """Define the part's block once, insert it, write its ``std_part`` payload. + + ``at`` is the WCS point the block's own datum lands on. The default + ``view="side"`` falls back to a family's first view (``simplified`` for a + bearing) and the result reports which view was drawn; any other mismatch is + refused. Refusals - an unknown designation with the nearest catalogue + entries, a bad view, a non-finite coordinate or rotation, an attribute tag + the block does not have - all fire before the first entity is written. + """ + spec = block_spec(designation, view) + try: + x, y = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"insert_std_part: 'at' must be two numbers, got {at!r}") from exc + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"insert_std_part: 'at' must be two finite numbers, got {at!r}") + angle = float(rotation) + if not math.isfinite(angle): + raise ValueError(f"insert_std_part: rotation must be finite, got {rotation!r}") + + values = {att["tag"]: att["default"] for att in spec["attdefs"]} + supplied = dict(attributes or {}) + if material is not None: + supplied["MAT"] = material + unknown = sorted(set(supplied) - set(values)) + if unknown: + raise ValueError( + f"insert_std_part: {spec['name']} has no attribute {', '.join(unknown)}; " + f"valid tags: {', '.join(values)}." + ) + values.update({str(key): str(value) for key, value in supplied.items()}) + + existing = {block.name for block in await backend.block_list()} + defined = spec["name"] not in existing + layers_created: list[str] = [] + if defined: + created = await backend.block_define( + spec["name"], + spec["entities"], + spec["attdefs"], + spec["base_x"], + spec["base_y"], + False, + True, + ) + layers_created = list(created.get("layers_created", [])) + ref = await backend.block_insert(spec["name"], x, y, 1.0, 1.0, angle, values, layer) + payload = { + "v": PAYLOAD_VERSION, + "kind": "std_part", + "designation": spec["designation"], + "standard": spec["standard"], + "size": spec["size"], + "qty": 1, + "material": values["MAT"], + } + await backend.entity_set_xdata(ref.handle, APP_ID, to_values(payload)) + return { + "ok": True, + "handle": ref.handle, + "block_name": spec["name"], + "defined": defined, + "layers_created": layers_created, + "designation": spec["designation"], + "standard": spec["standard"], + "size": spec["size"], + "family": spec["family"], + "view": spec["view"], + "layer": layer, + "at": [x, y], + "rotation": angle, + "dims": spec["dims"], + "source": spec["source"], + "payload": payload, + "backend": backend.name, + } + + +# ── standard features drawn onto geometry that already exists ─────────────── +# +# Local frame for every feature: the origin is on the axis, +X runs along the +# axis in the direction the feature extends, +Y is radially outward. No feature +# draws a centre line - the geometry it is added to already has one. + +STD_FEATURE_KINDS = ("thread", "undercut", "ring_groove", "centre_hole", "oring_groove") + +FEATURE_SOURCE = { + "thread": SOURCE_ISO_6410, + "undercut": "DIN 509 relief grooves - profile from the registered DIN 509 table", + "ring_groove": "DIN 471 / DIN 472 retaining rings - dimensions from the registered table", + "centre_hole": "DIN 332-1 centre holes forms A and B - dimensions from the registered table", + "oring_groove": "ISO 3601-2 O-ring housings - dimensions from the registered table", +} + + +def _positive(params: dict, key: str, kind: str) -> float: + if key not in params: + raise ValueError(f"{kind}: params[{key!r}] is required") + value = params[key] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{kind}: params[{key!r}] must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{kind}: params[{key!r}] must be finite and > 0, got {value!r}") + return number + + +def _side(params: dict, kind: str) -> str: + where = " ".join(str(params.get("kind", "shaft")).split()).lower() + if where not in ("shaft", "bore"): + raise ValueError( + f"{kind}: params['kind'] must be 'shaft' or 'bore', got {params.get('kind')!r}" + ) + return where + + +def _table_row(standard: str, size, kind: str, required: tuple[str, ...]) -> dict: + try: + row = dict(standards_lookup(standard, size)) + except (KeyError, ValueError) as exc: + raise ValueError(f"{kind}: {standard} - {exc}") from exc + missing = [key for key in required if key not in row] + if missing: + raise ValueError( + f"{kind}: the {standard} row for {size} carries no {', '.join(missing)}. " + "std_feature_draw draws the dimensions the standard's own table module " + "publishes and never reconstructs them." + ) + return row + + +def _groove_prims(surface: float, floor: float, width: float) -> tuple[dict, ...]: + steps = ( + (0.0, surface, 0.0, floor), + (0.0, floor, width, floor), + (width, floor, width, surface), + ) + return tuple( + _line(x1, sign * y1, x2, sign * y2) for sign in (1.0, -1.0) for x1, y1, x2, y2 in steps + ) + + +def _thread_feature(params: dict): + d = _positive(params, "d", "thread") + length = _positive(params, "length", "thread") + internal = bool(params.get("internal", False)) + size = params.get("size") + if size: + pitch = thread_pitch(size) + elif "pitch" in params: + pitch = _positive(params, "pitch", "thread") + else: + raise ValueError( + "thread: give params['size'] (e.g. 'M20', whose pitch comes from ISO 261) or " + "an explicit params['pitch'] - the pitch is never guessed." + ) + thin = d / 2.0 if internal else THREAD_MINOR_RATIO * d / 2.0 + prims = ( + _line(0.0, thin, length, thin), + _line(0.0, -thin, length, -thin), + _line(length, -d / 2.0, length, d / 2.0), + ) + dims = {"d": d, "length": length, "pitch": pitch, "internal": internal} + return prims, dims, "ISO 6410" + + +def _ring_groove_feature(params: dict): + d = _positive(params, "d", "ring_groove") + where = _side(params, "ring_groove") + standard = " ".join( + str(params.get("standard") or ("DIN 471" if where == "shaft" else "DIN 472")).split() + ) + row = _table_row(standard, d, "ring_groove", ("m", "d2")) + width, groove_d = float(row["m"]), float(row["d2"]) + if not math.isfinite(width) or width <= 0.0: + raise ValueError(f"ring_groove: {standard} row for d={d} has groove width m={width}.") + if where == "shaft" and not 0.0 < groove_d < d: + raise ValueError( + f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a " + "shaft groove (a shaft groove needs d2 < d)." + ) + if where == "bore" and not groove_d > d: + raise ValueError( + f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a " + "bore groove (a bore groove needs d2 > d)." + ) + prims = _groove_prims(d / 2.0, groove_d / 2.0, width) + return prims, {"d": d, "m": width, "d2": groove_d, "kind": where}, standard + + +def _oring_groove_feature(params: dict): + d = _positive(params, "d", "oring_groove") + cord = _positive(params, "cord", "oring_groove") + where = _side(params, "oring_groove") + standard = " ".join(str(params.get("standard") or "ISO 3601-2").split()) + row = _table_row(standard, cord, "oring_groove", ("b", "h")) + width, depth = float(row["b"]), float(row["h"]) + if not math.isfinite(width) or width <= 0.0 or not math.isfinite(depth) or depth <= 0.0: + raise ValueError( + f"oring_groove: {standard} row for cord={cord} has b={width}, h={depth}; " + "both must be finite and > 0." + ) + if where == "shaft" and depth >= d / 2.0: + raise ValueError( + f"oring_groove: {standard} groove depth h={depth} is not inside a shaft of " + f"d={d} (the floor would be at or through the axis)." + ) + surface = d / 2.0 + floor = surface - depth if where == "shaft" else surface + depth + prims = _groove_prims(surface, floor, width) + return prims, {"d": d, "cord": cord, "b": width, "h": depth, "kind": where}, standard + + +def _centre_hole_feature(params: dict): + form = " ".join(str(params.get("form", "A")).split()).upper() + if form not in ("A", "B"): + raise ValueError( + "centre_hole: this tool draws DIN 332-1 forms A and B. Form R's radius " + f"profile is not transcribed here and is refused rather than approximated; " + f"got {form!r}." + ) + if params.get("size") is None: + raise ValueError("centre_hole: params['size'] is the DIN 332 pilot diameter d1, e.g. 2.5") + size = params["size"] + standard = " ".join(str(params.get("standard") or f"DIN 332-{form}").split()) + required = ("d1", "d2", "t") if form == "A" else ("d1", "d2", "d3", "t") + row = _table_row(standard, size, "centre_hole", required) + d1, d2, depth = float(row["d1"]), float(row["d2"]), float(row["t"]) + if not 0.0 < d1 < d2: + raise ValueError( + f"centre_hole: {standard} row for {size} has d1={d1}, d2={d2}; d1 < d2 is required." + ) + # 60 degrees included -> the axial run of the countersink is (d2-d1)/2 / tan(30). + cone = math.sqrt(3.0) * (d2 - d1) / 2.0 + start = 0.0 + prims: list[dict] = [] + dims = {"form": form, "d1": d1, "d2": d2, "t": depth, "cone": cone} + if form == "B": + d3 = float(row["d3"]) + if not d3 > d2: + raise ValueError( + f"centre_hole: {standard} row for {size} has d3={d3}; form B needs d3 > d2={d2}." + ) + # 120 degrees included -> (d3-d2)/2 / tan(60). + chamfer = (d3 - d2) / (2.0 * math.sqrt(3.0)) + prims += [ + _line(0.0, d3 / 2.0, chamfer, d2 / 2.0), + _line(0.0, -d3 / 2.0, chamfer, -d2 / 2.0), + ] + start = chamfer + dims["d3"] = d3 + dims["chamfer"] = chamfer + prims += [ + _line(start, d2 / 2.0, start + cone, d1 / 2.0), + _line(start, -d2 / 2.0, start + cone, -d1 / 2.0), + ] + if depth <= start + cone: + raise ValueError( + f"centre_hole: {standard} row for {size} has t={depth} mm, which is not deeper " + f"than the countersink ({start + cone:.3f} mm); t is measured from the end face." + ) + prims += [ + _line(start + cone, d1 / 2.0, depth, d1 / 2.0), + _line(start + cone, -d1 / 2.0, depth, -d1 / 2.0), + _line(depth, -d1 / 2.0, depth, d1 / 2.0), + ] + return tuple(prims), dims, standard + + +def _undercut_feature(params: dict): + d = _positive(params, "d", "undercut") + form = " ".join(str(params.get("form", "E")).split()).upper() + if form not in ("E", "F"): + raise ValueError(f"undercut: DIN 509 defines forms E and F; got {form!r}.") + standard = " ".join(str(params.get("standard") or f"DIN 509-{form}").split()) + row = _table_row(standard, d, "undercut", ("profile",)) + profile = row["profile"] + if not isinstance(profile, (list, tuple)) or len(profile) < 2: + raise ValueError( + f"undercut: the {standard} row for d={d} has a profile with fewer than two points." + ) + points: list[tuple[float, float]] = [] + for index, point in enumerate(profile): + if not isinstance(point, (list, tuple)) or len(point) != 2: + raise ValueError( + f"undercut: {standard} profile[{index}] must be an [along, depth] pair, " + f"got {point!r}" + ) + along, depth = float(point[0]), float(point[1]) + if not (math.isfinite(along) and math.isfinite(depth)): + raise ValueError(f"undercut: {standard} profile[{index}] must be finite.") + if depth < 0.0 or depth >= d / 2.0: + raise ValueError( + f"undercut: {standard} profile[{index}] depth {depth} is not inside a shaft " + f"of d={d} (needs 0 <= depth < {d / 2.0})." + ) + points.append((along, d / 2.0 - depth)) + prims = ( + _poly(points, closed=False), + _poly([(x, -y) for x, y in points], closed=False), + ) + dims = {"d": d, "form": form, "profile": [[float(a), float(b)] for a, b in profile]} + return prims, dims, standard + + +_FEATURES = { + "thread": _thread_feature, + "undercut": _undercut_feature, + "ring_groove": _ring_groove_feature, + "centre_hole": _centre_hole_feature, + "oring_groove": _oring_groove_feature, +} + + +def feature_spec(kind: str, params: dict) -> dict: + """Primitives, resolved dimensions and provenance for one standard feature.""" + name = " ".join(str(kind).split()).lower() + if name not in STD_FEATURE_KINDS: + raise ValueError( + f"std_feature_draw: kind must be one of {', '.join(STD_FEATURE_KINDS)}, got {kind!r}." + ) + prims, dims, standard = _FEATURES[name](dict(params or {})) + return { + "kind": name, + "prims": prims, + "dims": dims, + "standard": standard, + "source": FEATURE_SOURCE[name], + } + + +def feature_prims(kind: str, params: dict) -> tuple[dict, ...]: + """Just the primitives of :func:`feature_spec`, in the feature's local frame.""" + return feature_spec(kind, params)["prims"] + + +def place_prims(prims, at, rotation: float = 0.0) -> tuple[dict, ...]: + """Map local primitives into WCS: rotate about the origin, then translate to ``at``.""" + try: + x0, y0 = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"place_prims: 'at' must be two numbers, got {at!r}") from exc + if not (math.isfinite(x0) and math.isfinite(y0)): + raise ValueError(f"place_prims: 'at' must be two finite numbers, got {at!r}") + degrees = float(rotation) + if not math.isfinite(degrees): + raise ValueError(f"place_prims: rotation must be finite, got {rotation!r}") + cosine, sine = math.cos(math.radians(degrees)), math.sin(math.radians(degrees)) + + def point(x: float, y: float) -> tuple[float, float]: + return (x0 + x * cosine - y * sine, y0 + x * sine + y * cosine) + + placed: list[dict] = [] + for prim in prims: + moved = dict(prim) + kind = prim["type"] + if kind == "line": + moved["x1"], moved["y1"] = point(prim["x1"], prim["y1"]) + moved["x2"], moved["y2"] = point(prim["x2"], prim["y2"]) + elif kind == "circle": + moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"]) + elif kind == "arc": + moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"]) + moved["start_deg"] = prim["start_deg"] + degrees + moved["end_deg"] = prim["end_deg"] + degrees + elif kind == "polyline": + moved["points"] = [list(point(px, py)) for px, py in prim["points"]] + else: # pragma: no cover - the builders emit nothing else + raise ValueError(f"place_prims: cannot place a {kind!r} primitive") + placed.append(moved) + return tuple(placed) + + +async def draw_std_feature( + backend: AutoCADBackend, + kind: str, + at, + params: dict, + *, + rotation: float = 0.0, + layer: str | None = None, +) -> dict: + """Draw a standard feature onto geometry that already exists. + + ``at`` is a WCS point on the axis; ``rotation`` turns the local +X (along + the axis, in the direction the feature extends) counter-clockwise. ``layer`` + overrides the visible primitives only - CENTER and HIDDEN primitives keep + their own. Every refusal fires before the first entity is written. + """ + spec = feature_spec(kind, params) + placed = place_prims(spec["prims"], at, rotation) + handles: list[str] = [] + layers: list[str] = [] + for prim in placed: + target = layer if (layer and prim["layer"] == LAYER_VISIBLE) else prim["layer"] + if prim["type"] == "line": + info = await backend.entity_create_line( + prim["x1"], prim["y1"], prim["x2"], prim["y2"], layer=target + ) + elif prim["type"] == "circle": + info = await backend.entity_create_circle( + prim["cx"], prim["cy"], prim["r"], layer=target + ) + elif prim["type"] == "arc": + info = await backend.entity_create_arc( + prim["cx"], prim["cy"], prim["r"], prim["start_deg"], prim["end_deg"], layer=target + ) + else: + info = await backend.entity_create_polyline(prim["points"], prim["closed"], target) + handles.append(info.handle) + if target not in layers: + layers.append(target) + return { + "ok": True, + "kind": spec["kind"], + "handles": handles, + "primitive_count": len(handles), + "dims": spec["dims"], + "standard": spec["standard"], + "source": spec["source"], + "layers": layers, + "at": [float(at[0]), float(at[1])], + "rotation": float(rotation), + "backend": backend.name, + } diff --git a/server.py b/server.py index 1635d7e..db13456 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,14 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "system_prompt_message", } ), + # SECTION 22 - the standard-parts catalogue (tracks B+G). + "mech": frozenset( + { + "std_part_list", + "std_part_insert", + "std_feature_draw", + } + ), } _active_tool_profile: dict | None = None @@ -8123,6 +8133,197 @@ async def drawing_properties_set( return await _backend(ctx).drawing_properties_set(summary, custom) +# --------------------------------------------------------------------------- +# ── SECTION 22: Standard Parts (3 tools) ──────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.", + cost="read", +) +@mcp.tool( + annotations={"title": "Standard Parts: Catalogue", "readOnlyHint": True}, + tags={"mech", "query"}, +) +async def std_part_list( + query: Annotated[ + str | None, + Field( + default=None, + description=( + "Free text matched against designation, standard, size and family - " + "'M12', 'ISO 4014', '6205', 'washer'" + ), + ), + ] = None, + standard: Annotated[ + str | None, + Field( + default=None, + description="One of ISO 4014, ISO 4017, ISO 4032, ISO 7089, ISO 4762, ISO 15", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Catalogue rows with the dimensions a drawing needs, and their provenance. + + Every row is transcribed from the named standard's own table and carries + its `source`. Coverage is deliberately narrow: ISO 261 first-choice sizes + only (M5-M36, M3-M24 for ISO 4762) and ISO 15 bore numbers 00-10 in the + 600x / 620x / 630x series. A size outside a table is refused by name by + `std_part_insert` with the nearest entries, never interpolated. An unknown + `standard` is refused with the list of catalogue standards. + """ + from engineering.mech.stdparts import FAMILY_STANDARDS, catalogue + + rows = catalogue(query, standard) + return {"count": len(rows), "standards": list(FAMILY_STANDARDS), "parts": list(rows)} + + +@cad_tool( + summary=( + "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing " + "- as a tagged block." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "Standard Parts: Insert", "readOnlyHint": False}, + tags={"mech", "create"}, +) +async def std_part_insert( + designation: Annotated[ + str, + Field( + description=( + "'ISO 4014 - M12x60', 'ISO 4032 - M12', 'ISO 7089 - M12', " + "'ISO 4762 - M8x30', 'ISO 15 - 6205' (a bare '6205' also works)" + ) + ), + ], + x: Annotated[float, Field(description="Insertion X (WCS)")], + y: Annotated[float, Field(description="Insertion Y (WCS)")], + view: Annotated[ + str, + Field( + default="side", + description=( + "Fasteners: side | top. Bearings: simplified (ISO 8826-1) | detailed " + "(ISO 8826-2). The default 'side' falls back to the family's first view." + ), + ), + ] = "side", + rotation: Annotated[ + float, + Field(default=0.0, description="Degrees CCW; 0 puts the part's axis along +X"), + ] = 0.0, + layer: Annotated[ + str, + Field( + default="GEOMETRY", + description=( + "Layer for the INSERT; the block's own primitives keep GEOMETRY / CENTER / HIDDEN" + ), + ), + ] = "GEOMETRY", + material: Annotated[ + str | None, + Field( + default=None, + description="Property class or material for the MAT attribute and the XDATA, e.g. '8.8'", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Define the part's block once, insert it, and write its `std_part` XDATA. + + The block carries DESIG / STD / SIZE / MAT attributes and an ACADMCP_MECH + payload, so a parts list is a read of the drawing rather than a naming + convention. Refusals, all before any write: a designation outside the + transcribed tables (named, with the nearest catalogue entries - nothing is + drawn approximately), a malformed designation, a missing or stray nominal + length, a view the family does not have, an attribute tag the block does + not carry, and a non-finite coordinate or rotation. + """ + from engineering.mech.stdparts import insert_std_part + + await ctx.info(f"standard part {designation} ({view}) at ({x}, {y})") + return await insert_std_part( + _backend(ctx), + designation, + at=(x, y), + view=view, + rotation=rotation, + layer=layer, + material=material, + ) + + +@cad_tool( + summary=( + "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole " + "- onto existing geometry." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "Standard Parts: Draw Feature", "readOnlyHint": False}, + tags={"mech", "create"}, +) +async def std_feature_draw( + kind: Annotated[ + str, + Field(description="thread | undercut | ring_groove | centre_hole | oring_groove"), + ], + x: Annotated[float, Field(description="Feature origin X (WCS): a point on the axis")], + y: Annotated[float, Field(description="Feature origin Y (WCS): a point on the axis")], + params: Annotated[ + dict, + Field( + description=( + "thread: {d, length, size|pitch, internal}; undercut: {d, form: E|F}; " + "ring_groove: {d, kind: shaft|bore}; centre_hole: {size, form: A|B}; " + "oring_groove: {d, cord, kind: shaft|bore}" + ) + ), + ], + rotation: Annotated[ + float, + Field( + default=0.0, + description="Degrees CCW applied to the local +X (along the axis, into the feature)", + ), + ] = 0.0, + layer: Annotated[ + str | None, + Field( + default=None, + description="Override the visible primitives' layer; CENTER and HIDDEN keep theirs", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Add a standard feature to geometry that already exists - a foreign drawing being finished. + + Local frame: `(x, y)` is on the axis, +X runs along the axis in the + direction the feature extends and +Y is radially outward. The thread is the + ISO 6410 representation (minor diameter at 0.8 x major, thread-length limit + to the major) and its pitch comes from ISO 261 via `size` or from an + explicit `pitch` - it is never guessed. Every other kind takes its + dimensions from the registered standards table and is refused by name when + that table is absent, when the size is outside its coverage, or when the row + lacks a dimension the feature needs: no value is interpolated and no profile + is reconstructed. DIN 332 form R is refused - only forms A and B are drawn. + """ + from engineering.mech.stdparts import draw_std_feature + + await ctx.info(f"standard feature {kind} at ({x}, {y})") + return await draw_std_feature( + _backend(ctx), kind, (x, y), params, rotation=rotation, layer=layer + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_mech_std_features.py b/tests/test_mech_std_features.py new file mode 100644 index 0000000..a156aea --- /dev/null +++ b/tests/test_mech_std_features.py @@ -0,0 +1,232 @@ +"""std_feature_draw: the ISO 6410 thread, and grooves drawn from registry rows. + +The DIN 471 / DIN 509 / DIN 332 / ISO 3601-2 tables themselves land with Task 3 +(group M). These tests register **synthetic** tables under obviously fake +standard names, so what is pinned here is that the feature draws exactly the +dimensions the registry publishes - never a number this module invented. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.standards import Coverage, register, standards +from engineering.mech.stdparts import ( + STD_FEATURE_KINDS, + draw_std_feature, + feature_prims, + feature_spec, + place_prims, +) + + +@pytest.fixture(scope="module", autouse=True) +def synthetic_tables(): + """Round, obviously synthetic rows - NOT DIN or ISO values.""" + if "TEST 471" in standards(): + return + register( + "TEST 471", + {30.0: {"m": 2.0, "d2": 27.0}}, + Coverage(8.0, 100.0), + "synthetic test fixture - the real DIN 471 table lands with Task 3", + ) + register( + "TEST 472", + {30.0: {"m": 2.0, "d2": 33.0}}, + Coverage(8.0, 100.0), + "synthetic test fixture - the real DIN 472 table lands with Task 3", + ) + register( + "TEST 509", + {30.0: {"profile": [[0.0, 0.0], [0.0, 0.4], [2.0, 0.4], [2.5, 0.0]]}}, + Coverage(18.0, 80.0), + "synthetic test fixture - the real DIN 509 table lands with Task 3", + ) + register( + "TEST 332 A", + {2.5: {"d1": 2.5, "d2": 5.3, "t": 5.0}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real DIN 332-1 table lands with Task 3", + ) + register( + "TEST 332 B", + {2.5: {"d1": 2.5, "d2": 5.3, "d3": 6.3, "t": 6.0}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real DIN 332-1 table lands with Task 3", + ) + register( + "TEST 3601", + {3.53: {"b": 4.7, "h": 2.7}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real ISO 3601-2 table lands with Task 3", + ) + + +def test_feature_kinds_are_the_five_the_spec_names(): + assert STD_FEATURE_KINDS == ( + "thread", + "undercut", + "ring_groove", + "centre_hole", + "oring_groove", + ) + with pytest.raises(ValueError, match="oring_groove"): + feature_prims("spline", {}) + + +def test_external_thread_is_the_iso6410_representation(): + spec = feature_spec("thread", {"d": 20.0, "length": 30.0, "size": "M20"}) + assert spec["dims"]["pitch"] == 2.5 # ISO 261 coarse, from the fastener table + assert "ISO 6410" in spec["source"] + assert spec["prims"] == ( + {"type": "line", "x1": 0.0, "y1": 8.0, "x2": 30.0, "y2": 8.0, "layer": "GEOMETRY"}, + {"type": "line", "x1": 0.0, "y1": -8.0, "x2": 30.0, "y2": -8.0, "layer": "GEOMETRY"}, + {"type": "line", "x1": 30.0, "y1": -10.0, "x2": 30.0, "y2": 10.0, "layer": "GEOMETRY"}, + ) + + +def test_internal_thread_draws_the_major_diameter_instead(): + prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5, "internal": True}) + assert prims[0]["y1"] == 10.0 and prims[1]["y1"] == -10.0 + + +def test_a_thread_without_a_size_or_a_pitch_is_refused(): + with pytest.raises(ValueError, match="never guessed"): + feature_prims("thread", {"d": 20.0, "length": 30.0}) + with pytest.raises(ValueError, match="ISO 261"): + feature_prims("thread", {"d": 14.0, "length": 30.0, "size": "M14"}) + + +def test_ring_groove_is_drawn_from_the_registry_row(): + prims = feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 471"}) + assert len(prims) == 6 + assert prims[0] == { + "type": "line", + "x1": 0.0, + "y1": 15.0, + "x2": 0.0, + "y2": 13.5, + "layer": "GEOMETRY", + } + assert prims[1] == { + "type": "line", + "x1": 0.0, + "y1": 13.5, + "x2": 2.0, + "y2": 13.5, + "layer": "GEOMETRY", + } + assert prims[3]["y1"] == -15.0 + bore = feature_prims("ring_groove", {"d": 30.0, "kind": "bore", "standard": "TEST 472"}) + assert bore[1]["y1"] == 16.5 + + +def test_ring_groove_refuses_a_row_that_is_the_wrong_way_round(): + with pytest.raises(ValueError, match="not a shaft groove"): + feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 472"}) + with pytest.raises(ValueError, match="DIN 471"): + feature_prims("ring_groove", {"d": 30.0, "kind": "shaft"}) + + +def test_oring_groove_is_a_rectangular_groove_from_the_row(): + prims = feature_prims( + "oring_groove", {"d": 30.0, "cord": 3.53, "kind": "shaft", "standard": "TEST 3601"} + ) + assert len(prims) == 6 + assert prims[0]["y1"] == 15.0 and prims[0]["y2"] == pytest.approx(12.3) + assert prims[1]["x2"] == pytest.approx(4.7) + + +def test_centre_hole_form_a_is_a_sixty_degree_countersink(): + spec = feature_spec("centre_hole", {"size": 2.5, "form": "A", "standard": "TEST 332 A"}) + cone = math.sqrt(3.0) * (5.3 - 2.5) / 2.0 + assert spec["dims"]["cone"] == pytest.approx(cone) + prims = spec["prims"] + assert len(prims) == 5 + assert prims[0] == { + "type": "line", + "x1": 0.0, + "y1": 2.65, + "x2": pytest.approx(cone), + "y2": 1.25, + "layer": "GEOMETRY", + } + assert prims[2]["x2"] == 5.0 and prims[2]["y1"] == 1.25 + assert prims[4] == { + "type": "line", + "x1": 5.0, + "y1": -1.25, + "x2": 5.0, + "y2": 1.25, + "layer": "GEOMETRY", + } + + +def test_centre_hole_form_b_adds_the_protecting_chamfer(): + prims = feature_prims("centre_hole", {"size": 2.5, "form": "B", "standard": "TEST 332 B"}) + chamfer = (6.3 - 5.3) / (2.0 * math.sqrt(3.0)) + assert len(prims) == 7 + assert prims[0]["y1"] == 3.15 # d3 / 2 + assert prims[0]["x2"] == pytest.approx(chamfer) + + +def test_centre_hole_form_r_is_refused_not_approximated(): + with pytest.raises(ValueError, match="forms A and B"): + feature_prims("centre_hole", {"size": 2.5, "form": "R", "standard": "TEST 332 A"}) + + +def test_undercut_draws_the_profile_the_table_publishes(): + prims = feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 509"}) + assert len(prims) == 2 + assert prims[0] == { + "type": "polyline", + "points": [[0.0, 15.0], [0.0, 14.6], [2.0, 14.6], [2.5, 15.0]], + "closed": False, + "layer": "GEOMETRY", + } + assert prims[1]["points"][1] == [0.0, -14.6] + + +def test_undercut_refuses_a_row_without_a_profile(): + with pytest.raises(ValueError, match="carries no profile"): + feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 471"}) + with pytest.raises(ValueError, match="DIN 509"): + feature_prims("undercut", {"d": 30.0, "form": "E"}) + + +def test_place_prims_rotates_about_the_insertion_point(): + prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5}) + placed = place_prims(prims, (100.0, 50.0), 90.0) + assert placed[0]["x1"] == pytest.approx(92.0) + assert placed[0]["y1"] == pytest.approx(50.0) + assert placed[0]["x2"] == pytest.approx(92.0) + assert placed[0]["y2"] == pytest.approx(80.0) + with pytest.raises(ValueError, match="finite"): + place_prims(prims, (float("inf"), 0.0)) + + +@pytest.mark.asyncio +async def test_draw_std_feature_writes_the_primitives(backend): + result = await draw_std_feature( + backend, "thread", (100.0, 50.0), {"d": 20.0, "length": 30.0, "size": "M20"} + ) + assert result["ok"] is True + assert result["primitive_count"] == 3 + assert len(result["handles"]) == 3 + assert result["layers"] == ["GEOMETRY"] + assert result["dims"]["pitch"] == 2.5 + info = await backend.entity_get(result["handles"][0]) + assert info.layer == "GEOMETRY" + + +@pytest.mark.asyncio +async def test_draw_std_feature_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="never guessed"): + await draw_std_feature(backend, "thread", (0.0, 0.0), {"d": 20.0, "length": 30.0}) + with pytest.raises(ValueError, match="DIN 509"): + await draw_std_feature(backend, "undercut", (0.0, 0.0), {"d": 30.0}) + assert len(await backend.entity_list()) == before diff --git a/tests/test_mech_stdparts.py b/tests/test_mech_stdparts.py new file mode 100644 index 0000000..ba41da0 --- /dev/null +++ b/tests/test_mech_stdparts.py @@ -0,0 +1,333 @@ +"""The standard-parts catalogue: search, designations, block specs, insertion. + +Geometry is asserted as emitted primitive dicts - counts, layers, coordinates - +never as a screenshot. +""" + +from __future__ import annotations + +import math + +import pytest + +from backends.block_specs import validate_attdef_specs, validate_entity_specs +from engineering.mech.stdparts import ( + FAMILY_STANDARDS, + FAMILY_VIEWS, + THREAD_MINOR_RATIO, + block_spec, + catalogue, + parse_designation, +) + +R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head +MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread + + +def test_the_iso6410_minor_radius_is_four_point_eight(): + """Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary.""" + assert MINOR12 == pytest.approx(4.8) + + +def test_the_catalogue_holds_every_transcribed_size(): + rows = catalogue() + assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings + assert len(catalogue(standard="ISO 15")) == 33 + assert len(catalogue("M12")) == 5 + assert len(catalogue("6205")) == 1 + assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS) + + +def test_a_catalogue_row_carries_what_a_drawing_needs(): + (row,) = catalogue("6205") + assert row["designation"] == "ISO 15 - 6205" + assert row["standard"] == "ISO 15" + assert row["family"] == "deep_groove_bearing" + assert row["views"] == ["simplified", "detailed"] + assert row["length_required"] is False + assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0} + assert "ISO 15:2017" in row["source"] + + bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12") + assert bolt["length_required"] is True + assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5 + + +def test_catalogue_refuses_an_unknown_standard(): + with pytest.raises(ValueError, match="ISO 4014"): + catalogue(standard="DIN 931") + + +def test_parse_designation_normalises_and_splits(): + assert parse_designation("iso4014 - m12x60") == { + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "family": "hex_bolt", + "size": "M12x60", + "thread": "M12", + "length": 60.0, + } + assert parse_designation("6205")["designation"] == "ISO 15 - 6205" + assert parse_designation("ISO 4032 - M12")["length"] is None + + +def test_parse_designation_refuses_a_missing_or_stray_length(): + with pytest.raises(ValueError, match="nominal length"): + parse_designation("ISO 4014 - M12") + with pytest.raises(ValueError, match="takes no length"): + parse_designation("ISO 4032 - M12x60") + with pytest.raises(ValueError, match="catalogue standard"): + parse_designation("ISO 9999 - M12") + + +def test_an_unknown_size_is_refused_with_the_nearest_entries(): + with pytest.raises(ValueError, match=r"ISO 4014 - M12"): + block_spec("ISO 4014 - M13x60", "side") + with pytest.raises(ValueError, match=r"ISO 15 - 6210"): + block_spec("ISO 15 - 6211", "simplified") + with pytest.raises(ValueError, match="approximately"): + block_spec("ISO 4014 - M13x60", "side") + + +def test_hex_bolt_side_view_is_the_iso6410_representation(): + spec = block_spec("ISO 4014 - M12x60", "side") + assert spec["name"] == "STD_ISO_4014_M12X60_SIDE" + assert spec["view"] == "side" + assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125 + ents = spec["entities"] + assert len(ents) == 10 + assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True + assert ents[0]["points"] == [ + [-7.5, -R12], + [0.0, -R12], + [0.0, R12], + [-7.5, R12], + ] + assert ents[3] == { + "type": "line", + "x1": 0.0, + "y1": 6.0, + "x2": 60.0, + "y2": 6.0, + "layer": "GEOMETRY", + } + # ISO 6410: minor diameter at 0.8 x major, over the useful thread length. + assert ents[6] == { + "type": "line", + "x1": 30.0, + "y1": MINOR12, + "x2": 60.0, + "y2": MINOR12, + "layer": "GEOMETRY", + } + # ISO 6410: the thread-length limit is a line to the major diameter. + assert ents[8] == { + "type": "line", + "x1": 30.0, + "y1": -6.0, + "x2": 30.0, + "y2": 6.0, + "layer": "GEOMETRY", + } + assert ents[9]["layer"] == "CENTER" + assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12)) + + +def test_a_fully_threaded_screw_has_no_thread_limit_line(): + spec = block_spec("ISO 4017 - M12x60", "side") + assert len(spec["entities"]) == 9 + assert spec["dims"]["b"] == pytest.approx(60.0) + minor = [e for e in spec["entities"] if e.get("y1") == MINOR12] + assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0 + + +def test_hex_top_view_uses_the_three_quarter_minor_arc(): + spec = block_spec("ISO 4014 - M12x60", "top") + ents = spec["entities"] + assert len(ents) == 5 + assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6 + assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"} + assert ents[2] == { + "type": "arc", + "cx": 0.0, + "cy": 0.0, + "r": MINOR12, + "start_deg": 90.0, + "end_deg": 360.0, + "layer": "GEOMETRY", + } + assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER" + + +def test_hex_nut_side_view_shows_the_thread_as_hidden_lines(): + spec = block_spec("ISO 4032 - M12", "side") + ents = spec["entities"] + assert len(ents) == 8 + hidden = [e for e in ents if e["layer"] == "HIDDEN"] + assert len(hidden) == 4 + assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12} + assert ents[0]["points"][1] == [10.8, -R12] # nut height m + + +def test_washer_views(): + side = block_spec("ISO 7089 - M12", "side") + assert len(side["entities"]) == 3 + assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]] + top = block_spec("ISO 7089 - M12", "top") + assert top["entities"][0] == { + "type": "circle", + "cx": 0.0, + "cy": 0.0, + "r": 12.0, + "layer": "GEOMETRY", + } + assert top["entities"][1]["r"] == 6.5 + + +def test_socket_head_screw_views(): + side = block_spec("ISO 4762 - M8x30", "side") + assert len(side["entities"]) == 8 + assert side["dims"]["b"] == pytest.approx(28.0) + assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5 + top = block_spec("ISO 4762 - M8x30", "top") + assert top["entities"][0]["r"] == 6.5 + assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6 + + +def test_bearing_simplified_is_the_iso8826_1_outline_and_cross(): + spec = block_spec("ISO 15 - 6205", "simplified") + ents = spec["entities"] + assert len(ents) == 7 + assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]] + assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]] + assert ents[2] == { + "type": "line", + "x1": -4.5, + "y1": 19.25, + "x2": 4.5, + "y2": 19.25, + "layer": "GEOMETRY", + } + assert ents[3] == { + "type": "line", + "x1": 0.0, + "y1": 15.2, + "x2": 0.0, + "y2": 23.3, + "layer": "GEOMETRY", + } + assert ents[6]["layer"] == "CENTER" + + +def test_bearing_detailed_draws_the_element_symbol(): + spec = block_spec("ISO 15 - 6205", "detailed") + ents = spec["entities"] + assert len(ents) == 5 + assert ents[2] == { + "type": "circle", + "cx": 0.0, + "cy": 19.25, + "r": 4.05, + "layer": "GEOMETRY", + } + assert ents[3]["cy"] == -19.25 + + +def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back(): + assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified" + with pytest.raises(ValueError, match="simplified"): + block_spec("ISO 15 - 6205", "top") + with pytest.raises(ValueError, match="side"): + block_spec("ISO 4014 - M12x60", "simplified") + + +@pytest.mark.parametrize( + ("designation", "view"), + [ + ("ISO 4014 - M12x60", "side"), + ("ISO 4014 - M12x60", "top"), + ("ISO 4017 - M12x60", "side"), + ("ISO 4032 - M12", "side"), + ("ISO 4032 - M12", "top"), + ("ISO 7089 - M12", "side"), + ("ISO 7089 - M12", "top"), + ("ISO 4762 - M8x30", "side"), + ("ISO 4762 - M8x30", "top"), + ("ISO 15 - 6205", "simplified"), + ("ISO 15 - 6205", "detailed"), + ], +) +def test_every_block_spec_validates_against_block_define(designation, view): + spec = block_spec(designation, view) + validate_entity_specs(spec["entities"]) + validate_attdef_specs(spec["attdefs"]) + assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"] + assert spec["attdefs"][0]["default"] == spec["designation"] + assert spec["attdefs"][0]["invisible"] is False + assert all(att["invisible"] for att in spec["attdefs"][1:]) + assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3] + assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]} + + +@pytest.mark.asyncio +async def test_insert_defines_the_block_once_and_writes_the_payload(backend): + from engineering.mech.stdparts import insert_std_part + from engineering.mech.xdata import APP_ID, decode + + result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8") + assert result["ok"] is True + assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE" + assert result["defined"] is True + assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"] + assert result["view"] == "side" + assert result["layer"] == "GEOMETRY" + assert result["dims"]["s"] == 18.0 + + stored = await backend.entity_get_xdata(result["handle"], APP_ID) + payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]]) + assert payload == { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + assert await backend.block_get_attributes(result["handle"]) == { + "DESIG": "ISO 4014 - M12x60", + "STD": "ISO 4014", + "SIZE": "M12x60", + "MAT": "8.8", + } + + again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0)) + assert again["defined"] is False and again["layers_created"] == [] + assert again["payload"]["material"] == "" + + +@pytest.mark.asyncio +async def test_bearing_inserts_with_its_own_view_name(backend): + from engineering.mech.stdparts import insert_std_part + + result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0) + assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED" + assert result["view"] == "simplified" + assert result["rotation"] == 90.0 + assert result["dims"]["B"] == 15.0 + + +@pytest.mark.asyncio +async def test_insert_refuses_before_writing_anything(backend): + from engineering.mech.stdparts import insert_std_part + + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="Nearest entries"): + await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0)) + with pytest.raises(ValueError, match="finite"): + await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0)) + with pytest.raises(ValueError, match="no attribute"): + await insert_std_part( + backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"} + ) + assert len(await backend.entity_list()) == before diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py index fc370c2..98f01f4 100644 --- a/tests/test_tool_packs.py +++ b/tests/test_tool_packs.py @@ -5,6 +5,8 @@ v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools. Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment surface and nothing else. + +v1.6 tracks B+G add the `mech` pack: SECTION 22's three standard-parts tools. """ from __future__ import annotations @@ -78,6 +80,12 @@ SETTINGS_TOOLS = { "system_prompt_message", } +MECH_TOOLS = { + "std_part_list", + "std_part_insert", + "std_feature_draw", +} + LEAN_SETTINGS_ESSENTIALS = { "drawing_apply_standard", "page_setup_apply", @@ -86,7 +94,10 @@ LEAN_SETTINGS_ESSENTIALS = { "textstyle_set_current", } -ALL_PACKS = ["core", "pid", "settings"] +#: Declaration order - what `tool_packs["available"]` reports. +ALL_PACKS = ["core", "pid", "settings", "mech"] +#: `tool_packs["enabled"]` is sorted, which is no longer the same list. +ALL_PACKS_ENABLED = sorted(ALL_PACKS) @pytest_asyncio.fixture(autouse=True) @@ -97,11 +108,15 @@ 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["settings"] == frozenset(SETTINGS_TOOLS) + assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS) assert len(SETTINGS_TOOLS) == 22 + assert len(MECH_TOOLS) == 3 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"]) async def test_every_pack_member_is_a_registered_tool(): @@ -117,6 +132,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup( disabled = set(info["disabled_tools"]) assert PID_TOOLS <= disabled assert SETTINGS_TOOLS <= disabled + assert MECH_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 @@ -136,8 +152,8 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch): monkeypatch.setattr(config.settings, "tool_packs", "all") 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 not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS) & disabled) + assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( @@ -184,7 +200,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..e4e7894 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -529,6 +529,13 @@ async def test_tool_group_sizes_are_unchanged(): 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. + + `mech` appeared (0 -> 3) when v1.6's SECTION 22 opened with the + standard-parts catalogue (`std_part_list` / `std_part_insert` / + `std_feature_draw`); the `mech` tag is ranked directly after `pid` in + `_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under + their secondary `query` / `create` tags. This snapshot is branch-local: + the merge task recomputes it once all four track B+G branches have landed. """ 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": 3, "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()) == 207