From 49b972f007afc58472c85a4eb7f9a6357fe9d7bc Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 18:53:17 +0300 Subject: [PATCH 1/4] feat(task-8): ISO fastener and ISO 15 bearing tables, transcribed and pinned engineering/mech/standards/fasteners.py carries ISO 4014:2011 / ISO 4017:2014 head dimensions (s, k), ISO 4032:2012 nut heights, ISO 7089:2000 washer d1/d2/h and ISO 4762:2004 socket head cap screws, plus the ISO 261:1998 coarse pitch column. bearings.py carries the ISO 15:2017 deep groove boundary dimensions for 6000-6010, 6200-6210 and 6300-6310. Only the ISO 261 first-choice sizes ship. M14/M18/M22/M27/M33, everything above M36 (above M24 for ISO 4762), the 618x/619x/16xx bearing series, bore numbers above 10 and every suffixed bearing variant are refused by name rather than interpolated: a narrow table is merely narrow, a wrong row ships a wrong workshop drawing. Across-corners e is derived geometry (2s/sqrt(3)), so no e column had to be transcribed at all, and the ISO 8826-1 cross and ISO 8826-2 element-symbol fractions are declared drawing conventions, not table values. No ActiveX member is called by this task, so no live AutoCAD probe was needed; the two modules are pure tables and pure arithmetic. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/standards/bearings.py | 136 ++++++++++++ engineering/mech/standards/fasteners.py | 269 ++++++++++++++++++++++++ tests/test_mech_bearings.py | 111 ++++++++++ tests/test_mech_fasteners.py | 189 +++++++++++++++++ 4 files changed, 705 insertions(+) create mode 100644 engineering/mech/standards/bearings.py create mode 100644 engineering/mech/standards/fasteners.py create mode 100644 tests/test_mech_bearings.py create mode 100644 tests/test_mech_fasteners.py 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/tests/test_mech_bearings.py b/tests/test_mech_bearings.py new file mode 100644 index 0000000..7c6c595 --- /dev/null +++ b/tests/test_mech_bearings.py @@ -0,0 +1,111 @@ +"""ISO 15 boundary dimensions and the ISO 8826 representation conventions.""" + +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, +) + + +@pytest.mark.parametrize( + ("designation", "bore", "outer", "width"), + [ + ("6000", 10.0, 26.0, 8.0), + ("6004", 20.0, 42.0, 12.0), + ("6006", 30.0, 55.0, 13.0), + ("6010", 50.0, 80.0, 16.0), + ("6200", 10.0, 30.0, 9.0), + ("6203", 17.0, 40.0, 12.0), + ("6205", 25.0, 52.0, 15.0), + ("6208", 40.0, 80.0, 18.0), + ("6210", 50.0, 90.0, 20.0), + ("6300", 10.0, 35.0, 11.0), + ("6305", 25.0, 62.0, 17.0), + ("6309", 45.0, 100.0, 25.0), + ("6310", 50.0, 110.0, 27.0), + ], +) +def test_named_rows_match_iso15(designation, bore, outer, width): + row = bearing(designation) + assert (row["d"], row["D"], row["B"]) == (bore, outer, width) + assert row["designation"] == designation + assert row["source"] == SOURCE_ISO_15 + + +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..c67ce8a --- /dev/null +++ b/tests/test_mech_fasteners.py @@ -0,0 +1,189 @@ +"""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. 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_NUT, + SOCKET_HEAD, + SOURCE_ISO_4014, + across_corners, + hex_head, + hex_nut, + nominal_diameter, + sizes, + socket_head, + thread_length, + thread_pitch, + washer, +) + + +def test_iso4014_named_rows_match_the_standard(): + """ISO 4014:2011 Table 1 - width across flats s and head height k.""" + assert (hex_head("M6")["s"], hex_head("M6")["k"]) == (10.0, 4.0) + assert (hex_head("M8")["s"], hex_head("M8")["k"]) == (13.0, 5.3) + assert (hex_head("M10")["s"], hex_head("M10")["k"]) == (16.0, 6.4) + assert (hex_head("M12")["s"], hex_head("M12")["k"]) == (18.0, 7.5) + assert (hex_head("M16")["s"], hex_head("M16")["k"]) == (24.0, 10.0) + assert (hex_head("M20")["s"], hex_head("M20")["k"]) == (30.0, 12.5) + assert (hex_head("M24")["s"], hex_head("M24")["k"]) == (36.0, 15.0) + assert (hex_head("M30")["s"], hex_head("M30")["k"]) == (46.0, 18.7) + assert (hex_head("M36")["s"], hex_head("M36")["k"]) == (55.0, 22.5) + + +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") + + +def test_iso4032_named_rows_match_the_standard(): + """ISO 4032:2012 Table 1 - nut height m (max).""" + assert hex_nut("M6")["m"] == 5.2 + assert hex_nut("M8")["m"] == 6.8 + assert hex_nut("M10")["m"] == 8.4 + assert hex_nut("M12")["m"] == 10.8 + assert hex_nut("M16")["m"] == 14.8 + assert hex_nut("M20")["m"] == 18.0 + assert hex_nut("M24")["m"] == 21.5 + assert hex_nut("M30")["m"] == 25.6 + assert hex_nut("M36")["m"] == 31.0 + for size in HEX_NUT: + assert hex_nut(size)["s"] == hex_head(size)["s"] + + +def test_iso7089_named_rows_match_the_standard(): + """ISO 7089:2000 Table 1 - d1, d2, h (nominal), normal series, grade A.""" + assert (washer("M6")["d1"], washer("M6")["d2"], washer("M6")["h"]) == (6.4, 12.0, 1.6) + assert (washer("M8")["d1"], washer("M8")["d2"], washer("M8")["h"]) == (8.4, 16.0, 1.6) + assert (washer("M10")["d1"], washer("M10")["d2"], washer("M10")["h"]) == (10.5, 20.0, 2.0) + assert (washer("M12")["d1"], washer("M12")["d2"], washer("M12")["h"]) == (13.0, 24.0, 2.5) + assert (washer("M16")["d1"], washer("M16")["d2"], washer("M16")["h"]) == (17.0, 30.0, 3.0) + assert (washer("M20")["d1"], washer("M20")["d2"], washer("M20")["h"]) == (21.0, 37.0, 3.0) + assert (washer("M24")["d1"], washer("M24")["d2"], washer("M24")["h"]) == (25.0, 44.0, 4.0) + assert (washer("M30")["d1"], washer("M30")["d2"], washer("M30")["h"]) == (31.0, 56.0, 4.0) + assert (washer("M36")["d1"], washer("M36")["d2"], washer("M36")["h"]) == (37.0, 66.0, 5.0) + + +def test_iso4762_named_rows_match_the_standard(): + """ISO 4762:2004 Table 1 - head dk (max), socket s, thread length b.""" + assert ( + socket_head("M3")["dk"], + socket_head("M3")["s"], + socket_head("M3")["b"], + ) == (5.5, 2.5, 18.0) + assert ( + socket_head("M5")["dk"], + socket_head("M5")["s"], + socket_head("M5")["b"], + ) == (8.5, 4.0, 22.0) + assert ( + socket_head("M8")["dk"], + socket_head("M8")["s"], + socket_head("M8")["b"], + ) == (13.0, 6.0, 28.0) + assert ( + socket_head("M12")["dk"], + socket_head("M12")["s"], + socket_head("M12")["b"], + ) == (18.0, 10.0, 36.0) + assert ( + socket_head("M20")["dk"], + socket_head("M20")["s"], + socket_head("M20")["b"], + ) == (30.0, 17.0, 52.0) + assert ( + socket_head("M24")["dk"], + socket_head("M24")["s"], + socket_head("M24")["b"], + ) == (36.0, 19.0, 60.0) + # 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") From 6024723d6bab603df107b488d8aea03086d16385 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:07:53 +0300 Subject: [PATCH 2/4] fix(task-8): pin every transcribed standards row, not a sampled subset The module docstring claimed "every value asserted here is a named row of a named table", but the converse did not hold: 27 of the 83 shipped rows had a dimension no test compared against a literal. ISO 4014/4017 M5 (s,k), ISO 4032 M5 (m), ISO 7089 M5 (d1,d2,h), ISO 4762 M4/M6/M10/M16 (dk,s,b) and D/B for 20 of the 33 ISO 15 bearings were constrained only by inequality tests (series growth) and by the bore-number rule - never by the transcribed number. For a task whose worst failure is "a standards table row that is not in the standard", those rows were exactly where such a row would hide. Measured before the fix with a pytest plugin that rewrote five rows in memory (M5 hex bolt s=k=99, M5 nut, M5 washer, M16 socket screw dk=s=b=99, 6307 as 78x20 instead of 80x21): 32 passed in 0.07s. All five now fail. Each of the five tables is pinned by an ISO*_ROWS dict literal carrying every dimension of every shipped size, parametrized one case per size/designation, plus a completeness test asserting the table's key set equals the literal's - so a row added later without a pin is red too (verified: a fabricated M14 head and 6011 bearing fail the completeness gate). 32 -> 93 tests, 0.06 -> 0.13 s. No production code changed; no ActiveX member is involved, so no live probe was required for this finding. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- tests/test_mech_bearings.py | 76 +++++++++++----- tests/test_mech_fasteners.py | 166 +++++++++++++++++++++-------------- 2 files changed, 155 insertions(+), 87 deletions(-) diff --git a/tests/test_mech_bearings.py b/tests/test_mech_bearings.py index 7c6c595..4d0e766 100644 --- a/tests/test_mech_bearings.py +++ b/tests/test_mech_bearings.py @@ -1,4 +1,10 @@ -"""ISO 15 boundary dimensions and the ISO 8826 representation conventions.""" +"""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 @@ -17,32 +23,60 @@ from engineering.mech.standards.bearings import ( 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", "bore", "outer", "width"), - [ - ("6000", 10.0, 26.0, 8.0), - ("6004", 20.0, 42.0, 12.0), - ("6006", 30.0, 55.0, 13.0), - ("6010", 50.0, 80.0, 16.0), - ("6200", 10.0, 30.0, 9.0), - ("6203", 17.0, 40.0, 12.0), - ("6205", 25.0, 52.0, 15.0), - ("6208", 40.0, 80.0, 18.0), - ("6210", 50.0, 90.0, 20.0), - ("6300", 10.0, 35.0, 11.0), - ("6305", 25.0, 62.0, 17.0), - ("6309", 45.0, 100.0, 25.0), - ("6310", 50.0, 110.0, 27.0), - ], -) -def test_named_rows_match_iso15(designation, bore, outer, width): + +@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"]) == (bore, outer, width) + 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"): diff --git a/tests/test_mech_fasteners.py b/tests/test_mech_fasteners.py index c67ce8a..357bcf9 100644 --- a/tests/test_mech_fasteners.py +++ b/tests/test_mech_fasteners.py @@ -1,8 +1,11 @@ """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. 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. +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 @@ -14,9 +17,11 @@ 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, @@ -28,18 +33,74 @@ from engineering.mech.standards.fasteners import ( 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), +} -def test_iso4014_named_rows_match_the_standard(): +#: 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.""" - assert (hex_head("M6")["s"], hex_head("M6")["k"]) == (10.0, 4.0) - assert (hex_head("M8")["s"], hex_head("M8")["k"]) == (13.0, 5.3) - assert (hex_head("M10")["s"], hex_head("M10")["k"]) == (16.0, 6.4) - assert (hex_head("M12")["s"], hex_head("M12")["k"]) == (18.0, 7.5) - assert (hex_head("M16")["s"], hex_head("M16")["k"]) == (24.0, 10.0) - assert (hex_head("M20")["s"], hex_head("M20")["k"]) == (30.0, 12.5) - assert (hex_head("M24")["s"], hex_head("M24")["k"]) == (36.0, 15.0) - assert (hex_head("M30")["s"], hex_head("M30")["k"]) == (46.0, 18.7) - assert (hex_head("M36")["s"], hex_head("M36")["k"]) == (55.0, 22.5) + 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(): @@ -47,66 +108,39 @@ def test_iso4017_shares_the_iso4014_head(): assert sizes("ISO 4017") == sizes("ISO 4014") -def test_iso4032_named_rows_match_the_standard(): - """ISO 4032:2012 Table 1 - nut height m (max).""" - assert hex_nut("M6")["m"] == 5.2 - assert hex_nut("M8")["m"] == 6.8 - assert hex_nut("M10")["m"] == 8.4 - assert hex_nut("M12")["m"] == 10.8 - assert hex_nut("M16")["m"] == 14.8 - assert hex_nut("M20")["m"] == 18.0 - assert hex_nut("M24")["m"] == 21.5 - assert hex_nut("M30")["m"] == 25.6 - assert hex_nut("M36")["m"] == 31.0 +@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"] -def test_iso7089_named_rows_match_the_standard(): +@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.""" - assert (washer("M6")["d1"], washer("M6")["d2"], washer("M6")["h"]) == (6.4, 12.0, 1.6) - assert (washer("M8")["d1"], washer("M8")["d2"], washer("M8")["h"]) == (8.4, 16.0, 1.6) - assert (washer("M10")["d1"], washer("M10")["d2"], washer("M10")["h"]) == (10.5, 20.0, 2.0) - assert (washer("M12")["d1"], washer("M12")["d2"], washer("M12")["h"]) == (13.0, 24.0, 2.5) - assert (washer("M16")["d1"], washer("M16")["d2"], washer("M16")["h"]) == (17.0, 30.0, 3.0) - assert (washer("M20")["d1"], washer("M20")["d2"], washer("M20")["h"]) == (21.0, 37.0, 3.0) - assert (washer("M24")["d1"], washer("M24")["d2"], washer("M24")["h"]) == (25.0, 44.0, 4.0) - assert (washer("M30")["d1"], washer("M30")["d2"], washer("M30")["h"]) == (31.0, 56.0, 4.0) - assert (washer("M36")["d1"], washer("M36")["d2"], washer("M36")["h"]) == (37.0, 66.0, 5.0) + row = washer(size) + assert (row["d1"], row["d2"], row["h"]) == expected -def test_iso4762_named_rows_match_the_standard(): - """ISO 4762:2004 Table 1 - head dk (max), socket s, thread length b.""" - assert ( - socket_head("M3")["dk"], - socket_head("M3")["s"], - socket_head("M3")["b"], - ) == (5.5, 2.5, 18.0) - assert ( - socket_head("M5")["dk"], - socket_head("M5")["s"], - socket_head("M5")["b"], - ) == (8.5, 4.0, 22.0) - assert ( - socket_head("M8")["dk"], - socket_head("M8")["s"], - socket_head("M8")["b"], - ) == (13.0, 6.0, 28.0) - assert ( - socket_head("M12")["dk"], - socket_head("M12")["s"], - socket_head("M12")["b"], - ) == (18.0, 10.0, 36.0) - assert ( - socket_head("M20")["dk"], - socket_head("M20")["s"], - socket_head("M20")["b"], - ) == (30.0, 17.0, 52.0) - assert ( - socket_head("M24")["dk"], - socket_head("M24")["s"], - socket_head("M24")["b"], - ) == (36.0, 19.0, 60.0) +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) From 84c1ef42fb220036a0dd0ae45358a6d45d3ff05c Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:26:49 +0300 Subject: [PATCH 3/4] feat(task-9): SECTION 22 - std_part_list, std_part_insert, std_feature_draw engineering/mech/stdparts.py turns the Task 8 tables into real blocks. catalogue() searches 83 transcribed parts by designation, standard, size or family; block_spec() emits the typed primitives and DESIG/STD/SIZE/MAT ATTDEFs block_define takes, in side/top for fasteners and ISO 8826-1 simplified / ISO 8826-2 detailed for bearings; insert_std_part() defines the block once, inserts it and writes the std_part ACADMCP_MECH payload the skeleton pins, so the parts list is a read of the drawing rather than a naming convention. Threads follow ISO 6410 - minor at 0.8 x major, a thread-length limit line to the major, a three-quarter minor arc in the end view. std_feature_draw() adds a thread, a DIN 509 undercut, a DIN 471/472 groove, a DIN 332 centre hole or an ISO 3601-2 O-ring groove to geometry that already exists. Its dimensions come from the shared standards registry, never from this module: an absent table, a size outside coverage or a row missing a dimension is refused by name, and the DIN 509 relief is drawn from the profile its table publishes rather than reconstructed here. DIN 332 form R is refused outright. An unknown designation is refused with the nearest catalogue entries; nothing is drawn approximately. SECTION 22 registers the three tools in the new mech pack, with alias records (INSERT plus shop-floor synonyms in English and Turkish), a regenerated tool inventory, the README release snapshot at 207 tools in 24 groups, and the frozen group-size snapshot gaining mech: 3. Two plan corrections, both verified by running the code: * the plan's @cad_tool summaries for std_part_insert (118 chars) and std_feature_draw (130) overran discovery.serialize.MAX_SUMMARY_CHARS = 110, so tests/test_tool_registry.py::test_no_authored_summary_is_long_enough_to_be_truncated failed - they would have shipped rendered as "...". Both are shortened to 99 and 104 characters with the same content. * the plan's file list omitted tests/test_tool_packs.py, which pins the pack registry, the tool_packs report and the group count. It now carries MECH_TOOLS and distinguishes ALL_PACKS (declaration order, what "available" reports) from ALL_PACKS_ENABLED (sorted, what "enabled" reports) - those two lists stopped being the same list when "mech" was appended after "settings". No new ActiveX member is called: the module drives block_list / block_define / block_insert / entity_set_xdata / entity_create_* only. The COM path was still executed live once on AutoCAD 2026 in a scratch document created and closed by the probe: the bolt block defined with layers CENTER+GEOMETRY, attributes read back as DESIG/STD/SIZE/MAT = ISO 4014 - M12x60 / ISO 4014 / M12x60 / 8.8, the ACADMCP_MECH std_part payload round-tripped through entity_get_xdata, STD_ISO_15_6205_SIMPLIFIED inserted, and the M20 thread's three primitives landing on GEOMETRY. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- discovery/aliases.py | 55 ++ docs/tool-inventory.json | 47 +- engineering/mech/stdparts.py | 1022 +++++++++++++++++++++++++++++++ server.py | 203 +++++- tests/test_mech_std_features.py | 232 +++++++ tests/test_mech_stdparts.py | 333 ++++++++++ tests/test_tool_packs.py | 27 +- tests/test_tool_registry.py | 10 +- 9 files changed, 1923 insertions(+), 12 deletions(-) create mode 100644 engineering/mech/stdparts.py create mode 100644 tests/test_mech_std_features.py create mode 100644 tests/test_mech_stdparts.py diff --git a/README.md b/README.md index 6d056a1..6bb65af 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests. -> 204 is the **registered** count; a default install advertises 199 over `tools/list`, +> **v1.5 release snapshot:** 207 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 207 is the **registered** count; a default install advertises 202 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +207 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index f139c03..12e049e 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1859,6 +1859,61 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "save a detail view", ), ), + # ── Standard parts (SECTION 22, tracks B+G group S) ───────────────────── + "std_part_list": ToolAliases( + acad=(), + synonyms=( + "standard parts catalogue", + "what bolts do you have", + "bolt sizes", + "hex bolt table", + "bearing list", + "iso 4014 sizes", + "find a washer", + "civata listesi", + "rulman listesi", + ), + ), + "std_part_insert": ToolAliases( + acad=("INSERT",), + synonyms=( + "insert a bolt", + "place a screw", + "draw a nut", + "add a washer", + "put a bearing in", + "hex bolt", + "socket head cap screw", + "allen screw", + "deep groove ball bearing", + "standard part", + "civata", + "somun", + "rondela", + "rulman", + ), + ), + "std_feature_draw": ToolAliases( + acad=(), + synonyms=( + "draw a thread", + "thread representation", + "undercut", + "relief groove", + "retaining ring groove", + "circlip groove", + "snap ring groove", + "centre hole", + "center drill", + "o-ring groove", + "din 509", + "din 471", + "din 332", + "vida disi", + "segman kanali", + "punta deligi", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index c5c16f1..4e1500b 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,10 +3,10 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 204, + "tools": 207, "resources": 8, "prompts": 5, - "groups": 23 + "groups": 24 }, "groups": { "analysis": 12, @@ -22,6 +22,7 @@ "environment": 22, "layers": 14, "layouts": 12, + "mech": 3, "page_setup": 6, "pid": 9, "premium": 12, @@ -2333,6 +2334,48 @@ "destructiveHint": false } }, + { + "name": "std_feature_draw", + "group": "mech", + "cost": "mutate", + "summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Standard Parts: Draw Feature", + "readOnlyHint": false + } + }, + { + "name": "std_part_insert", + "group": "mech", + "cost": "mutate", + "summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Standard Parts: Insert", + "readOnlyHint": false + } + }, + { + "name": "std_part_list", + "group": "mech", + "cost": "read", + "summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.", + "tags": [ + "mech", + "query" + ], + "annotations": { + "title": "Standard Parts: Catalogue", + "readOnlyHint": true + } + }, { "name": "system_about", "group": "system", diff --git a/engineering/mech/stdparts.py b/engineering/mech/stdparts.py new file mode 100644 index 0000000..4f969c3 --- /dev/null +++ b/engineering/mech/stdparts.py @@ -0,0 +1,1022 @@ +"""The standard-parts catalogue: tables in, real blocks with attributes out. + +Two halves live here. + +**Standard parts.** :func:`catalogue` searches the authored tables of +``standards/fasteners.py`` and ``standards/bearings.py``; :func:`block_spec` +turns one designation and one view into the typed primitives and ATTDEFs +``block_define`` takes; :func:`insert_std_part` defines the block once, inserts +it and writes the ``std_part`` ACADMCP_MECH payload, so a parts list is a read +of the drawing rather than a naming convention. This is the Track A (P&ID) +pattern applied to fasteners and bearings. + +**Standard features.** :func:`draw_std_feature` adds a thread, a DIN 509 +undercut, a retaining-ring groove, a DIN 332 centre hole or an ISO 3601-2 +O-ring groove to geometry that already exists - a foreign drawing being +finished. The thread is the ISO 6410 representation and its pitch comes from +ISO 261; every other feature takes its dimensions from the shared standards +registry and is refused by name when the table is absent, when the size is +outside its coverage, or when the row lacks a dimension the feature needs. +Nothing here reconstructs a standard profile it was not handed. + +Primitives are the ``block_define`` spec dicts of ``backends/block_specs.py`` +(``{"type": "line", "x1": ..., "layer": ...}``) with an explicit ``layer``, so +one vocabulary serves both the block definitions and the feature drawing, and +the pure half is testable by comparing emitted dicts. +""" + +from __future__ import annotations + +import difflib +import math +import re +from typing import TYPE_CHECKING + +from engineering.mech.standards import lookup as standards_lookup +from engineering.mech.standards.bearings import ( + BALL_SYMBOL_FRACTION, + CROSS_FRACTION, + SOURCE_ISO_15, + bearing, + list_bearings, +) +from engineering.mech.standards.fasteners import ( + SOURCE_ISO_4014, + SOURCE_ISO_4017, + SOURCE_ISO_4032, + SOURCE_ISO_4762, + SOURCE_ISO_7089, + across_corners, + hex_head, + hex_nut, + sizes, + socket_head, + thread_length, + thread_pitch, + washer, +) +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, to_values + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +LAYER_VISIBLE = "GEOMETRY" +LAYER_CENTER = "CENTER" +LAYER_HIDDEN = "HIDDEN" +TEXT_HEIGHT = 2.5 +AXIS_OVERRUN = 3.0 +DEFAULT_VIEW = "side" + +#: ISO 6410 draws the minor diameter of a thread at 0.8 x the major diameter. +THREAD_MINOR_RATIO = 0.8 + +SOURCE_ISO_6410 = ( + "ISO 6410-1:1993 - technical drawings, screw threads and threaded parts, general conventions" +) + +FAMILY_STANDARDS: dict[str, str] = { + "ISO 4014": "hex_bolt", + "ISO 4017": "hex_screw", + "ISO 4032": "hex_nut", + "ISO 7089": "washer", + "ISO 4762": "socket_head_screw", + "ISO 15": "deep_groove_bearing", +} +STANDARD_SOURCE: dict[str, str] = { + "ISO 4014": SOURCE_ISO_4014, + "ISO 4017": SOURCE_ISO_4017, + "ISO 4032": SOURCE_ISO_4032, + "ISO 7089": SOURCE_ISO_7089, + "ISO 4762": SOURCE_ISO_4762, + "ISO 15": SOURCE_ISO_15, +} +FAMILY_VIEWS: dict[str, tuple[str, ...]] = { + "hex_bolt": ("side", "top"), + "hex_screw": ("side", "top"), + "hex_nut": ("side", "top"), + "washer": ("side", "top"), + "socket_head_screw": ("side", "top"), + "deep_groove_bearing": ("simplified", "detailed"), +} +LENGTH_REQUIRED = frozenset({"hex_bolt", "hex_screw", "socket_head_screw"}) + +_THREAD_RE = re.compile(r"^(M\d{1,3})(?:\s*[X*]\s*(\d{1,4}(?:\.\d+)?))?$", re.IGNORECASE) +_BEARING_RE = re.compile(r"^\d{4}$") + + +# ── primitive helpers (block_define spec dicts, block-local mm) ───────────── + + +def _line(x1, y1, x2, y2, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "line", + "x1": float(x1), + "y1": float(y1), + "x2": float(x2), + "y2": float(y2), + "layer": layer, + } + + +def _circle(cx, cy, r, layer: str = LAYER_VISIBLE) -> dict: + return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r), "layer": layer} + + +def _arc(cx, cy, r, start_deg, end_deg, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "arc", + "cx": float(cx), + "cy": float(cy), + "r": float(r), + "start_deg": float(start_deg), + "end_deg": float(end_deg), + "layer": layer, + } + + +def _poly(points, closed: bool = True, layer: str = LAYER_VISIBLE) -> dict: + return { + "type": "polyline", + "points": [[float(x), float(y)] for x, y in points], + "closed": bool(closed), + "layer": layer, + } + + +def _hex_points(radius: float, start_deg: float = 0.0): + return [ + ( + radius * math.cos(math.radians(start_deg + 60.0 * i)), + radius * math.sin(math.radians(start_deg + 60.0 * i)), + ) + for i in range(6) + ] + + +def _centre_mark(cx: float, cy: float, extent: float) -> list[dict]: + return [ + _line(cx - extent, cy, cx + extent, cy, LAYER_CENTER), + _line(cx, cy - extent, cx, cy + extent, LAYER_CENTER), + ] + + +# ── designations and the catalogue ────────────────────────────────────────── + + +def _normalise_standard(text: str) -> str: + return re.sub(r"^(ISO)\s*", r"\1 ", " ".join(str(text).split()).upper()) + + +def parse_designation(designation: str) -> dict: + """Split a catalogue designation into standard, family, size, thread and length. + + Accepts ``'ISO 4014 - M12x60'``, ``'iso4014-m12x60'`` and a bare four-digit + bearing number (``'6205'`` becomes ``'ISO 15 - 6205'``). Returns + ``{"designation", "standard", "family", "size", "thread", "length"}`` with + a canonical designation. Every refusal names what was wrong. + """ + text = " ".join(str(designation).split()) + if not text: + raise ValueError( + "std part designation must be a non-empty string, e.g. 'ISO 4014 - M12x60'" + ) + if _BEARING_RE.match(text): + text = f"ISO 15 - {text}" + head, separator, tail = text.partition("-") + if not separator: + raise ValueError( + f"{designation!r} is not a catalogue designation: expected " + "' - ', e.g. 'ISO 4014 - M12x60' or 'ISO 15 - 6205'." + ) + standard = _normalise_standard(head) + size = " ".join(tail.split()) + if standard not in FAMILY_STANDARDS: + raise ValueError( + f"{designation!r}: {standard!r} is not a catalogue standard. " + f"The catalogue holds {', '.join(FAMILY_STANDARDS)}." + ) + family = FAMILY_STANDARDS[standard] + if family == "deep_groove_bearing": + if not _BEARING_RE.match(size): + raise ValueError( + f"{designation!r}: an ISO 15 size is a four-digit bearing designation " + f"such as '6205', got {size!r}." + ) + return { + "designation": f"{standard} - {size}", + "standard": standard, + "family": family, + "size": size, + "thread": None, + "length": None, + } + match = _THREAD_RE.match(size) + if not match: + shape = "'M12' or 'M12x60'" if family in LENGTH_REQUIRED else "'M12'" + raise ValueError( + f"{designation!r}: a {standard} size is a metric thread such as {shape}, got {size!r}." + ) + thread = match.group(1).upper() + length = float(match.group(2)) if match.group(2) else None + if family in LENGTH_REQUIRED and length is None: + raise ValueError( + f"{designation!r}: {standard} needs a nominal length - write 'M12x60'. The " + "per-size length range is not transcribed, so any finite positive length is " + "accepted and the thread length follows the standard's own b rule." + ) + if family not in LENGTH_REQUIRED and length is not None: + raise ValueError(f"{designation!r}: {standard} takes no length, got {size!r}.") + canonical = thread if length is None else f"{thread}x{length:g}" + return { + "designation": f"{standard} - {canonical}", + "standard": standard, + "family": family, + "size": canonical, + "thread": thread, + "length": length, + } + + +def catalogue(query: str | None = None, standard: str | None = None) -> tuple[dict, ...]: + """Every transcribed standard part, optionally filtered. + + ``standard`` restricts to one catalogue standard (refused by name when it + is not one). ``query`` is matched, case-insensitively, as a substring of + the designation, standard, size and family, so ``'M12'``, ``'ISO 4014'``, + ``'6205'`` and ``'washer'`` all work. + """ + rows: list[dict] = [] + getters = { + "hex_bolt": hex_head, + "hex_screw": hex_head, + "hex_nut": hex_nut, + "washer": washer, + "socket_head_screw": socket_head, + } + for name, family in FAMILY_STANDARDS.items(): + if family == "deep_groove_bearing": + for row in list_bearings(): + rows.append( + { + "designation": f"{name} - {row['designation']}", + "standard": name, + "family": family, + "size": row["designation"], + "views": list(FAMILY_VIEWS[family]), + "length_required": False, + "dims": {key: row[key] for key in ("d", "D", "B")}, + "source": row["source"], + } + ) + continue + getter = getters[family] + for size in sizes(name): + dims = getter(size) + rows.append( + { + "designation": f"{name} - {size}", + "standard": name, + "family": family, + "size": size, + "views": list(FAMILY_VIEWS[family]), + "length_required": family in LENGTH_REQUIRED, + "dims": {key: value for key, value in dims.items() if key != "size"}, + "source": STANDARD_SOURCE[name], + } + ) + if standard is not None: + want = _normalise_standard(standard) + if want not in FAMILY_STANDARDS: + raise ValueError( + f"catalogue: {standard!r} is not a catalogue standard; " + f"have {', '.join(FAMILY_STANDARDS)}." + ) + rows = [row for row in rows if row["standard"] == want] + if query: + needle = " ".join(str(query).split()).lower() + rows = [ + row + for row in rows + if needle + in f"{row['designation']} {row['standard']} {row['size']} {row['family']}".lower() + ] + return tuple(rows) + + +def _numeric_key(family: str, size: str) -> float | None: + if family == "deep_groove_bearing": + return float(size) if size.isdigit() else None + match = _THREAD_RE.match(size) + if not match: + return None + try: + return float(match.group(1)[1:]) + except ValueError: # pragma: no cover - the regex already fixed the shape + return None + + +def _suggest(parsed: dict) -> str: + rows = list(catalogue(standard=parsed["standard"])) + target = _numeric_key(parsed["family"], parsed["thread"] or parsed["size"]) + if target is not None: + ranked = sorted( + rows, + key=lambda row: abs((_numeric_key(row["family"], row["size"]) or 0.0) - target), + ) + else: + names = [row["designation"] for row in rows] + close = difflib.get_close_matches(parsed["designation"], names, n=3, cutoff=0.3) + ranked = [row for row in rows if row["designation"] in close] + return ", ".join(row["designation"] for row in ranked[:3]) + + +def _resolve_dims(parsed: dict) -> dict: + family = parsed["family"] + try: + if family in ("hex_bolt", "hex_screw"): + dims = hex_head(parsed["thread"]) + elif family == "hex_nut": + dims = hex_nut(parsed["thread"]) + elif family == "washer": + dims = washer(parsed["thread"]) + elif family == "socket_head_screw": + dims = socket_head(parsed["thread"]) + else: + dims = bearing(parsed["size"]) + except ValueError as exc: + raise ValueError( + f"{parsed['designation']!r} is not in the standard-parts catalogue. {exc} " + f"Nearest entries: {_suggest(parsed)}. Nothing is drawn approximately." + ) from exc + if family in ("hex_bolt", "hex_screw", "hex_nut"): + dims["e"] = across_corners(dims["s"]) + if family in LENGTH_REQUIRED: + if family == "socket_head_screw": + dims["b_table"] = dims["b"] + dims["l"] = parsed["length"] + dims["b"] = thread_length(parsed["thread"], parsed["length"], parsed["standard"]) + return dims + + +def _resolve_view(family: str, view) -> str: + valid = FAMILY_VIEWS[family] + name = " ".join(str(view).split()).lower() + if name in valid: + return name + if name == DEFAULT_VIEW: + return valid[0] + raise ValueError(f"{family}: view must be one of {', '.join(valid)}, got {view!r}.") + + +def _block_name(standard: str, size: str, view: str) -> str: + raw = f"STD_{standard}_{size}_{view}".upper() + return re.sub(r"[^A-Z0-9]+", "_", raw).strip("_") + + +def _attdefs(parsed: dict, bbox: tuple[float, float, float, float]) -> list[dict]: + top = bbox[1] - 2.0 * TEXT_HEIGHT + rows = ( + ("DESIG", "Designation", parsed["designation"], False), + ("STD", "Standard", parsed["standard"], True), + ("SIZE", "Size", parsed["size"], True), + ("MAT", "Material or property class", "", True), + ) + return [ + { + "tag": tag, + "prompt": prompt, + "default": default, + "x": 0.0, + "y": top - index * TEXT_HEIGHT * 1.6, + "height": TEXT_HEIGHT, + "align": "center", + "invisible": invisible, + } + for index, (tag, prompt, default, invisible) in enumerate(rows) + ] + + +# ── the view builders: (entities, bbox) in block-local mm ─────────────────── + + +def _hex_fastener_side(dims: dict, *, threaded_to_head: bool): + d, s, k = dims["d"], dims["s"], dims["k"] + length, b = dims["l"], dims["b"] + r = across_corners(s) / 2.0 + minor = THREAD_MINOR_RATIO * d / 2.0 + x0 = length - b + ents = [ + _poly([(-k, -r), (0.0, -r), (0.0, r), (-k, r)]), + _line(-k, r / 2.0, 0.0, r / 2.0), + _line(-k, -r / 2.0, 0.0, -r / 2.0), + _line(0.0, d / 2.0, length, d / 2.0), + _line(0.0, -d / 2.0, length, -d / 2.0), + _line(length, -d / 2.0, length, d / 2.0), + _line(x0, minor, length, minor), + _line(x0, -minor, length, -minor), + ] + if not threaded_to_head and x0 > 1e-9: + ents.append(_line(x0, -d / 2.0, x0, d / 2.0)) + ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-k - AXIS_OVERRUN, -r, length + AXIS_OVERRUN, r) + + +def _hex_fastener_top(dims: dict): + d, s = dims["d"], dims["s"] + r = across_corners(s) / 2.0 + ents = [ + _poly(_hex_points(r)), + _circle(0.0, 0.0, d / 2.0), + _arc(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0, 90.0, 360.0), + ] + ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN) + extent = r + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _hex_nut_side(dims: dict): + d, s, m = dims["d"], dims["s"], dims["m"] + r = across_corners(s) / 2.0 + minor = THREAD_MINOR_RATIO * d / 2.0 + ents = [ + _poly([(0.0, -r), (m, -r), (m, r), (0.0, r)]), + _line(0.0, r / 2.0, m, r / 2.0), + _line(0.0, -r / 2.0, m, -r / 2.0), + _line(0.0, d / 2.0, m, d / 2.0, LAYER_HIDDEN), + _line(0.0, -d / 2.0, m, -d / 2.0, LAYER_HIDDEN), + _line(0.0, minor, m, minor, LAYER_HIDDEN), + _line(0.0, -minor, m, -minor, LAYER_HIDDEN), + _line(-AXIS_OVERRUN, 0.0, m + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-AXIS_OVERRUN, -r, m + AXIS_OVERRUN, r) + + +def _hex_nut_top(dims: dict): + d, s = dims["d"], dims["s"] + r = across_corners(s) / 2.0 + ents = [ + _poly(_hex_points(r)), + _circle(0.0, 0.0, THREAD_MINOR_RATIO * d / 2.0), + _arc(0.0, 0.0, d / 2.0, 90.0, 360.0), + ] + ents += _centre_mark(0.0, 0.0, r + AXIS_OVERRUN) + extent = r + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _washer_side(dims: dict): + d1, d2, h = dims["d1"], dims["d2"], dims["h"] + ents = [ + _poly([(0.0, d1 / 2.0), (h, d1 / 2.0), (h, d2 / 2.0), (0.0, d2 / 2.0)]), + _poly([(0.0, -d1 / 2.0), (h, -d1 / 2.0), (h, -d2 / 2.0), (0.0, -d2 / 2.0)]), + _line(-AXIS_OVERRUN, 0.0, h + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-AXIS_OVERRUN, -d2 / 2.0, h + AXIS_OVERRUN, d2 / 2.0) + + +def _washer_top(dims: dict): + d1, d2 = dims["d1"], dims["d2"] + ents = [_circle(0.0, 0.0, d2 / 2.0), _circle(0.0, 0.0, d1 / 2.0)] + ents += _centre_mark(0.0, 0.0, d2 / 2.0 + AXIS_OVERRUN) + extent = d2 / 2.0 + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _socket_head_side(dims: dict): + d, dk, k = dims["d"], dims["dk"], dims["k"] + length, b = dims["l"], dims["b"] + minor = THREAD_MINOR_RATIO * d / 2.0 + x0 = length - b + ents = [ + _poly([(-k, -dk / 2.0), (0.0, -dk / 2.0), (0.0, dk / 2.0), (-k, dk / 2.0)]), + _line(0.0, d / 2.0, length, d / 2.0), + _line(0.0, -d / 2.0, length, -d / 2.0), + _line(length, -d / 2.0, length, d / 2.0), + _line(x0, minor, length, minor), + _line(x0, -minor, length, -minor), + ] + if x0 > 1e-9: + ents.append(_line(x0, -d / 2.0, x0, d / 2.0)) + ents.append(_line(-k - AXIS_OVERRUN, 0.0, length + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-k - AXIS_OVERRUN, -dk / 2.0, length + AXIS_OVERRUN, dk / 2.0) + + +def _socket_head_top(dims: dict): + dk, s = dims["dk"], dims["s"] + ents = [_circle(0.0, 0.0, dk / 2.0), _poly(_hex_points(across_corners(s) / 2.0))] + ents += _centre_mark(0.0, 0.0, dk / 2.0 + AXIS_OVERRUN) + extent = dk / 2.0 + AXIS_OVERRUN + return ents, (-extent, -extent, extent, extent) + + +def _bearing_simplified(dims: dict): + inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0 + ents = [ + _poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]), + _poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]), + ] + arm_x = CROSS_FRACTION * half + arm_y = CROSS_FRACTION * (outer - inner) / 2.0 + middle = (inner + outer) / 2.0 + for centre in (middle, -middle): + ents.append(_line(-arm_x, centre, arm_x, centre)) + ents.append(_line(0.0, centre - arm_y, 0.0, centre + arm_y)) + ents.append(_line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER)) + return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer) + + +def _bearing_detailed(dims: dict): + inner, outer, half = dims["d"] / 2.0, dims["D"] / 2.0, dims["B"] / 2.0 + radius = BALL_SYMBOL_FRACTION * (outer - inner) / 2.0 + middle = (inner + outer) / 2.0 + ents = [ + _poly([(-half, inner), (half, inner), (half, outer), (-half, outer)]), + _poly([(-half, -inner), (half, -inner), (half, -outer), (-half, -outer)]), + _circle(0.0, middle, radius), + _circle(0.0, -middle, radius), + _line(-half - AXIS_OVERRUN, 0.0, half + AXIS_OVERRUN, 0.0, LAYER_CENTER), + ] + return ents, (-half - AXIS_OVERRUN, -outer, half + AXIS_OVERRUN, outer) + + +_BUILDERS = { + ("hex_bolt", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=False), + ("hex_bolt", "top"): _hex_fastener_top, + ("hex_screw", "side"): lambda dims: _hex_fastener_side(dims, threaded_to_head=True), + ("hex_screw", "top"): _hex_fastener_top, + ("hex_nut", "side"): _hex_nut_side, + ("hex_nut", "top"): _hex_nut_top, + ("washer", "side"): _washer_side, + ("washer", "top"): _washer_top, + ("socket_head_screw", "side"): _socket_head_side, + ("socket_head_screw", "top"): _socket_head_top, + ("deep_groove_bearing", "simplified"): _bearing_simplified, + ("deep_groove_bearing", "detailed"): _bearing_detailed, +} + + +def block_spec(designation: str, view: str) -> dict: + """Typed primitives and ATTDEFs for ``block_define``, in block-local mm. + + The block's origin is the part's own datum: the head bearing face for a + bolt or cap screw, the bearing face for a nut or washer, the centre for a + bearing, with the axis along +X. Every refusal fires before any geometry + is built. + """ + parsed = parse_designation(designation) + kind = _resolve_view(parsed["family"], view) + dims = _resolve_dims(parsed) + entities, bbox = _BUILDERS[(parsed["family"], kind)](dims) + return { + "name": _block_name(parsed["standard"], parsed["size"], kind), + "entities": entities, + "attdefs": _attdefs(parsed, bbox), + "base_x": 0.0, + "base_y": 0.0, + "bbox": bbox, + "view": kind, + "designation": parsed["designation"], + "standard": parsed["standard"], + "size": parsed["size"], + "family": parsed["family"], + "dims": dims, + "source": STANDARD_SOURCE[parsed["standard"]], + } + + +async def insert_std_part( + backend: AutoCADBackend, + designation: str, + *, + at, + view: str = DEFAULT_VIEW, + rotation: float = 0.0, + layer: str = LAYER_VISIBLE, + attributes: dict | None = None, + material: str | None = None, +) -> dict: + """Define the part's block once, insert it, write its ``std_part`` payload. + + ``at`` is the WCS point the block's own datum lands on. The default + ``view="side"`` falls back to a family's first view (``simplified`` for a + bearing) and the result reports which view was drawn; any other mismatch is + refused. Refusals - an unknown designation with the nearest catalogue + entries, a bad view, a non-finite coordinate or rotation, an attribute tag + the block does not have - all fire before the first entity is written. + """ + spec = block_spec(designation, view) + try: + x, y = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"insert_std_part: 'at' must be two numbers, got {at!r}") from exc + if not (math.isfinite(x) and math.isfinite(y)): + raise ValueError(f"insert_std_part: 'at' must be two finite numbers, got {at!r}") + angle = float(rotation) + if not math.isfinite(angle): + raise ValueError(f"insert_std_part: rotation must be finite, got {rotation!r}") + + values = {att["tag"]: att["default"] for att in spec["attdefs"]} + supplied = dict(attributes or {}) + if material is not None: + supplied["MAT"] = material + unknown = sorted(set(supplied) - set(values)) + if unknown: + raise ValueError( + f"insert_std_part: {spec['name']} has no attribute {', '.join(unknown)}; " + f"valid tags: {', '.join(values)}." + ) + values.update({str(key): str(value) for key, value in supplied.items()}) + + existing = {block.name for block in await backend.block_list()} + defined = spec["name"] not in existing + layers_created: list[str] = [] + if defined: + created = await backend.block_define( + spec["name"], + spec["entities"], + spec["attdefs"], + spec["base_x"], + spec["base_y"], + False, + True, + ) + layers_created = list(created.get("layers_created", [])) + ref = await backend.block_insert(spec["name"], x, y, 1.0, 1.0, angle, values, layer) + payload = { + "v": PAYLOAD_VERSION, + "kind": "std_part", + "designation": spec["designation"], + "standard": spec["standard"], + "size": spec["size"], + "qty": 1, + "material": values["MAT"], + } + await backend.entity_set_xdata(ref.handle, APP_ID, to_values(payload)) + return { + "ok": True, + "handle": ref.handle, + "block_name": spec["name"], + "defined": defined, + "layers_created": layers_created, + "designation": spec["designation"], + "standard": spec["standard"], + "size": spec["size"], + "family": spec["family"], + "view": spec["view"], + "layer": layer, + "at": [x, y], + "rotation": angle, + "dims": spec["dims"], + "source": spec["source"], + "payload": payload, + "backend": backend.name, + } + + +# ── standard features drawn onto geometry that already exists ─────────────── +# +# Local frame for every feature: the origin is on the axis, +X runs along the +# axis in the direction the feature extends, +Y is radially outward. No feature +# draws a centre line - the geometry it is added to already has one. + +STD_FEATURE_KINDS = ("thread", "undercut", "ring_groove", "centre_hole", "oring_groove") + +FEATURE_SOURCE = { + "thread": SOURCE_ISO_6410, + "undercut": "DIN 509 relief grooves - profile from the registered DIN 509 table", + "ring_groove": "DIN 471 / DIN 472 retaining rings - dimensions from the registered table", + "centre_hole": "DIN 332-1 centre holes forms A and B - dimensions from the registered table", + "oring_groove": "ISO 3601-2 O-ring housings - dimensions from the registered table", +} + + +def _positive(params: dict, key: str, kind: str) -> float: + if key not in params: + raise ValueError(f"{kind}: params[{key!r}] is required") + value = params[key] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{kind}: params[{key!r}] must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number) or number <= 0.0: + raise ValueError(f"{kind}: params[{key!r}] must be finite and > 0, got {value!r}") + return number + + +def _side(params: dict, kind: str) -> str: + where = " ".join(str(params.get("kind", "shaft")).split()).lower() + if where not in ("shaft", "bore"): + raise ValueError( + f"{kind}: params['kind'] must be 'shaft' or 'bore', got {params.get('kind')!r}" + ) + return where + + +def _table_row(standard: str, size, kind: str, required: tuple[str, ...]) -> dict: + try: + row = dict(standards_lookup(standard, size)) + except (KeyError, ValueError) as exc: + raise ValueError(f"{kind}: {standard} - {exc}") from exc + missing = [key for key in required if key not in row] + if missing: + raise ValueError( + f"{kind}: the {standard} row for {size} carries no {', '.join(missing)}. " + "std_feature_draw draws the dimensions the standard's own table module " + "publishes and never reconstructs them." + ) + return row + + +def _groove_prims(surface: float, floor: float, width: float) -> tuple[dict, ...]: + steps = ( + (0.0, surface, 0.0, floor), + (0.0, floor, width, floor), + (width, floor, width, surface), + ) + return tuple( + _line(x1, sign * y1, x2, sign * y2) for sign in (1.0, -1.0) for x1, y1, x2, y2 in steps + ) + + +def _thread_feature(params: dict): + d = _positive(params, "d", "thread") + length = _positive(params, "length", "thread") + internal = bool(params.get("internal", False)) + size = params.get("size") + if size: + pitch = thread_pitch(size) + elif "pitch" in params: + pitch = _positive(params, "pitch", "thread") + else: + raise ValueError( + "thread: give params['size'] (e.g. 'M20', whose pitch comes from ISO 261) or " + "an explicit params['pitch'] - the pitch is never guessed." + ) + thin = d / 2.0 if internal else THREAD_MINOR_RATIO * d / 2.0 + prims = ( + _line(0.0, thin, length, thin), + _line(0.0, -thin, length, -thin), + _line(length, -d / 2.0, length, d / 2.0), + ) + dims = {"d": d, "length": length, "pitch": pitch, "internal": internal} + return prims, dims, "ISO 6410" + + +def _ring_groove_feature(params: dict): + d = _positive(params, "d", "ring_groove") + where = _side(params, "ring_groove") + standard = " ".join( + str(params.get("standard") or ("DIN 471" if where == "shaft" else "DIN 472")).split() + ) + row = _table_row(standard, d, "ring_groove", ("m", "d2")) + width, groove_d = float(row["m"]), float(row["d2"]) + if not math.isfinite(width) or width <= 0.0: + raise ValueError(f"ring_groove: {standard} row for d={d} has groove width m={width}.") + if where == "shaft" and not 0.0 < groove_d < d: + raise ValueError( + f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a " + "shaft groove (a shaft groove needs d2 < d)." + ) + if where == "bore" and not groove_d > d: + raise ValueError( + f"ring_groove: {standard} row for d={d} has d2={groove_d}, which is not a " + "bore groove (a bore groove needs d2 > d)." + ) + prims = _groove_prims(d / 2.0, groove_d / 2.0, width) + return prims, {"d": d, "m": width, "d2": groove_d, "kind": where}, standard + + +def _oring_groove_feature(params: dict): + d = _positive(params, "d", "oring_groove") + cord = _positive(params, "cord", "oring_groove") + where = _side(params, "oring_groove") + standard = " ".join(str(params.get("standard") or "ISO 3601-2").split()) + row = _table_row(standard, cord, "oring_groove", ("b", "h")) + width, depth = float(row["b"]), float(row["h"]) + if not math.isfinite(width) or width <= 0.0 or not math.isfinite(depth) or depth <= 0.0: + raise ValueError( + f"oring_groove: {standard} row for cord={cord} has b={width}, h={depth}; " + "both must be finite and > 0." + ) + if where == "shaft" and depth >= d / 2.0: + raise ValueError( + f"oring_groove: {standard} groove depth h={depth} is not inside a shaft of " + f"d={d} (the floor would be at or through the axis)." + ) + surface = d / 2.0 + floor = surface - depth if where == "shaft" else surface + depth + prims = _groove_prims(surface, floor, width) + return prims, {"d": d, "cord": cord, "b": width, "h": depth, "kind": where}, standard + + +def _centre_hole_feature(params: dict): + form = " ".join(str(params.get("form", "A")).split()).upper() + if form not in ("A", "B"): + raise ValueError( + "centre_hole: this tool draws DIN 332-1 forms A and B. Form R's radius " + f"profile is not transcribed here and is refused rather than approximated; " + f"got {form!r}." + ) + if params.get("size") is None: + raise ValueError("centre_hole: params['size'] is the DIN 332 pilot diameter d1, e.g. 2.5") + size = params["size"] + standard = " ".join(str(params.get("standard") or f"DIN 332-{form}").split()) + required = ("d1", "d2", "t") if form == "A" else ("d1", "d2", "d3", "t") + row = _table_row(standard, size, "centre_hole", required) + d1, d2, depth = float(row["d1"]), float(row["d2"]), float(row["t"]) + if not 0.0 < d1 < d2: + raise ValueError( + f"centre_hole: {standard} row for {size} has d1={d1}, d2={d2}; d1 < d2 is required." + ) + # 60 degrees included -> the axial run of the countersink is (d2-d1)/2 / tan(30). + cone = math.sqrt(3.0) * (d2 - d1) / 2.0 + start = 0.0 + prims: list[dict] = [] + dims = {"form": form, "d1": d1, "d2": d2, "t": depth, "cone": cone} + if form == "B": + d3 = float(row["d3"]) + if not d3 > d2: + raise ValueError( + f"centre_hole: {standard} row for {size} has d3={d3}; form B needs d3 > d2={d2}." + ) + # 120 degrees included -> (d3-d2)/2 / tan(60). + chamfer = (d3 - d2) / (2.0 * math.sqrt(3.0)) + prims += [ + _line(0.0, d3 / 2.0, chamfer, d2 / 2.0), + _line(0.0, -d3 / 2.0, chamfer, -d2 / 2.0), + ] + start = chamfer + dims["d3"] = d3 + dims["chamfer"] = chamfer + prims += [ + _line(start, d2 / 2.0, start + cone, d1 / 2.0), + _line(start, -d2 / 2.0, start + cone, -d1 / 2.0), + ] + if depth <= start + cone: + raise ValueError( + f"centre_hole: {standard} row for {size} has t={depth} mm, which is not deeper " + f"than the countersink ({start + cone:.3f} mm); t is measured from the end face." + ) + prims += [ + _line(start + cone, d1 / 2.0, depth, d1 / 2.0), + _line(start + cone, -d1 / 2.0, depth, -d1 / 2.0), + _line(depth, -d1 / 2.0, depth, d1 / 2.0), + ] + return tuple(prims), dims, standard + + +def _undercut_feature(params: dict): + d = _positive(params, "d", "undercut") + form = " ".join(str(params.get("form", "E")).split()).upper() + if form not in ("E", "F"): + raise ValueError(f"undercut: DIN 509 defines forms E and F; got {form!r}.") + standard = " ".join(str(params.get("standard") or f"DIN 509-{form}").split()) + row = _table_row(standard, d, "undercut", ("profile",)) + profile = row["profile"] + if not isinstance(profile, (list, tuple)) or len(profile) < 2: + raise ValueError( + f"undercut: the {standard} row for d={d} has a profile with fewer than two points." + ) + points: list[tuple[float, float]] = [] + for index, point in enumerate(profile): + if not isinstance(point, (list, tuple)) or len(point) != 2: + raise ValueError( + f"undercut: {standard} profile[{index}] must be an [along, depth] pair, " + f"got {point!r}" + ) + along, depth = float(point[0]), float(point[1]) + if not (math.isfinite(along) and math.isfinite(depth)): + raise ValueError(f"undercut: {standard} profile[{index}] must be finite.") + if depth < 0.0 or depth >= d / 2.0: + raise ValueError( + f"undercut: {standard} profile[{index}] depth {depth} is not inside a shaft " + f"of d={d} (needs 0 <= depth < {d / 2.0})." + ) + points.append((along, d / 2.0 - depth)) + prims = ( + _poly(points, closed=False), + _poly([(x, -y) for x, y in points], closed=False), + ) + dims = {"d": d, "form": form, "profile": [[float(a), float(b)] for a, b in profile]} + return prims, dims, standard + + +_FEATURES = { + "thread": _thread_feature, + "undercut": _undercut_feature, + "ring_groove": _ring_groove_feature, + "centre_hole": _centre_hole_feature, + "oring_groove": _oring_groove_feature, +} + + +def feature_spec(kind: str, params: dict) -> dict: + """Primitives, resolved dimensions and provenance for one standard feature.""" + name = " ".join(str(kind).split()).lower() + if name not in STD_FEATURE_KINDS: + raise ValueError( + f"std_feature_draw: kind must be one of {', '.join(STD_FEATURE_KINDS)}, got {kind!r}." + ) + prims, dims, standard = _FEATURES[name](dict(params or {})) + return { + "kind": name, + "prims": prims, + "dims": dims, + "standard": standard, + "source": FEATURE_SOURCE[name], + } + + +def feature_prims(kind: str, params: dict) -> tuple[dict, ...]: + """Just the primitives of :func:`feature_spec`, in the feature's local frame.""" + return feature_spec(kind, params)["prims"] + + +def place_prims(prims, at, rotation: float = 0.0) -> tuple[dict, ...]: + """Map local primitives into WCS: rotate about the origin, then translate to ``at``.""" + try: + x0, y0 = float(at[0]), float(at[1]) + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"place_prims: 'at' must be two numbers, got {at!r}") from exc + if not (math.isfinite(x0) and math.isfinite(y0)): + raise ValueError(f"place_prims: 'at' must be two finite numbers, got {at!r}") + degrees = float(rotation) + if not math.isfinite(degrees): + raise ValueError(f"place_prims: rotation must be finite, got {rotation!r}") + cosine, sine = math.cos(math.radians(degrees)), math.sin(math.radians(degrees)) + + def point(x: float, y: float) -> tuple[float, float]: + return (x0 + x * cosine - y * sine, y0 + x * sine + y * cosine) + + placed: list[dict] = [] + for prim in prims: + moved = dict(prim) + kind = prim["type"] + if kind == "line": + moved["x1"], moved["y1"] = point(prim["x1"], prim["y1"]) + moved["x2"], moved["y2"] = point(prim["x2"], prim["y2"]) + elif kind == "circle": + moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"]) + elif kind == "arc": + moved["cx"], moved["cy"] = point(prim["cx"], prim["cy"]) + moved["start_deg"] = prim["start_deg"] + degrees + moved["end_deg"] = prim["end_deg"] + degrees + elif kind == "polyline": + moved["points"] = [list(point(px, py)) for px, py in prim["points"]] + else: # pragma: no cover - the builders emit nothing else + raise ValueError(f"place_prims: cannot place a {kind!r} primitive") + placed.append(moved) + return tuple(placed) + + +async def draw_std_feature( + backend: AutoCADBackend, + kind: str, + at, + params: dict, + *, + rotation: float = 0.0, + layer: str | None = None, +) -> dict: + """Draw a standard feature onto geometry that already exists. + + ``at`` is a WCS point on the axis; ``rotation`` turns the local +X (along + the axis, in the direction the feature extends) counter-clockwise. ``layer`` + overrides the visible primitives only - CENTER and HIDDEN primitives keep + their own. Every refusal fires before the first entity is written. + """ + spec = feature_spec(kind, params) + placed = place_prims(spec["prims"], at, rotation) + handles: list[str] = [] + layers: list[str] = [] + for prim in placed: + target = layer if (layer and prim["layer"] == LAYER_VISIBLE) else prim["layer"] + if prim["type"] == "line": + info = await backend.entity_create_line( + prim["x1"], prim["y1"], prim["x2"], prim["y2"], layer=target + ) + elif prim["type"] == "circle": + info = await backend.entity_create_circle( + prim["cx"], prim["cy"], prim["r"], layer=target + ) + elif prim["type"] == "arc": + info = await backend.entity_create_arc( + prim["cx"], prim["cy"], prim["r"], prim["start_deg"], prim["end_deg"], layer=target + ) + else: + info = await backend.entity_create_polyline(prim["points"], prim["closed"], target) + handles.append(info.handle) + if target not in layers: + layers.append(target) + return { + "ok": True, + "kind": spec["kind"], + "handles": handles, + "primitive_count": len(handles), + "dims": spec["dims"], + "standard": spec["standard"], + "source": spec["source"], + "layers": layers, + "at": [float(at[0]), float(at[1])], + "rotation": float(rotation), + "backend": backend.name, + } diff --git a/server.py b/server.py index 1635d7e..db13456 100644 --- a/server.py +++ b/server.py @@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None: # entity/drawing tags. Tools whose tags match none fall into "other". _GROUP_TAG_PRIORITY = ( "pid", + "mech", "style", "pagesetup", "environment", @@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = ( # Map the winning tag to a human-readable group label for the breakdown. _GROUP_TAG_LABELS = { "pid": "pid", + "mech": "mech", "style": "styles", "pagesetup": "page_setup", "environment": "environment", @@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset( # mechanically should not pay for the P&ID surface, and a client that never # talks to a live seat should not pay for the environment surface. Styles and # page setup (SECTION 18/19) are drafting essentials and stay in core. -TOOL_PACK_NAMES = ("core", "pid", "settings") +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "pid": frozenset( { @@ -1002,6 +1004,14 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "system_prompt_message", } ), + # SECTION 22 - the standard-parts catalogue (tracks B+G). + "mech": frozenset( + { + "std_part_list", + "std_part_insert", + "std_feature_draw", + } + ), } _active_tool_profile: dict | None = None @@ -8123,6 +8133,197 @@ async def drawing_properties_set( return await _backend(ctx).drawing_properties_set(summary, custom) +# --------------------------------------------------------------------------- +# ── SECTION 22: Standard Parts (3 tools) ──────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.", + cost="read", +) +@mcp.tool( + annotations={"title": "Standard Parts: Catalogue", "readOnlyHint": True}, + tags={"mech", "query"}, +) +async def std_part_list( + query: Annotated[ + str | None, + Field( + default=None, + description=( + "Free text matched against designation, standard, size and family - " + "'M12', 'ISO 4014', '6205', 'washer'" + ), + ), + ] = None, + standard: Annotated[ + str | None, + Field( + default=None, + description="One of ISO 4014, ISO 4017, ISO 4032, ISO 7089, ISO 4762, ISO 15", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Catalogue rows with the dimensions a drawing needs, and their provenance. + + Every row is transcribed from the named standard's own table and carries + its `source`. Coverage is deliberately narrow: ISO 261 first-choice sizes + only (M5-M36, M3-M24 for ISO 4762) and ISO 15 bore numbers 00-10 in the + 600x / 620x / 630x series. A size outside a table is refused by name by + `std_part_insert` with the nearest entries, never interpolated. An unknown + `standard` is refused with the list of catalogue standards. + """ + from engineering.mech.stdparts import FAMILY_STANDARDS, catalogue + + rows = catalogue(query, standard) + return {"count": len(rows), "standards": list(FAMILY_STANDARDS), "parts": list(rows)} + + +@cad_tool( + summary=( + "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing " + "- as a tagged block." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "Standard Parts: Insert", "readOnlyHint": False}, + tags={"mech", "create"}, +) +async def std_part_insert( + designation: Annotated[ + str, + Field( + description=( + "'ISO 4014 - M12x60', 'ISO 4032 - M12', 'ISO 7089 - M12', " + "'ISO 4762 - M8x30', 'ISO 15 - 6205' (a bare '6205' also works)" + ) + ), + ], + x: Annotated[float, Field(description="Insertion X (WCS)")], + y: Annotated[float, Field(description="Insertion Y (WCS)")], + view: Annotated[ + str, + Field( + default="side", + description=( + "Fasteners: side | top. Bearings: simplified (ISO 8826-1) | detailed " + "(ISO 8826-2). The default 'side' falls back to the family's first view." + ), + ), + ] = "side", + rotation: Annotated[ + float, + Field(default=0.0, description="Degrees CCW; 0 puts the part's axis along +X"), + ] = 0.0, + layer: Annotated[ + str, + Field( + default="GEOMETRY", + description=( + "Layer for the INSERT; the block's own primitives keep GEOMETRY / CENTER / HIDDEN" + ), + ), + ] = "GEOMETRY", + material: Annotated[ + str | None, + Field( + default=None, + description="Property class or material for the MAT attribute and the XDATA, e.g. '8.8'", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Define the part's block once, insert it, and write its `std_part` XDATA. + + The block carries DESIG / STD / SIZE / MAT attributes and an ACADMCP_MECH + payload, so a parts list is a read of the drawing rather than a naming + convention. Refusals, all before any write: a designation outside the + transcribed tables (named, with the nearest catalogue entries - nothing is + drawn approximately), a malformed designation, a missing or stray nominal + length, a view the family does not have, an attribute tag the block does + not carry, and a non-finite coordinate or rotation. + """ + from engineering.mech.stdparts import insert_std_part + + await ctx.info(f"standard part {designation} ({view}) at ({x}, {y})") + return await insert_std_part( + _backend(ctx), + designation, + at=(x, y), + view=view, + rotation=rotation, + layer=layer, + material=material, + ) + + +@cad_tool( + summary=( + "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole " + "- onto existing geometry." + ), + cost="mutate", +) +@mcp.tool( + annotations={"title": "Standard Parts: Draw Feature", "readOnlyHint": False}, + tags={"mech", "create"}, +) +async def std_feature_draw( + kind: Annotated[ + str, + Field(description="thread | undercut | ring_groove | centre_hole | oring_groove"), + ], + x: Annotated[float, Field(description="Feature origin X (WCS): a point on the axis")], + y: Annotated[float, Field(description="Feature origin Y (WCS): a point on the axis")], + params: Annotated[ + dict, + Field( + description=( + "thread: {d, length, size|pitch, internal}; undercut: {d, form: E|F}; " + "ring_groove: {d, kind: shaft|bore}; centre_hole: {size, form: A|B}; " + "oring_groove: {d, cord, kind: shaft|bore}" + ) + ), + ], + rotation: Annotated[ + float, + Field( + default=0.0, + description="Degrees CCW applied to the local +X (along the axis, into the feature)", + ), + ] = 0.0, + layer: Annotated[ + str | None, + Field( + default=None, + description="Override the visible primitives' layer; CENTER and HIDDEN keep theirs", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Add a standard feature to geometry that already exists - a foreign drawing being finished. + + Local frame: `(x, y)` is on the axis, +X runs along the axis in the + direction the feature extends and +Y is radially outward. The thread is the + ISO 6410 representation (minor diameter at 0.8 x major, thread-length limit + to the major) and its pitch comes from ISO 261 via `size` or from an + explicit `pitch` - it is never guessed. Every other kind takes its + dimensions from the registered standards table and is refused by name when + that table is absent, when the size is outside its coverage, or when the row + lacks a dimension the feature needs: no value is interpolated and no profile + is reconstructed. DIN 332 form R is refused - only forms A and B are drawn. + """ + from engineering.mech.stdparts import draw_std_feature + + await ctx.info(f"standard feature {kind} at ({x}, {y})") + return await draw_std_feature( + _backend(ctx), kind, (x, y), params, rotation=rotation, layer=layer + ) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_mech_std_features.py b/tests/test_mech_std_features.py new file mode 100644 index 0000000..a156aea --- /dev/null +++ b/tests/test_mech_std_features.py @@ -0,0 +1,232 @@ +"""std_feature_draw: the ISO 6410 thread, and grooves drawn from registry rows. + +The DIN 471 / DIN 509 / DIN 332 / ISO 3601-2 tables themselves land with Task 3 +(group M). These tests register **synthetic** tables under obviously fake +standard names, so what is pinned here is that the feature draws exactly the +dimensions the registry publishes - never a number this module invented. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.standards import Coverage, register, standards +from engineering.mech.stdparts import ( + STD_FEATURE_KINDS, + draw_std_feature, + feature_prims, + feature_spec, + place_prims, +) + + +@pytest.fixture(scope="module", autouse=True) +def synthetic_tables(): + """Round, obviously synthetic rows - NOT DIN or ISO values.""" + if "TEST 471" in standards(): + return + register( + "TEST 471", + {30.0: {"m": 2.0, "d2": 27.0}}, + Coverage(8.0, 100.0), + "synthetic test fixture - the real DIN 471 table lands with Task 3", + ) + register( + "TEST 472", + {30.0: {"m": 2.0, "d2": 33.0}}, + Coverage(8.0, 100.0), + "synthetic test fixture - the real DIN 472 table lands with Task 3", + ) + register( + "TEST 509", + {30.0: {"profile": [[0.0, 0.0], [0.0, 0.4], [2.0, 0.4], [2.5, 0.0]]}}, + Coverage(18.0, 80.0), + "synthetic test fixture - the real DIN 509 table lands with Task 3", + ) + register( + "TEST 332 A", + {2.5: {"d1": 2.5, "d2": 5.3, "t": 5.0}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real DIN 332-1 table lands with Task 3", + ) + register( + "TEST 332 B", + {2.5: {"d1": 2.5, "d2": 5.3, "d3": 6.3, "t": 6.0}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real DIN 332-1 table lands with Task 3", + ) + register( + "TEST 3601", + {3.53: {"b": 4.7, "h": 2.7}}, + Coverage(1.0, 10.0), + "synthetic test fixture - the real ISO 3601-2 table lands with Task 3", + ) + + +def test_feature_kinds_are_the_five_the_spec_names(): + assert STD_FEATURE_KINDS == ( + "thread", + "undercut", + "ring_groove", + "centre_hole", + "oring_groove", + ) + with pytest.raises(ValueError, match="oring_groove"): + feature_prims("spline", {}) + + +def test_external_thread_is_the_iso6410_representation(): + spec = feature_spec("thread", {"d": 20.0, "length": 30.0, "size": "M20"}) + assert spec["dims"]["pitch"] == 2.5 # ISO 261 coarse, from the fastener table + assert "ISO 6410" in spec["source"] + assert spec["prims"] == ( + {"type": "line", "x1": 0.0, "y1": 8.0, "x2": 30.0, "y2": 8.0, "layer": "GEOMETRY"}, + {"type": "line", "x1": 0.0, "y1": -8.0, "x2": 30.0, "y2": -8.0, "layer": "GEOMETRY"}, + {"type": "line", "x1": 30.0, "y1": -10.0, "x2": 30.0, "y2": 10.0, "layer": "GEOMETRY"}, + ) + + +def test_internal_thread_draws_the_major_diameter_instead(): + prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5, "internal": True}) + assert prims[0]["y1"] == 10.0 and prims[1]["y1"] == -10.0 + + +def test_a_thread_without_a_size_or_a_pitch_is_refused(): + with pytest.raises(ValueError, match="never guessed"): + feature_prims("thread", {"d": 20.0, "length": 30.0}) + with pytest.raises(ValueError, match="ISO 261"): + feature_prims("thread", {"d": 14.0, "length": 30.0, "size": "M14"}) + + +def test_ring_groove_is_drawn_from_the_registry_row(): + prims = feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 471"}) + assert len(prims) == 6 + assert prims[0] == { + "type": "line", + "x1": 0.0, + "y1": 15.0, + "x2": 0.0, + "y2": 13.5, + "layer": "GEOMETRY", + } + assert prims[1] == { + "type": "line", + "x1": 0.0, + "y1": 13.5, + "x2": 2.0, + "y2": 13.5, + "layer": "GEOMETRY", + } + assert prims[3]["y1"] == -15.0 + bore = feature_prims("ring_groove", {"d": 30.0, "kind": "bore", "standard": "TEST 472"}) + assert bore[1]["y1"] == 16.5 + + +def test_ring_groove_refuses_a_row_that_is_the_wrong_way_round(): + with pytest.raises(ValueError, match="not a shaft groove"): + feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 472"}) + with pytest.raises(ValueError, match="DIN 471"): + feature_prims("ring_groove", {"d": 30.0, "kind": "shaft"}) + + +def test_oring_groove_is_a_rectangular_groove_from_the_row(): + prims = feature_prims( + "oring_groove", {"d": 30.0, "cord": 3.53, "kind": "shaft", "standard": "TEST 3601"} + ) + assert len(prims) == 6 + assert prims[0]["y1"] == 15.0 and prims[0]["y2"] == pytest.approx(12.3) + assert prims[1]["x2"] == pytest.approx(4.7) + + +def test_centre_hole_form_a_is_a_sixty_degree_countersink(): + spec = feature_spec("centre_hole", {"size": 2.5, "form": "A", "standard": "TEST 332 A"}) + cone = math.sqrt(3.0) * (5.3 - 2.5) / 2.0 + assert spec["dims"]["cone"] == pytest.approx(cone) + prims = spec["prims"] + assert len(prims) == 5 + assert prims[0] == { + "type": "line", + "x1": 0.0, + "y1": 2.65, + "x2": pytest.approx(cone), + "y2": 1.25, + "layer": "GEOMETRY", + } + assert prims[2]["x2"] == 5.0 and prims[2]["y1"] == 1.25 + assert prims[4] == { + "type": "line", + "x1": 5.0, + "y1": -1.25, + "x2": 5.0, + "y2": 1.25, + "layer": "GEOMETRY", + } + + +def test_centre_hole_form_b_adds_the_protecting_chamfer(): + prims = feature_prims("centre_hole", {"size": 2.5, "form": "B", "standard": "TEST 332 B"}) + chamfer = (6.3 - 5.3) / (2.0 * math.sqrt(3.0)) + assert len(prims) == 7 + assert prims[0]["y1"] == 3.15 # d3 / 2 + assert prims[0]["x2"] == pytest.approx(chamfer) + + +def test_centre_hole_form_r_is_refused_not_approximated(): + with pytest.raises(ValueError, match="forms A and B"): + feature_prims("centre_hole", {"size": 2.5, "form": "R", "standard": "TEST 332 A"}) + + +def test_undercut_draws_the_profile_the_table_publishes(): + prims = feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 509"}) + assert len(prims) == 2 + assert prims[0] == { + "type": "polyline", + "points": [[0.0, 15.0], [0.0, 14.6], [2.0, 14.6], [2.5, 15.0]], + "closed": False, + "layer": "GEOMETRY", + } + assert prims[1]["points"][1] == [0.0, -14.6] + + +def test_undercut_refuses_a_row_without_a_profile(): + with pytest.raises(ValueError, match="carries no profile"): + feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 471"}) + with pytest.raises(ValueError, match="DIN 509"): + feature_prims("undercut", {"d": 30.0, "form": "E"}) + + +def test_place_prims_rotates_about_the_insertion_point(): + prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5}) + placed = place_prims(prims, (100.0, 50.0), 90.0) + assert placed[0]["x1"] == pytest.approx(92.0) + assert placed[0]["y1"] == pytest.approx(50.0) + assert placed[0]["x2"] == pytest.approx(92.0) + assert placed[0]["y2"] == pytest.approx(80.0) + with pytest.raises(ValueError, match="finite"): + place_prims(prims, (float("inf"), 0.0)) + + +@pytest.mark.asyncio +async def test_draw_std_feature_writes_the_primitives(backend): + result = await draw_std_feature( + backend, "thread", (100.0, 50.0), {"d": 20.0, "length": 30.0, "size": "M20"} + ) + assert result["ok"] is True + assert result["primitive_count"] == 3 + assert len(result["handles"]) == 3 + assert result["layers"] == ["GEOMETRY"] + assert result["dims"]["pitch"] == 2.5 + info = await backend.entity_get(result["handles"][0]) + assert info.layer == "GEOMETRY" + + +@pytest.mark.asyncio +async def test_draw_std_feature_refuses_before_writing_anything(backend): + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="never guessed"): + await draw_std_feature(backend, "thread", (0.0, 0.0), {"d": 20.0, "length": 30.0}) + with pytest.raises(ValueError, match="DIN 509"): + await draw_std_feature(backend, "undercut", (0.0, 0.0), {"d": 30.0}) + assert len(await backend.entity_list()) == before diff --git a/tests/test_mech_stdparts.py b/tests/test_mech_stdparts.py new file mode 100644 index 0000000..ba41da0 --- /dev/null +++ b/tests/test_mech_stdparts.py @@ -0,0 +1,333 @@ +"""The standard-parts catalogue: search, designations, block specs, insertion. + +Geometry is asserted as emitted primitive dicts - counts, layers, coordinates - +never as a screenshot. +""" + +from __future__ import annotations + +import math + +import pytest + +from backends.block_specs import validate_attdef_specs, validate_entity_specs +from engineering.mech.stdparts import ( + FAMILY_STANDARDS, + FAMILY_VIEWS, + THREAD_MINOR_RATIO, + block_spec, + catalogue, + parse_designation, +) + +R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head +MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread + + +def test_the_iso6410_minor_radius_is_four_point_eight(): + """Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary.""" + assert MINOR12 == pytest.approx(4.8) + + +def test_the_catalogue_holds_every_transcribed_size(): + rows = catalogue() + assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings + assert len(catalogue(standard="ISO 15")) == 33 + assert len(catalogue("M12")) == 5 + assert len(catalogue("6205")) == 1 + assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS) + + +def test_a_catalogue_row_carries_what_a_drawing_needs(): + (row,) = catalogue("6205") + assert row["designation"] == "ISO 15 - 6205" + assert row["standard"] == "ISO 15" + assert row["family"] == "deep_groove_bearing" + assert row["views"] == ["simplified", "detailed"] + assert row["length_required"] is False + assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0} + assert "ISO 15:2017" in row["source"] + + bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12") + assert bolt["length_required"] is True + assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5 + + +def test_catalogue_refuses_an_unknown_standard(): + with pytest.raises(ValueError, match="ISO 4014"): + catalogue(standard="DIN 931") + + +def test_parse_designation_normalises_and_splits(): + assert parse_designation("iso4014 - m12x60") == { + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "family": "hex_bolt", + "size": "M12x60", + "thread": "M12", + "length": 60.0, + } + assert parse_designation("6205")["designation"] == "ISO 15 - 6205" + assert parse_designation("ISO 4032 - M12")["length"] is None + + +def test_parse_designation_refuses_a_missing_or_stray_length(): + with pytest.raises(ValueError, match="nominal length"): + parse_designation("ISO 4014 - M12") + with pytest.raises(ValueError, match="takes no length"): + parse_designation("ISO 4032 - M12x60") + with pytest.raises(ValueError, match="catalogue standard"): + parse_designation("ISO 9999 - M12") + + +def test_an_unknown_size_is_refused_with_the_nearest_entries(): + with pytest.raises(ValueError, match=r"ISO 4014 - M12"): + block_spec("ISO 4014 - M13x60", "side") + with pytest.raises(ValueError, match=r"ISO 15 - 6210"): + block_spec("ISO 15 - 6211", "simplified") + with pytest.raises(ValueError, match="approximately"): + block_spec("ISO 4014 - M13x60", "side") + + +def test_hex_bolt_side_view_is_the_iso6410_representation(): + spec = block_spec("ISO 4014 - M12x60", "side") + assert spec["name"] == "STD_ISO_4014_M12X60_SIDE" + assert spec["view"] == "side" + assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125 + ents = spec["entities"] + assert len(ents) == 10 + assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True + assert ents[0]["points"] == [ + [-7.5, -R12], + [0.0, -R12], + [0.0, R12], + [-7.5, R12], + ] + assert ents[3] == { + "type": "line", + "x1": 0.0, + "y1": 6.0, + "x2": 60.0, + "y2": 6.0, + "layer": "GEOMETRY", + } + # ISO 6410: minor diameter at 0.8 x major, over the useful thread length. + assert ents[6] == { + "type": "line", + "x1": 30.0, + "y1": MINOR12, + "x2": 60.0, + "y2": MINOR12, + "layer": "GEOMETRY", + } + # ISO 6410: the thread-length limit is a line to the major diameter. + assert ents[8] == { + "type": "line", + "x1": 30.0, + "y1": -6.0, + "x2": 30.0, + "y2": 6.0, + "layer": "GEOMETRY", + } + assert ents[9]["layer"] == "CENTER" + assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12)) + + +def test_a_fully_threaded_screw_has_no_thread_limit_line(): + spec = block_spec("ISO 4017 - M12x60", "side") + assert len(spec["entities"]) == 9 + assert spec["dims"]["b"] == pytest.approx(60.0) + minor = [e for e in spec["entities"] if e.get("y1") == MINOR12] + assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0 + + +def test_hex_top_view_uses_the_three_quarter_minor_arc(): + spec = block_spec("ISO 4014 - M12x60", "top") + ents = spec["entities"] + assert len(ents) == 5 + assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6 + assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"} + assert ents[2] == { + "type": "arc", + "cx": 0.0, + "cy": 0.0, + "r": MINOR12, + "start_deg": 90.0, + "end_deg": 360.0, + "layer": "GEOMETRY", + } + assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER" + + +def test_hex_nut_side_view_shows_the_thread_as_hidden_lines(): + spec = block_spec("ISO 4032 - M12", "side") + ents = spec["entities"] + assert len(ents) == 8 + hidden = [e for e in ents if e["layer"] == "HIDDEN"] + assert len(hidden) == 4 + assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12} + assert ents[0]["points"][1] == [10.8, -R12] # nut height m + + +def test_washer_views(): + side = block_spec("ISO 7089 - M12", "side") + assert len(side["entities"]) == 3 + assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]] + top = block_spec("ISO 7089 - M12", "top") + assert top["entities"][0] == { + "type": "circle", + "cx": 0.0, + "cy": 0.0, + "r": 12.0, + "layer": "GEOMETRY", + } + assert top["entities"][1]["r"] == 6.5 + + +def test_socket_head_screw_views(): + side = block_spec("ISO 4762 - M8x30", "side") + assert len(side["entities"]) == 8 + assert side["dims"]["b"] == pytest.approx(28.0) + assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5 + top = block_spec("ISO 4762 - M8x30", "top") + assert top["entities"][0]["r"] == 6.5 + assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6 + + +def test_bearing_simplified_is_the_iso8826_1_outline_and_cross(): + spec = block_spec("ISO 15 - 6205", "simplified") + ents = spec["entities"] + assert len(ents) == 7 + assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]] + assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]] + assert ents[2] == { + "type": "line", + "x1": -4.5, + "y1": 19.25, + "x2": 4.5, + "y2": 19.25, + "layer": "GEOMETRY", + } + assert ents[3] == { + "type": "line", + "x1": 0.0, + "y1": 15.2, + "x2": 0.0, + "y2": 23.3, + "layer": "GEOMETRY", + } + assert ents[6]["layer"] == "CENTER" + + +def test_bearing_detailed_draws_the_element_symbol(): + spec = block_spec("ISO 15 - 6205", "detailed") + ents = spec["entities"] + assert len(ents) == 5 + assert ents[2] == { + "type": "circle", + "cx": 0.0, + "cy": 19.25, + "r": 4.05, + "layer": "GEOMETRY", + } + assert ents[3]["cy"] == -19.25 + + +def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back(): + assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified" + with pytest.raises(ValueError, match="simplified"): + block_spec("ISO 15 - 6205", "top") + with pytest.raises(ValueError, match="side"): + block_spec("ISO 4014 - M12x60", "simplified") + + +@pytest.mark.parametrize( + ("designation", "view"), + [ + ("ISO 4014 - M12x60", "side"), + ("ISO 4014 - M12x60", "top"), + ("ISO 4017 - M12x60", "side"), + ("ISO 4032 - M12", "side"), + ("ISO 4032 - M12", "top"), + ("ISO 7089 - M12", "side"), + ("ISO 7089 - M12", "top"), + ("ISO 4762 - M8x30", "side"), + ("ISO 4762 - M8x30", "top"), + ("ISO 15 - 6205", "simplified"), + ("ISO 15 - 6205", "detailed"), + ], +) +def test_every_block_spec_validates_against_block_define(designation, view): + spec = block_spec(designation, view) + validate_entity_specs(spec["entities"]) + validate_attdef_specs(spec["attdefs"]) + assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"] + assert spec["attdefs"][0]["default"] == spec["designation"] + assert spec["attdefs"][0]["invisible"] is False + assert all(att["invisible"] for att in spec["attdefs"][1:]) + assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3] + assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]} + + +@pytest.mark.asyncio +async def test_insert_defines_the_block_once_and_writes_the_payload(backend): + from engineering.mech.stdparts import insert_std_part + from engineering.mech.xdata import APP_ID, decode + + result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8") + assert result["ok"] is True + assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE" + assert result["defined"] is True + assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"] + assert result["view"] == "side" + assert result["layer"] == "GEOMETRY" + assert result["dims"]["s"] == 18.0 + + stored = await backend.entity_get_xdata(result["handle"], APP_ID) + payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]]) + assert payload == { + "v": 1, + "kind": "std_part", + "designation": "ISO 4014 - M12x60", + "standard": "ISO 4014", + "size": "M12x60", + "qty": 1, + "material": "8.8", + } + assert await backend.block_get_attributes(result["handle"]) == { + "DESIG": "ISO 4014 - M12x60", + "STD": "ISO 4014", + "SIZE": "M12x60", + "MAT": "8.8", + } + + again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0)) + assert again["defined"] is False and again["layers_created"] == [] + assert again["payload"]["material"] == "" + + +@pytest.mark.asyncio +async def test_bearing_inserts_with_its_own_view_name(backend): + from engineering.mech.stdparts import insert_std_part + + result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0) + assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED" + assert result["view"] == "simplified" + assert result["rotation"] == 90.0 + assert result["dims"]["B"] == 15.0 + + +@pytest.mark.asyncio +async def test_insert_refuses_before_writing_anything(backend): + from engineering.mech.stdparts import insert_std_part + + before = len(await backend.entity_list()) + with pytest.raises(ValueError, match="Nearest entries"): + await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0)) + with pytest.raises(ValueError, match="finite"): + await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0)) + with pytest.raises(ValueError, match="no attribute"): + await insert_std_part( + backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"} + ) + assert len(await backend.entity_list()) == before diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py index fc370c2..98f01f4 100644 --- a/tests/test_tool_packs.py +++ b/tests/test_tool_packs.py @@ -5,6 +5,8 @@ v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools. Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment surface and nothing else. + +v1.6 tracks B+G add the `mech` pack: SECTION 22's three standard-parts tools. """ from __future__ import annotations @@ -78,6 +80,12 @@ SETTINGS_TOOLS = { "system_prompt_message", } +MECH_TOOLS = { + "std_part_list", + "std_part_insert", + "std_feature_draw", +} + LEAN_SETTINGS_ESSENTIALS = { "drawing_apply_standard", "page_setup_apply", @@ -86,7 +94,10 @@ LEAN_SETTINGS_ESSENTIALS = { "textstyle_set_current", } -ALL_PACKS = ["core", "pid", "settings"] +#: Declaration order - what `tool_packs["available"]` reports. +ALL_PACKS = ["core", "pid", "settings", "mech"] +#: `tool_packs["enabled"]` is sorted, which is no longer the same list. +ALL_PACKS_ENABLED = sorted(ALL_PACKS) @pytest_asyncio.fixture(autouse=True) @@ -97,11 +108,15 @@ async def _restore(monkeypatch): def test_pack_registry_names_real_tools_and_only_them(): - assert server.TOOL_PACK_NAMES == ("core", "pid", "settings") + assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech") assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS) + assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS) assert len(SETTINGS_TOOLS) == 22 + assert len(MECH_TOOLS) == 3 assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"]) + assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"]) + assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"]) async def test_every_pack_member_is_a_registered_tool(): @@ -117,6 +132,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup( disabled = set(info["disabled_tools"]) assert PID_TOOLS <= disabled assert SETTINGS_TOOLS <= disabled + assert MECH_TOOLS <= disabled assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials" assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core" assert "block_define" not in disabled and "entity_set_xdata" not in disabled @@ -136,8 +152,8 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch): monkeypatch.setattr(config.settings, "tool_packs", "all") info = await server._apply_tool_profile("full") disabled = set(info["disabled_tools"]) - assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled) - assert info["tool_packs"]["enabled"] == ALL_PACKS + assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS) & disabled) + assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( @@ -184,7 +200,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups(): assert set(groups["styles"]) == STYLE_TOOLS assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS assert set(groups["environment"]) == SETTINGS_TOOLS - assert len(groups) == 23 + assert set(groups["mech"]) == MECH_TOOLS + assert len(groups) == 24 async def test_system_about_reports_packs(): diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 4b55428..e4e7894 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -529,6 +529,13 @@ async def test_tool_group_sizes_are_unchanged(): their secondary tags; those counts returned to their pre-track-E values when the section tags landed.) Every other number here has been unchanged since the snapshot was taken. + + `mech` appeared (0 -> 3) when v1.6's SECTION 22 opened with the + standard-parts catalogue (`std_part_list` / `std_part_insert` / + `std_feature_draw`); the `mech` tag is ranked directly after `pid` in + `_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under + their secondary `query` / `create` tags. This snapshot is branch-local: + the merge task recomputes it once all four track B+G branches have landed. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged(): "environment": 22, "layers": 14, "layouts": 12, + "mech": 3, "page_setup": 6, "pid": 9, "premium": 12, @@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 204 + assert sum(sizes.values()) == 207 From bf8b3baf74f8cbcbe279c0f09ad54c7944bfb69b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 20:01:51 +0300 Subject: [PATCH 4/4] fix(task-9): put the fastener plan in projection with its elevation, and refuse a thread size that is not d Two wrong-drawing bugs in SECTION 22. 1. _hex_points defaulted to start_deg=0, putting vertices at 3 and 9 o'clock, so the plan of an M12 hex head measured 18 mm (across flats) across the vertical while the elevation drew that same head 20.78 mm tall (across corners e, silhouette edges at +/- e/4 - the projection of a hexagon with vertices top and bottom). Place the two blocks in projection and the two views of one part disagree. The default is now 30 deg, named HEX_START_DEG and documented on both the constant and _hex_points as the convention the elevation is the projection of, so a future fourth caller cannot pick the other one by accident. The three top builders (_hex_fastener_top, _hex_nut_top, _socket_head_top) inherit it. 2. _thread_feature read the pitch from params['size'] via the ISO 261 table while drawing the geometry from params['d'], never cross-checking the two. feature_spec('thread', {'d': 8.0, 'length': 20.0, 'size': 'M20'}) drew an M8 minor diameter and returned dims['pitch'] = 2.5 with ISO 261 cited as the source - one thread's pitch on another thread's geometry, a silent wrong number carrying a standards citation. A mismatch is now refused by name, like every other check in the module. std_feature_draw's params description says so. Guard tests: the old top-view test asserted only len(points) == 6 and never a vertex coordinate, so the orientation was untested. Added the vertex coordinates, the plan-vs-elevation silhouette-height invariant across ISO 4014 / 4017 / 4032, the socket screw's dk invariant, and the refused thread pair. No ActiveX member is added or changed by either fix; both are pure geometry and validation, so no live measurement is cited. The live AutoCAD could not be probed for a confirmation run - GetActiveObject blocked past 150 s under sibling contention. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/stdparts.py | 27 +++++++++++++++++- server.py | 4 ++- tests/test_mech_std_features.py | 8 ++++++ tests/test_mech_stdparts.py | 49 +++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/engineering/mech/stdparts.py b/engineering/mech/stdparts.py index 4f969c3..31ea527 100644 --- a/engineering/mech/stdparts.py +++ b/engineering/mech/stdparts.py @@ -49,6 +49,7 @@ from engineering.mech.standards.fasteners import ( across_corners, hex_head, hex_nut, + nominal_diameter, sizes, socket_head, thread_length, @@ -70,6 +71,12 @@ 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" ) @@ -143,7 +150,16 @@ def _poly(points, closed: bool = True, layer: str = LAYER_VISIBLE) -> dict: } -def _hex_points(radius: float, start_deg: float = 0.0): +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)), @@ -743,6 +759,15 @@ def _thread_feature(params: dict): 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") diff --git a/server.py b/server.py index db13456..e7298d4 100644 --- a/server.py +++ b/server.py @@ -8282,7 +8282,9 @@ async def std_feature_draw( dict, Field( description=( - "thread: {d, length, size|pitch, internal}; undercut: {d, form: E|F}; " + "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}" ) diff --git a/tests/test_mech_std_features.py b/tests/test_mech_std_features.py index a156aea..4744149 100644 --- a/tests/test_mech_std_features.py +++ b/tests/test_mech_std_features.py @@ -100,6 +100,14 @@ def test_a_thread_without_a_size_or_a_pitch_is_refused(): 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 diff --git a/tests/test_mech_stdparts.py b/tests/test_mech_stdparts.py index ba41da0..fdd1424 100644 --- a/tests/test_mech_stdparts.py +++ b/tests/test_mech_stdparts.py @@ -159,6 +159,55 @@ def test_hex_top_view_uses_the_three_quarter_minor_arc(): 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"]