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