diff --git a/README.md b/README.md
index a632df5..0173421 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:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
-> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
+> **v1.5 release snapshot:** 213 tools · 8 resources · 5 prompt templates · 3284 collected tests.
+> 213 is the **registered** count; a default install advertises 208 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 |
-210 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+213 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 5f0df86..370606c 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -1949,6 +1949,61 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"parca bilgisi",
),
),
+ # ── 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 55bb8ab..9c2c0d8 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,7 +3,7 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 210,
+ "tools": 213,
"resources": 8,
"prompts": 5,
"groups": 24
@@ -22,7 +22,7 @@
"environment": 22,
"layers": 14,
"layouts": 12,
- "mech": 6,
+ "mech": 9,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -2418,6 +2418,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/standards/bearings.py b/engineering/mech/standards/bearings.py
new file mode 100644
index 0000000..b0031fe
--- /dev/null
+++ b/engineering/mech/standards/bearings.py
@@ -0,0 +1,136 @@
+"""ISO 15 deep groove ball bearings, plus the ISO 8826 representation rules.
+
+SOURCE
+------
+* ``DEEP_GROOVE`` - ISO 15:2017 Table 4: boundary dimensions ``d`` (bore),
+ ``D`` (outside diameter) and ``B`` (width) for the radial bearings of
+ dimension series 10 (600x), 02 (620x) and 03 (630x).
+* ``SOURCE_ISO_8826_1`` - ISO 8826-1:1989, general simplified representation:
+ the bearing is drawn as its envelope outline with an upright cross inside
+ that does not touch the outline.
+* ``SOURCE_ISO_8826_2`` - ISO 8826-2:1994, particular simplified
+ representation: the envelope outline plus a symbol for the rolling element.
+
+COVERAGE
+--------
+600x, 620x and 630x, bore numbers 00..10 (d = 10..50 mm) - 33 rows. Bore
+numbers 00/01/02/03 are 10/12/15/17 mm by the ISO 15 rule; 04 and up are
+``5 x bore number``. Deliberately **not** transcribed, and refused by name:
+bore numbers above 10, the wide series 622x/623x, the light series 618x/619x
+and 16xx, the inch series, and every suffixed variant (``6205-2RS``,
+``6205-Z``) - a suffix changes width and sealing, not a number this module may
+guess.
+
+The chamfer dimensions ``r_s`` are not transcribed either: neither simplified
+representation needs them, so no row carries a value nobody checked.
+
+CROSS_FRACTION and BALL_SYMBOL_FRACTION are **declared drawing conventions**,
+not transcribed dimensions. ISO 8826-1 fixes the *form* of the cross and
+requires only that it not touch the outline; ISO 15 carries no ball diameter
+at all, so the element symbol is sized as a fraction of the ring gap rather
+than invented as a real ball.
+"""
+
+from __future__ import annotations
+
+from . import Coverage, register
+
+SOURCE_ISO_15 = (
+ "ISO 15:2017 Table 4 - rolling bearings, radial bearings, boundary dimensions "
+ "(dimension series 10, 02, 03)"
+)
+SOURCE_ISO_8826_1 = (
+ "ISO 8826-1:1989 - technical drawings, rolling bearings, general simplified representation"
+)
+SOURCE_ISO_8826_2 = (
+ "ISO 8826-2:1994 - technical drawings, rolling bearings, particular simplified representation"
+)
+
+#: Fraction of the half-width / half-gap used by the ISO 8826-1 upright cross.
+#: A declared convention (see the module docstring), never a table value.
+CROSS_FRACTION = 0.6
+
+#: Fraction of the half-gap used by the ISO 8826-2 rolling-element symbol.
+#: A declared convention, never a ball diameter.
+BALL_SYMBOL_FRACTION = 0.6
+
+BEARING_SERIES = ("600x", "620x", "630x")
+_SERIES_PREFIX = {"60": "600x", "62": "620x", "63": "630x"}
+
+#: ISO 15:2017 Table 4 - d, D, B in millimetres.
+DEEP_GROOVE: dict[str, dict[str, float]] = {
+ # dimension series 10 (600x)
+ "6000": {"d": 10.0, "D": 26.0, "B": 8.0},
+ "6001": {"d": 12.0, "D": 28.0, "B": 8.0},
+ "6002": {"d": 15.0, "D": 32.0, "B": 9.0},
+ "6003": {"d": 17.0, "D": 35.0, "B": 10.0},
+ "6004": {"d": 20.0, "D": 42.0, "B": 12.0},
+ "6005": {"d": 25.0, "D": 47.0, "B": 12.0},
+ "6006": {"d": 30.0, "D": 55.0, "B": 13.0},
+ "6007": {"d": 35.0, "D": 62.0, "B": 14.0},
+ "6008": {"d": 40.0, "D": 68.0, "B": 15.0},
+ "6009": {"d": 45.0, "D": 75.0, "B": 16.0},
+ "6010": {"d": 50.0, "D": 80.0, "B": 16.0},
+ # dimension series 02 (620x)
+ "6200": {"d": 10.0, "D": 30.0, "B": 9.0},
+ "6201": {"d": 12.0, "D": 32.0, "B": 10.0},
+ "6202": {"d": 15.0, "D": 35.0, "B": 11.0},
+ "6203": {"d": 17.0, "D": 40.0, "B": 12.0},
+ "6204": {"d": 20.0, "D": 47.0, "B": 14.0},
+ "6205": {"d": 25.0, "D": 52.0, "B": 15.0},
+ "6206": {"d": 30.0, "D": 62.0, "B": 16.0},
+ "6207": {"d": 35.0, "D": 72.0, "B": 17.0},
+ "6208": {"d": 40.0, "D": 80.0, "B": 18.0},
+ "6209": {"d": 45.0, "D": 85.0, "B": 19.0},
+ "6210": {"d": 50.0, "D": 90.0, "B": 20.0},
+ # dimension series 03 (630x)
+ "6300": {"d": 10.0, "D": 35.0, "B": 11.0},
+ "6301": {"d": 12.0, "D": 37.0, "B": 12.0},
+ "6302": {"d": 15.0, "D": 42.0, "B": 13.0},
+ "6303": {"d": 17.0, "D": 47.0, "B": 14.0},
+ "6304": {"d": 20.0, "D": 52.0, "B": 15.0},
+ "6305": {"d": 25.0, "D": 62.0, "B": 17.0},
+ "6306": {"d": 30.0, "D": 72.0, "B": 19.0},
+ "6307": {"d": 35.0, "D": 80.0, "B": 21.0},
+ "6308": {"d": 40.0, "D": 90.0, "B": 23.0},
+ "6309": {"d": 45.0, "D": 100.0, "B": 25.0},
+ "6310": {"d": 50.0, "D": 110.0, "B": 27.0},
+}
+
+
+def bearing(designation) -> dict:
+ """Boundary dimensions of one deep groove ball bearing.
+
+ Returns ``{"designation", "series", "source", "d", "D", "B"}``. A
+ designation outside the transcribed table is refused by name; nothing is
+ interpolated between series or bore numbers.
+ """
+ key = " ".join(str(designation).split()).upper()
+ if key not in DEEP_GROOVE:
+ raise ValueError(
+ "ISO 15 deep groove ball bearings are transcribed here for the 600x, 620x "
+ f"and 630x series, bore numbers 00-10 (d = 10-50 mm); {key!r} is outside "
+ f"that table and is never interpolated ({SOURCE_ISO_15})."
+ )
+ return {
+ "designation": key,
+ "series": _SERIES_PREFIX[key[:2]],
+ "source": SOURCE_ISO_15,
+ **DEEP_GROOVE[key],
+ }
+
+
+def list_bearings(series: str | None = None) -> tuple[dict, ...]:
+ """Every transcribed bearing, optionally restricted to one series."""
+ rows = tuple(bearing(name) for name in DEEP_GROOVE)
+ if series is None:
+ return rows
+ name = " ".join(str(series).split()).lower()
+ if name not in BEARING_SERIES:
+ raise ValueError(
+ f"list_bearings: series must be one of {', '.join(BEARING_SERIES)}, got {series!r}."
+ )
+ return tuple(row for row in rows if row["series"] == name)
+
+
+register("ISO 15", DEEP_GROOVE, Coverage(10.0, 50.0), SOURCE_ISO_15)
diff --git a/engineering/mech/standards/fasteners.py b/engineering/mech/standards/fasteners.py
new file mode 100644
index 0000000..27f1451
--- /dev/null
+++ b/engineering/mech/standards/fasteners.py
@@ -0,0 +1,269 @@
+"""ISO metric fastener tables - transcribed from the standards, never derived.
+
+SOURCE
+------
+* ``HEX_HEAD`` ISO 4014:2011 Table 1 (hexagon head bolts, product grades A
+ and B) and ISO 4017:2014 Table 1 (hexagon head screws, fully threaded). The
+ two standards carry the same width across flats ``s`` and head height ``k``
+ for every size shipped here, so one table serves both and the standard is a
+ property of the *part*, not a second copy of the table.
+* ``HEX_NUT`` ISO 4032:2012 Table 1 (hexagon regular nuts, style 1):
+ width across flats ``s``, nut height ``m`` (max).
+* ``WASHER`` ISO 7089:2000 Table 1 (plain washers, normal series, product
+ grade A): inside diameter ``d1``, outside diameter ``d2``, thickness ``h``.
+* ``SOCKET_HEAD`` ISO 4762:2004 Table 1 (hexagon socket head cap screws): head
+ diameter ``dk`` (max), head height ``k`` (max), hexagon socket width across
+ flats ``s``, thread length ``b``.
+* ``COARSE_PITCH`` ISO 261:1998, coarse pitch series.
+
+COVERAGE (deliberately narrow - the table rule of the mech spec, section 5)
+---------------------------------------------------------------------------
+Only ISO 261 **first-choice** sizes are shipped:
+
+* ISO 4014 / 4017 / 4032 / 7089 : M5 M6 M8 M10 M12 M16 M20 M24 M30 M36
+* ISO 4762 : M3 M4 M5 M6 M8 M10 M12 M16 M20 M24
+
+Second-choice sizes (M14, M18, M22, M27, M33) and everything beyond those
+ranges are **not** shipped and are refused by name. A narrow table is merely
+narrow; a wrong across-flats ships a wrong workshop drawing.
+
+Two columns are deliberately absent:
+
+* across-corners ``e`` - :func:`across_corners` returns the *nominal*
+ hexagon's ``2s/sqrt(3)`` instead. The standards' own ``e`` is a minimum
+ computed from ``s`` min and is product-grade dependent; a drawing of the
+ nominal part wants the nominal hexagon, so no ``e`` column is transcribed.
+* the per-size nominal length ranges of ISO 4014 Table 1 - ``l`` is validated
+ as a finite positive number and the useful thread length comes from the
+ standard's own ``b`` rule in :func:`thread_length`.
+"""
+
+from __future__ import annotations
+
+import math
+
+from . import Coverage, register
+
+SOURCE_ISO_4014 = "ISO 4014:2011 Table 1 - hexagon head bolts, product grades A and B"
+SOURCE_ISO_4017 = "ISO 4017:2014 Table 1 - hexagon head screws, fully threaded"
+SOURCE_ISO_4032 = "ISO 4032:2012 Table 1 - hexagon regular nuts, style 1"
+SOURCE_ISO_7089 = "ISO 7089:2000 Table 1 - plain washers, normal series, product grade A"
+SOURCE_ISO_4762 = "ISO 4762:2004 Table 1 - hexagon socket head cap screws"
+SOURCE_ISO_261 = "ISO 261:1998 - ISO general purpose metric screw threads, coarse pitch series"
+
+#: ISO 261:1998 coarse pitch, one entry per size any table below carries.
+COARSE_PITCH: dict[str, float] = {
+ "M3": 0.5,
+ "M4": 0.7,
+ "M5": 0.8,
+ "M6": 1.0,
+ "M8": 1.25,
+ "M10": 1.5,
+ "M12": 1.75,
+ "M16": 2.0,
+ "M20": 2.5,
+ "M24": 3.0,
+ "M30": 3.5,
+ "M36": 4.0,
+}
+
+#: ISO 4014:2011 / ISO 4017:2014 Table 1 - s = across flats, k = head height.
+HEX_HEAD: dict[str, dict[str, float]] = {
+ "M5": {"s": 8.0, "k": 3.5},
+ "M6": {"s": 10.0, "k": 4.0},
+ "M8": {"s": 13.0, "k": 5.3},
+ "M10": {"s": 16.0, "k": 6.4},
+ "M12": {"s": 18.0, "k": 7.5},
+ "M16": {"s": 24.0, "k": 10.0},
+ "M20": {"s": 30.0, "k": 12.5},
+ "M24": {"s": 36.0, "k": 15.0},
+ "M30": {"s": 46.0, "k": 18.7},
+ "M36": {"s": 55.0, "k": 22.5},
+}
+
+#: ISO 4032:2012 Table 1 - s = across flats, m = nut height (max).
+HEX_NUT: dict[str, dict[str, float]] = {
+ "M5": {"s": 8.0, "m": 4.7},
+ "M6": {"s": 10.0, "m": 5.2},
+ "M8": {"s": 13.0, "m": 6.8},
+ "M10": {"s": 16.0, "m": 8.4},
+ "M12": {"s": 18.0, "m": 10.8},
+ "M16": {"s": 24.0, "m": 14.8},
+ "M20": {"s": 30.0, "m": 18.0},
+ "M24": {"s": 36.0, "m": 21.5},
+ "M30": {"s": 46.0, "m": 25.6},
+ "M36": {"s": 55.0, "m": 31.0},
+}
+
+#: ISO 7089:2000 Table 1 - d1 = bore, d2 = outside diameter, h = thickness.
+WASHER: dict[str, dict[str, float]] = {
+ "M5": {"d1": 5.3, "d2": 10.0, "h": 1.0},
+ "M6": {"d1": 6.4, "d2": 12.0, "h": 1.6},
+ "M8": {"d1": 8.4, "d2": 16.0, "h": 1.6},
+ "M10": {"d1": 10.5, "d2": 20.0, "h": 2.0},
+ "M12": {"d1": 13.0, "d2": 24.0, "h": 2.5},
+ "M16": {"d1": 17.0, "d2": 30.0, "h": 3.0},
+ "M20": {"d1": 21.0, "d2": 37.0, "h": 3.0},
+ "M24": {"d1": 25.0, "d2": 44.0, "h": 4.0},
+ "M30": {"d1": 31.0, "d2": 56.0, "h": 4.0},
+ "M36": {"d1": 37.0, "d2": 66.0, "h": 5.0},
+}
+
+#: ISO 4762:2004 Table 1 - dk = head diameter (max), k = head height (max),
+#: s = hexagon socket across flats, b = thread length.
+SOCKET_HEAD: dict[str, dict[str, float]] = {
+ "M3": {"dk": 5.5, "k": 3.0, "s": 2.5, "b": 18.0},
+ "M4": {"dk": 7.0, "k": 4.0, "s": 3.0, "b": 20.0},
+ "M5": {"dk": 8.5, "k": 5.0, "s": 4.0, "b": 22.0},
+ "M6": {"dk": 10.0, "k": 6.0, "s": 5.0, "b": 24.0},
+ "M8": {"dk": 13.0, "k": 8.0, "s": 6.0, "b": 28.0},
+ "M10": {"dk": 16.0, "k": 10.0, "s": 8.0, "b": 32.0},
+ "M12": {"dk": 18.0, "k": 12.0, "s": 10.0, "b": 36.0},
+ "M16": {"dk": 24.0, "k": 16.0, "s": 14.0, "b": 44.0},
+ "M20": {"dk": 30.0, "k": 20.0, "s": 17.0, "b": 52.0},
+ "M24": {"dk": 36.0, "k": 24.0, "s": 19.0, "b": 60.0},
+}
+
+_TABLES: dict[str, tuple[dict[str, dict[str, float]], str]] = {
+ "ISO 4014": (HEX_HEAD, SOURCE_ISO_4014),
+ "ISO 4017": (HEX_HEAD, SOURCE_ISO_4017),
+ "ISO 4032": (HEX_NUT, SOURCE_ISO_4032),
+ "ISO 7089": (WASHER, SOURCE_ISO_7089),
+ "ISO 4762": (SOCKET_HEAD, SOURCE_ISO_4762),
+}
+
+THREAD_LENGTH_STANDARDS = ("ISO 4014", "ISO 4017", "ISO 4762")
+
+
+def _key(size) -> str:
+ key = str(size).strip().upper()
+ if not key:
+ raise ValueError("fastener size must be a non-empty string such as 'M12'")
+ return key
+
+
+def _row(table: dict, standard: str, source: str, key: str) -> dict:
+ if key not in table:
+ raise ValueError(
+ f"{standard} is transcribed here for {', '.join(table)} only; {key!r} is "
+ f"outside that table and is never interpolated ({source})."
+ )
+ return dict(table[key])
+
+
+def nominal_diameter(size) -> float:
+ """Nominal thread diameter in mm, e.g. ``'M12' -> 12.0``."""
+ key = _key(size)
+ if key not in COARSE_PITCH:
+ raise ValueError(
+ f"{SOURCE_ISO_261} is transcribed here for {', '.join(COARSE_PITCH)}; "
+ f"{key!r} is outside it."
+ )
+ return float(key[1:])
+
+
+def thread_pitch(size) -> float:
+ """ISO 261 coarse pitch in mm."""
+ key = _key(size)
+ if key not in COARSE_PITCH:
+ raise ValueError(
+ f"{SOURCE_ISO_261} is transcribed here for {', '.join(COARSE_PITCH)}; "
+ f"{key!r} is outside it."
+ )
+ return COARSE_PITCH[key]
+
+
+def hex_head(size) -> dict:
+ """ISO 4014 / ISO 4017 head row plus the nominal diameter and pitch."""
+ key = _key(size)
+ row = _row(HEX_HEAD, "ISO 4014 / ISO 4017", SOURCE_ISO_4014, key)
+ return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row}
+
+
+def hex_nut(size) -> dict:
+ """ISO 4032 nut row plus the nominal diameter and pitch."""
+ key = _key(size)
+ row = _row(HEX_NUT, "ISO 4032", SOURCE_ISO_4032, key)
+ return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row}
+
+
+def washer(size) -> dict:
+ """ISO 7089 washer row plus the mating thread's nominal diameter and pitch."""
+ key = _key(size)
+ row = _row(WASHER, "ISO 7089", SOURCE_ISO_7089, key)
+ return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row}
+
+
+def socket_head(size) -> dict:
+ """ISO 4762 socket head cap screw row plus the nominal diameter and pitch."""
+ key = _key(size)
+ row = _row(SOCKET_HEAD, "ISO 4762", SOURCE_ISO_4762, key)
+ return {"size": key, "d": nominal_diameter(key), "p": thread_pitch(key), **row}
+
+
+def thread_length(size, length: float, standard: str = "ISO 4014") -> float:
+ """Useful thread length ``b`` for a nominal length ``l``, per the part standard.
+
+ ISO 4014 fixes ``b`` by rule rather than by table: ``2d + 6`` for
+ ``l <= 125``, ``2d + 12`` for ``125 < l <= 200`` and ``2d + 25`` for
+ ``l > 200``. ISO 4017 screws are threaded over their whole length. ISO 4762
+ tabulates ``b`` per size. In every case a screw shorter than its own ``b``
+ is threaded to the head, so the result is capped at ``l``.
+ """
+ key = _key(size)
+ name = " ".join(str(standard).split())
+ if name not in THREAD_LENGTH_STANDARDS:
+ raise ValueError(
+ f"thread_length: standard must be one of {', '.join(THREAD_LENGTH_STANDARDS)}, "
+ f"got {standard!r}."
+ )
+ if isinstance(length, bool) or not isinstance(length, (int, float)):
+ raise ValueError(f"thread_length: length must be a number, got {length!r}")
+ l_mm = float(length)
+ if not math.isfinite(l_mm) or l_mm <= 0.0:
+ raise ValueError(f"thread_length: length must be finite and > 0, got {length!r}")
+ if name == "ISO 4017":
+ hex_head(key)
+ return l_mm
+ if name == "ISO 4762":
+ return min(socket_head(key)["b"], l_mm)
+ diameter = nominal_diameter(key)
+ hex_head(key) # ISO 4014 coverage: refuse a size the head table does not carry
+ if l_mm <= 125.0:
+ b = 2.0 * diameter + 6.0
+ elif l_mm <= 200.0:
+ b = 2.0 * diameter + 12.0
+ else:
+ b = 2.0 * diameter + 25.0
+ return min(b, l_mm)
+
+
+def across_corners(s: float) -> float:
+ """Across-corners width of the *nominal* hexagon: ``2s/sqrt(3)``.
+
+ Derived geometry, not a transcribed column - see the module docstring.
+ """
+ if isinstance(s, bool) or not isinstance(s, (int, float)):
+ raise ValueError(f"across_corners: s must be a number, got {s!r}")
+ width = float(s)
+ if not math.isfinite(width) or width <= 0.0:
+ raise ValueError(f"across_corners: s must be finite and > 0, got {s!r}")
+ return 2.0 * width / math.sqrt(3.0)
+
+
+def sizes(standard: str) -> tuple[str, ...]:
+ """The transcribed sizes of one fastener standard, in table order."""
+ name = " ".join(str(standard).split()).upper()
+ if name not in _TABLES:
+ raise ValueError(
+ f"sizes: {standard!r} is not a fastener standard in this module; "
+ f"have {', '.join(_TABLES)}."
+ )
+ return tuple(_TABLES[name][0])
+
+
+register("ISO 4014", HEX_HEAD, Coverage(5.0, 36.0), SOURCE_ISO_4014)
+register("ISO 4017", HEX_HEAD, Coverage(5.0, 36.0), SOURCE_ISO_4017)
+register("ISO 4032", HEX_NUT, Coverage(5.0, 36.0), SOURCE_ISO_4032)
+register("ISO 7089", WASHER, Coverage(5.0, 36.0), SOURCE_ISO_7089)
+register("ISO 4762", SOCKET_HEAD, Coverage(3.0, 24.0), SOURCE_ISO_4762)
diff --git a/engineering/mech/stdparts.py b/engineering/mech/stdparts.py
new file mode 100644
index 0000000..31ea527
--- /dev/null
+++ b/engineering/mech/stdparts.py
@@ -0,0 +1,1047 @@
+"""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,
+ nominal_diameter,
+ 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
+
+#: Hexagon orientation shared by the two views of one fastener: vertices at 12
+#: and 6 o'clock, so the plan is ``e`` tall across the vertical and the
+#: elevation - which draws the head ``e`` tall with its silhouette edges at
+#: ``+/- e / 4`` - is the projection of that same hexagon.
+HEX_START_DEG = 30.0
+
+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 = HEX_START_DEG):
+ """The six corners of a hexagon of across-corners radius ``radius``.
+
+ The default ``start_deg`` puts vertices at 12 and 6 o'clock, so the plan's
+ vertical extent is ``2 * radius = e`` and the two remaining vertex pairs sit
+ at ``+/- radius / 2``. That is exactly the silhouette the elevation builders
+ draw (full height ``e``, chamfer lines at ``+/- e / 4``), which is what keeps
+ the two views of one fastener in projection. Do not change it without
+ changing the elevation to match.
+ """
+ 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:
+ nominal = nominal_diameter(size)
+ if abs(nominal - d) > 1e-9:
+ raise ValueError(
+ f"thread: params['size']={size!r} is a {nominal} mm thread but "
+ f"params['d']={d}. The geometry is drawn from d and the pitch is read "
+ "from the ISO 261 table for size, so a mismatch would report one "
+ "thread's pitch on another thread's geometry; give the size that "
+ "matches d, or give params['pitch'] instead."
+ )
+ 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 0871291..e14ad9f 100644
--- a/server.py
+++ b/server.py
@@ -1004,7 +1004,8 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"system_prompt_message",
}
),
- # SECTION 21 - the mechanical part model and its view engine (tracks B+G).
+ # SECTIONS 21-23 - the mechanical part model, standard parts and
+ # mechanical annotation (tracks B+G).
"mech": frozenset(
{
"mech_part_draw",
@@ -1013,6 +1014,9 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"mech_hole_pattern",
"mech_part_from_spec",
"mech_part_inspect",
+ "std_part_list",
+ "std_part_insert",
+ "std_feature_draw",
}
),
}
@@ -8567,6 +8571,199 @@ async def mech_part_inspect(
raise ToolError(str(exc)) from exc
+# ---------------------------------------------------------------------------
+# ── 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} (a size must name d's own "
+ "thread - 'M20' with d=8 is refused, not drawn); "
+ "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_bearings.py b/tests/test_mech_bearings.py
new file mode 100644
index 0000000..4d0e766
--- /dev/null
+++ b/tests/test_mech_bearings.py
@@ -0,0 +1,145 @@
+"""ISO 15 boundary dimensions and the ISO 8826 representation conventions.
+
+``ISO15_ROWS`` below pins d, D and B for *every* one of the 33 shipped
+designations, and a completeness test asserts its key set equals the module's.
+Inequality tests (series growth, the bore-number rule) constrain a row; only a
+literal pins it.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.mech.standards import standards
+from engineering.mech.standards.bearings import (
+ BALL_SYMBOL_FRACTION,
+ BEARING_SERIES,
+ CROSS_FRACTION,
+ DEEP_GROOVE,
+ SOURCE_ISO_15,
+ SOURCE_ISO_8826_1,
+ SOURCE_ISO_8826_2,
+ bearing,
+ list_bearings,
+)
+
+#: ISO 15:2017 Table 4 - every transcribed designation, (d, D, B) in mm.
+ISO15_ROWS: dict[str, tuple[float, float, float]] = {
+ # dimension series 10 (600x)
+ "6000": (10.0, 26.0, 8.0),
+ "6001": (12.0, 28.0, 8.0),
+ "6002": (15.0, 32.0, 9.0),
+ "6003": (17.0, 35.0, 10.0),
+ "6004": (20.0, 42.0, 12.0),
+ "6005": (25.0, 47.0, 12.0),
+ "6006": (30.0, 55.0, 13.0),
+ "6007": (35.0, 62.0, 14.0),
+ "6008": (40.0, 68.0, 15.0),
+ "6009": (45.0, 75.0, 16.0),
+ "6010": (50.0, 80.0, 16.0),
+ # dimension series 02 (620x)
+ "6200": (10.0, 30.0, 9.0),
+ "6201": (12.0, 32.0, 10.0),
+ "6202": (15.0, 35.0, 11.0),
+ "6203": (17.0, 40.0, 12.0),
+ "6204": (20.0, 47.0, 14.0),
+ "6205": (25.0, 52.0, 15.0),
+ "6206": (30.0, 62.0, 16.0),
+ "6207": (35.0, 72.0, 17.0),
+ "6208": (40.0, 80.0, 18.0),
+ "6209": (45.0, 85.0, 19.0),
+ "6210": (50.0, 90.0, 20.0),
+ # dimension series 03 (630x)
+ "6300": (10.0, 35.0, 11.0),
+ "6301": (12.0, 37.0, 12.0),
+ "6302": (15.0, 42.0, 13.0),
+ "6303": (17.0, 47.0, 14.0),
+ "6304": (20.0, 52.0, 15.0),
+ "6305": (25.0, 62.0, 17.0),
+ "6306": (30.0, 72.0, 19.0),
+ "6307": (35.0, 80.0, 21.0),
+ "6308": (40.0, 90.0, 23.0),
+ "6309": (45.0, 100.0, 25.0),
+ "6310": (50.0, 110.0, 27.0),
+}
+
+
+@pytest.mark.parametrize(("designation", "expected"), sorted(ISO15_ROWS.items()))
+def test_named_rows_match_iso15(designation, expected):
+ row = bearing(designation)
+ assert (row["d"], row["D"], row["B"]) == expected
+ assert row["designation"] == designation
+ assert row["source"] == SOURCE_ISO_15
+
+
+def test_every_shipped_bearing_is_pinned_by_a_literal():
+ """No shipped designation may escape the table above - that is where a wrong row hides."""
+ assert set(DEEP_GROOVE) == set(ISO15_ROWS)
+
+
+def test_bore_number_rule_holds_across_every_series():
+ """ISO 15: bore 00/01/02/03 = 10/12/15/17 mm; 04 and up = 5 x bore number."""
+ for series in ("60", "62", "63"):
+ assert bearing(f"{series}00")["d"] == 10.0
+ assert bearing(f"{series}01")["d"] == 12.0
+ assert bearing(f"{series}02")["d"] == 15.0
+ assert bearing(f"{series}03")["d"] == 17.0
+ for n in range(4, 11):
+ assert bearing(f"{series}{n:02d}")["d"] == 5.0 * n
+
+
+def test_outside_diameter_and_width_grow_with_the_series():
+ for n in range(0, 11):
+ light = bearing(f"60{n:02d}")
+ medium = bearing(f"62{n:02d}")
+ heavy = bearing(f"63{n:02d}")
+ assert light["d"] == medium["d"] == heavy["d"]
+ assert light["D"] < medium["D"] < heavy["D"]
+ assert light["B"] < medium["B"] < heavy["B"]
+
+
+def test_coverage_refusals_name_the_table():
+ with pytest.raises(ValueError, match="ISO 15"):
+ bearing("6011")
+ with pytest.raises(ValueError, match="ISO 15"):
+ bearing("6211")
+ with pytest.raises(ValueError, match="ISO 15"):
+ bearing("6311")
+ with pytest.raises(ValueError, match="ISO 15"):
+ bearing("61805") # the 618x light series is not transcribed
+ with pytest.raises(ValueError, match="ISO 15"):
+ bearing("6205-2RS") # a seal suffix is a variant this table does not carry
+
+
+def test_representation_fractions_are_declared_conventions_not_table_values():
+ assert 0.0 < CROSS_FRACTION < 1.0
+ assert 0.0 < BALL_SYMBOL_FRACTION < 1.0
+ assert "ISO 8826-1" in SOURCE_ISO_8826_1
+ assert "ISO 8826-2" in SOURCE_ISO_8826_2
+ assert "ISO 15:2017" in SOURCE_ISO_15
+
+
+def test_the_element_symbol_always_fits_inside_the_ring_rectangle():
+ """The declared fractions must never draw a symbol outside the envelope."""
+ for name in DEEP_GROOVE:
+ row = bearing(name)
+ half_width = row["B"] / 2.0
+ half_gap = (row["D"] - row["d"]) / 4.0
+ assert BALL_SYMBOL_FRACTION * half_gap < half_width
+ assert CROSS_FRACTION * half_width < half_width
+ assert CROSS_FRACTION * half_gap < half_gap
+
+
+def test_list_bearings_filters_by_series():
+ assert len(DEEP_GROOVE) == 33
+ assert len(list_bearings()) == 33
+ assert BEARING_SERIES == ("600x", "620x", "630x")
+ assert {row["designation"] for row in list_bearings("620x")} == {
+ f"62{n:02d}" for n in range(11)
+ }
+ with pytest.raises(ValueError, match="620x"):
+ list_bearings("622x")
+
+
+def test_the_table_registers_itself():
+ assert "ISO 15" in standards()
diff --git a/tests/test_mech_fasteners.py b/tests/test_mech_fasteners.py
new file mode 100644
index 0000000..357bcf9
--- /dev/null
+++ b/tests/test_mech_fasteners.py
@@ -0,0 +1,223 @@
+"""Provenance and coverage of the ISO fastener tables (the table rule, spec 5).
+
+Every value asserted here is a named row of a named table, and the converse holds
+too: the ``ISO*_ROWS`` literals below carry *every* dimension of *every* shipped
+size, and a completeness test asserts each table's key set equals its literal's.
+A row nobody pinned is exactly where a wrong across-flats would hide. A row that
+could not be verified against the standard is not in the module at all, so the
+coverage refusals below are as load-bearing as the values.
+"""
+
+from __future__ import annotations
+
+import math
+
+import pytest
+
+from engineering.mech.standards import standards
+from engineering.mech.standards.fasteners import (
+ COARSE_PITCH,
+ HEX_HEAD,
+ HEX_NUT,
+ SOCKET_HEAD,
+ SOURCE_ISO_4014,
+ WASHER,
+ across_corners,
+ hex_head,
+ hex_nut,
+ nominal_diameter,
+ sizes,
+ socket_head,
+ thread_length,
+ thread_pitch,
+ washer,
+)
+
+#: ISO 4014:2011 / ISO 4017:2014 Table 1 - every transcribed size, (s, k).
+ISO4014_ROWS: dict[str, tuple[float, float]] = {
+ "M5": (8.0, 3.5),
+ "M6": (10.0, 4.0),
+ "M8": (13.0, 5.3),
+ "M10": (16.0, 6.4),
+ "M12": (18.0, 7.5),
+ "M16": (24.0, 10.0),
+ "M20": (30.0, 12.5),
+ "M24": (36.0, 15.0),
+ "M30": (46.0, 18.7),
+ "M36": (55.0, 22.5),
+}
+
+#: ISO 4032:2012 Table 1 - every transcribed size, (s, m).
+ISO4032_ROWS: dict[str, tuple[float, float]] = {
+ "M5": (8.0, 4.7),
+ "M6": (10.0, 5.2),
+ "M8": (13.0, 6.8),
+ "M10": (16.0, 8.4),
+ "M12": (18.0, 10.8),
+ "M16": (24.0, 14.8),
+ "M20": (30.0, 18.0),
+ "M24": (36.0, 21.5),
+ "M30": (46.0, 25.6),
+ "M36": (55.0, 31.0),
+}
+
+#: ISO 7089:2000 Table 1 - every transcribed size, (d1, d2, h).
+ISO7089_ROWS: dict[str, tuple[float, float, float]] = {
+ "M5": (5.3, 10.0, 1.0),
+ "M6": (6.4, 12.0, 1.6),
+ "M8": (8.4, 16.0, 1.6),
+ "M10": (10.5, 20.0, 2.0),
+ "M12": (13.0, 24.0, 2.5),
+ "M16": (17.0, 30.0, 3.0),
+ "M20": (21.0, 37.0, 3.0),
+ "M24": (25.0, 44.0, 4.0),
+ "M30": (31.0, 56.0, 4.0),
+ "M36": (37.0, 66.0, 5.0),
+}
+
+#: ISO 4762:2004 Table 1 - every transcribed size, (dk, k, s, b).
+ISO4762_ROWS: dict[str, tuple[float, float, float, float]] = {
+ "M3": (5.5, 3.0, 2.5, 18.0),
+ "M4": (7.0, 4.0, 3.0, 20.0),
+ "M5": (8.5, 5.0, 4.0, 22.0),
+ "M6": (10.0, 6.0, 5.0, 24.0),
+ "M8": (13.0, 8.0, 6.0, 28.0),
+ "M10": (16.0, 10.0, 8.0, 32.0),
+ "M12": (18.0, 12.0, 10.0, 36.0),
+ "M16": (24.0, 16.0, 14.0, 44.0),
+ "M20": (30.0, 20.0, 17.0, 52.0),
+ "M24": (36.0, 24.0, 19.0, 60.0),
+}
+
+
+@pytest.mark.parametrize(("size", "expected"), sorted(ISO4014_ROWS.items()))
+def test_iso4014_named_rows_match_the_standard(size, expected):
+ """ISO 4014:2011 Table 1 - width across flats s and head height k."""
+ row = hex_head(size)
+ assert (row["s"], row["k"]) == expected
+
+
+def test_every_iso4014_row_is_pinned_by_a_literal():
+ """No shipped size may escape the table above - that is where a wrong row hides."""
+ assert set(HEX_HEAD) == set(ISO4014_ROWS)
+ assert sizes("ISO 4014") == tuple(HEX_HEAD)
+
+
+def test_iso4017_shares_the_iso4014_head():
+ """ISO 4017 screws and ISO 4014 bolts have the same head at every size here."""
+ assert sizes("ISO 4017") == sizes("ISO 4014")
+
+
+@pytest.mark.parametrize(("size", "expected"), sorted(ISO4032_ROWS.items()))
+def test_iso4032_named_rows_match_the_standard(size, expected):
+ """ISO 4032:2012 Table 1 - width across flats s and nut height m (max)."""
+ row = hex_nut(size)
+ assert (row["s"], row["m"]) == expected
+
+
+def test_every_iso4032_row_is_pinned_and_shares_the_bolt_across_flats():
+ assert set(HEX_NUT) == set(ISO4032_ROWS)
+ for size in HEX_NUT:
+ assert hex_nut(size)["s"] == hex_head(size)["s"]
+
+
+@pytest.mark.parametrize(("size", "expected"), sorted(ISO7089_ROWS.items()))
+def test_iso7089_named_rows_match_the_standard(size, expected):
+ """ISO 7089:2000 Table 1 - d1, d2, h (nominal), normal series, grade A."""
+ row = washer(size)
+ assert (row["d1"], row["d2"], row["h"]) == expected
+
+
+def test_every_iso7089_row_is_pinned_by_a_literal():
+ assert set(WASHER) == set(ISO7089_ROWS)
+
+
+@pytest.mark.parametrize(("size", "expected"), sorted(ISO4762_ROWS.items()))
+def test_iso4762_named_rows_match_the_standard(size, expected):
+ """ISO 4762:2004 Table 1 - head dk (max), head height k (max), socket s, thread length b."""
+ row = socket_head(size)
+ assert (row["dk"], row["k"], row["s"], row["b"]) == expected
+
+
+def test_every_iso4762_row_is_pinned_and_its_head_height_equals_the_thread_diameter():
+ assert set(SOCKET_HEAD) == set(ISO4762_ROWS)
+ # ISO 4762 head height equals the nominal thread diameter at every size here.
+ for size in SOCKET_HEAD:
+ assert socket_head(size)["k"] == nominal_diameter(size)
+
+
+def test_iso261_coarse_pitch_column():
+ assert COARSE_PITCH == {
+ "M3": 0.5,
+ "M4": 0.7,
+ "M5": 0.8,
+ "M6": 1.0,
+ "M8": 1.25,
+ "M10": 1.5,
+ "M12": 1.75,
+ "M16": 2.0,
+ "M20": 2.5,
+ "M24": 3.0,
+ "M30": 3.5,
+ "M36": 4.0,
+ }
+ assert thread_pitch("M12") == 1.75
+ assert nominal_diameter("M12") == 12.0
+
+
+def test_coverage_is_refused_at_both_ends_by_name():
+ with pytest.raises(ValueError, match="ISO 4014"):
+ hex_head("M3")
+ with pytest.raises(ValueError, match="ISO 4014"):
+ hex_head("M42")
+ with pytest.raises(ValueError, match="ISO 4032"):
+ hex_nut("M42")
+ with pytest.raises(ValueError, match="ISO 7089"):
+ washer("M3")
+ with pytest.raises(ValueError, match="ISO 4762"):
+ socket_head("M2")
+ with pytest.raises(ValueError, match="ISO 4762"):
+ socket_head("M30")
+
+
+def test_second_choice_sizes_are_absent_not_interpolated():
+ for absent in ("M14", "M18", "M22", "M27", "M33"):
+ with pytest.raises(ValueError):
+ hex_head(absent)
+ with pytest.raises(ValueError):
+ hex_nut(absent)
+
+
+def test_thread_length_follows_the_standards_own_b_rule():
+ assert thread_length("M12", 60.0) == pytest.approx(30.0) # 2d + 6, l <= 125
+ assert thread_length("M12", 150.0) == pytest.approx(36.0) # 2d + 12, 125 < l <= 200
+ assert thread_length("M12", 260.0) == pytest.approx(49.0) # 2d + 25, l > 200
+ assert thread_length("M12", 25.0) == pytest.approx(25.0) # never longer than the bolt
+ assert thread_length("M12", 60.0, "ISO 4017") == pytest.approx(60.0)
+ assert thread_length("M8", 30.0, "ISO 4762") == pytest.approx(28.0)
+ assert thread_length("M8", 20.0, "ISO 4762") == pytest.approx(20.0)
+ with pytest.raises(ValueError):
+ thread_length("M12", 0.0)
+ with pytest.raises(ValueError):
+ thread_length("M12", float("inf"))
+ with pytest.raises(ValueError, match="ISO 4014"):
+ thread_length("M12", 60.0, "DIN 931")
+
+
+def test_across_corners_is_derived_geometry_not_a_transcribed_column():
+ assert across_corners(18.0) == pytest.approx(2.0 * 18.0 / math.sqrt(3.0))
+ assert across_corners(18.0) == pytest.approx(20.7846, abs=1e-4)
+ with pytest.raises(ValueError):
+ across_corners(0.0)
+
+
+def test_every_table_registers_itself_with_the_shared_registry():
+ for name in ("ISO 4014", "ISO 4017", "ISO 4032", "ISO 7089", "ISO 4762"):
+ assert name in standards()
+ assert "ISO 4014:2011" in SOURCE_ISO_4014
+
+
+def test_sizes_refuses_a_standard_this_module_does_not_carry():
+ assert sizes("ISO 4014")[0] == "M5"
+ with pytest.raises(ValueError, match="ISO 4014"):
+ sizes("ISO 15")
diff --git a/tests/test_mech_std_features.py b/tests/test_mech_std_features.py
new file mode 100644
index 0000000..4744149
--- /dev/null
+++ b/tests/test_mech_std_features.py
@@ -0,0 +1,240 @@
+"""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_a_size_that_does_not_match_d_is_refused():
+ """Otherwise the returned dims report M20's pitch on M8 geometry, citing ISO 261."""
+ with pytest.raises(ValueError, match=r"params\['size'\]='M20' is a 20.0 mm thread"):
+ feature_spec("thread", {"d": 8.0, "length": 20.0, "size": "M20"})
+ matching = feature_spec("thread", {"d": 8.0, "length": 20.0, "size": "M8"})
+ assert matching["dims"]["pitch"] == 1.25 # ISO 261 coarse pitch of M8
+
+
+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..fdd1424
--- /dev/null
+++ b/tests/test_mech_stdparts.py
@@ -0,0 +1,382 @@
+"""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 _hexagon(spec: dict) -> list[list[float]]:
+ return next(e for e in spec["entities"] if e["type"] == "polyline")["points"]
+
+
+def _height(points) -> float:
+ return max(y for _, y in points) - min(y for _, y in points)
+
+
+def _width(points) -> float:
+ return max(x for x, _ in points) - min(x for x, _ in points)
+
+
+def test_the_hexagon_has_vertices_at_twelve_and_six_oclock():
+ """The orientation the elevation is drawn as the projection of."""
+ points = _hexagon(block_spec("ISO 4014 - M12x60", "top"))
+ assert [pytest.approx(v, abs=1e-9) for v in points[0]] == [
+ R12 * math.sqrt(3.0) / 2.0,
+ R12 / 2.0,
+ ]
+ assert [pytest.approx(v, abs=1e-9) for v in points[1]] == [0.0, R12]
+ assert _height(points) == pytest.approx(2.0 * R12) # across corners e
+ assert _width(points) == pytest.approx(18.0) # across flats s
+
+
+@pytest.mark.parametrize(
+ "designation", ["ISO 4014 - M12x60", "ISO 4017 - M12x60", "ISO 4032 - M12"]
+)
+def test_the_two_views_of_one_fastener_agree_on_the_silhouette_height(designation):
+ """Plan and elevation of the same part must measure the same across the vertical.
+
+ Place the two blocks in projection and a disagreement here is a wrong
+ workshop drawing: the head would read 20.78 mm in the elevation and 18 mm in
+ the plan. The elevation's outline polyline spans the full head height, and
+ the plan's hexagon must span the same.
+ """
+ elevation = _height(_hexagon(block_spec(designation, "side")))
+ assert elevation == pytest.approx(2.0 * R12)
+ assert _height(_hexagon(block_spec(designation, "top"))) == pytest.approx(elevation)
+
+
+def test_the_socket_head_views_agree_on_the_head_diameter():
+ """The socket screw's silhouette is dk in both views; its hexagon is the key socket."""
+ elevation = _height(_hexagon(block_spec("ISO 4762 - M12x60", "side")))
+ plan = block_spec("ISO 4762 - M12x60", "top")["entities"]
+ assert plan[0]["type"] == "circle" and 2.0 * plan[0]["r"] == pytest.approx(elevation)
+ socket = plan[1]["points"]
+ assert _height(socket) > _width(socket) # same orientation as every other hexagon here
+
+
+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 e423f98..f075973 100644
--- a/tests/test_tool_packs.py
+++ b/tests/test_tool_packs.py
@@ -5,6 +5,9 @@ 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 21's six part-model tools and
+SECTION 22's three standard-parts tools.
"""
from __future__ import annotations
@@ -78,6 +81,18 @@ SETTINGS_TOOLS = {
"system_prompt_message",
}
+MECH_TOOLS = {
+ "mech_part_draw",
+ "mech_view_add",
+ "mech_dimension_part",
+ "mech_hole_pattern",
+ "mech_part_from_spec",
+ "mech_part_inspect",
+ "std_part_list",
+ "std_part_insert",
+ "std_feature_draw",
+}
+
LEAN_SETTINGS_ESSENTIALS = {
"drawing_apply_standard",
"page_setup_apply",
@@ -86,16 +101,10 @@ LEAN_SETTINGS_ESSENTIALS = {
"textstyle_set_current",
}
+#: Declaration order - what `tool_packs["available"]` reports.
ALL_PACKS = ["core", "pid", "settings", "mech"]
-ALL_PACKS_SORTED = sorted(ALL_PACKS)
-MECH_TOOLS = {
- "mech_part_draw",
- "mech_view_add",
- "mech_dimension_part",
- "mech_hole_pattern",
- "mech_part_from_spec",
- "mech_part_inspect",
-}
+#: `tool_packs["enabled"]` is sorted, which is no longer the same list.
+ALL_PACKS_ENABLED = sorted(ALL_PACKS)
@pytest_asyncio.fixture(autouse=True)
@@ -111,7 +120,10 @@ def test_pack_registry_names_real_tools_and_only_them():
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
assert len(SETTINGS_TOOLS) == 22
+ assert len(MECH_TOOLS) == 9
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():
@@ -127,6 +139,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
@@ -146,8 +159,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_SORTED
+ 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(
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 36edea0..d562a02 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -527,15 +527,19 @@ async def test_tool_group_sizes_are_unchanged():
merge task tagged them, the four groups had filed those 28 tools under
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
their secondary tags; those counts returned to their pre-track-E values
- when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's
- SECTION 21 opened with the part model and its view engine
- (`mech_part_draw` / `mech_view_add` / `mech_dimension_part` /
- `mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the
- `mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so
- those tools file under `mech` rather than under their secondary `create` /
- `dimension` / `query` tags. This snapshot is branch-local: the merge task
- recomputes it once all four track B+G branches have landed. Every other
- number here has been unchanged since the snapshot was taken.
+ when the section tags landed.) Every other number here has been unchanged
+ since the snapshot was taken.
+
+ `mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with
+ the part model and its view engine (`mech_part_draw` / `mech_view_add` /
+ `mech_dimension_part` / `mech_hole_pattern` / `mech_part_from_spec` /
+ `mech_part_inspect`); the `mech` tag is ranked directly after `pid` in
+ `_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under
+ their secondary `create` / `dimension` / `query` tags.
+
+ SECTION 22 added the standard-parts catalogue (`std_part_list` /
+ `std_part_insert` / `std_feature_draw`) to the same group, filed under
+ `mech` rather than under their secondary `query` / `create` tags.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -552,7 +556,7 @@ async def test_tool_group_sizes_are_unchanged():
"environment": 22,
"layers": 14,
"layouts": 12,
- "mech": 6,
+ "mech": 9,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -564,4 +568,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 210
+ assert sum(sizes.values()) == 213