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