merge: tracks B+G group S
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||
> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
|
||||
> **v1.5 release snapshot:** 213 tools · 8 resources · 5 prompt templates · 3284 collected tests.
|
||||
> 213 is the **registered** count; a default install advertises 208 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
## Why this exists
|
||||
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
||||
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
|
||||
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
|
||||
|
||||
<sub>210 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
<sub>213 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -1949,6 +1949,61 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"parca bilgisi",
|
||||
),
|
||||
),
|
||||
# ── Standard parts (SECTION 22, tracks B+G group S) ─────────────────────
|
||||
"std_part_list": ToolAliases(
|
||||
acad=(),
|
||||
synonyms=(
|
||||
"standard parts catalogue",
|
||||
"what bolts do you have",
|
||||
"bolt sizes",
|
||||
"hex bolt table",
|
||||
"bearing list",
|
||||
"iso 4014 sizes",
|
||||
"find a washer",
|
||||
"civata listesi",
|
||||
"rulman listesi",
|
||||
),
|
||||
),
|
||||
"std_part_insert": ToolAliases(
|
||||
acad=("INSERT",),
|
||||
synonyms=(
|
||||
"insert a bolt",
|
||||
"place a screw",
|
||||
"draw a nut",
|
||||
"add a washer",
|
||||
"put a bearing in",
|
||||
"hex bolt",
|
||||
"socket head cap screw",
|
||||
"allen screw",
|
||||
"deep groove ball bearing",
|
||||
"standard part",
|
||||
"civata",
|
||||
"somun",
|
||||
"rondela",
|
||||
"rulman",
|
||||
),
|
||||
),
|
||||
"std_feature_draw": ToolAliases(
|
||||
acad=(),
|
||||
synonyms=(
|
||||
"draw a thread",
|
||||
"thread representation",
|
||||
"undercut",
|
||||
"relief groove",
|
||||
"retaining ring groove",
|
||||
"circlip groove",
|
||||
"snap ring groove",
|
||||
"centre hole",
|
||||
"center drill",
|
||||
"o-ring groove",
|
||||
"din 509",
|
||||
"din 471",
|
||||
"din 332",
|
||||
"vida disi",
|
||||
"segman kanali",
|
||||
"punta deligi",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 210,
|
||||
"tools": 213,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 24
|
||||
@@ -22,7 +22,7 @@
|
||||
"environment": 22,
|
||||
"layers": 14,
|
||||
"layouts": 12,
|
||||
"mech": 6,
|
||||
"mech": 9,
|
||||
"page_setup": 6,
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
@@ -2418,6 +2418,48 @@
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "std_feature_draw",
|
||||
"group": "mech",
|
||||
"cost": "mutate",
|
||||
"summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.",
|
||||
"tags": [
|
||||
"create",
|
||||
"mech"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Standard Parts: Draw Feature",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "std_part_insert",
|
||||
"group": "mech",
|
||||
"cost": "mutate",
|
||||
"summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.",
|
||||
"tags": [
|
||||
"create",
|
||||
"mech"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Standard Parts: Insert",
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "std_part_list",
|
||||
"group": "mech",
|
||||
"cost": "read",
|
||||
"summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
|
||||
"tags": [
|
||||
"mech",
|
||||
"query"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Standard Parts: Catalogue",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "system_about",
|
||||
"group": "system",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1004,7 +1004,8 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
||||
"system_prompt_message",
|
||||
}
|
||||
),
|
||||
# SECTION 21 - the mechanical part model and its view engine (tracks B+G).
|
||||
# SECTIONS 21-23 - the mechanical part model, standard parts and
|
||||
# mechanical annotation (tracks B+G).
|
||||
"mech": frozenset(
|
||||
{
|
||||
"mech_part_draw",
|
||||
@@ -1013,6 +1014,9 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
||||
"mech_hole_pattern",
|
||||
"mech_part_from_spec",
|
||||
"mech_part_inspect",
|
||||
"std_part_list",
|
||||
"std_part_insert",
|
||||
"std_feature_draw",
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -8567,6 +8571,199 @@ async def mech_part_inspect(
|
||||
raise ToolError(str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 22: Standard Parts (3 tools) ────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
|
||||
cost="read",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Standard Parts: Catalogue", "readOnlyHint": True},
|
||||
tags={"mech", "query"},
|
||||
)
|
||||
async def std_part_list(
|
||||
query: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Free text matched against designation, standard, size and family - "
|
||||
"'M12', 'ISO 4014', '6205', 'washer'"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
standard: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="One of ISO 4014, ISO 4017, ISO 4032, ISO 7089, ISO 4762, ISO 15",
|
||||
),
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Catalogue rows with the dimensions a drawing needs, and their provenance.
|
||||
|
||||
Every row is transcribed from the named standard's own table and carries
|
||||
its `source`. Coverage is deliberately narrow: ISO 261 first-choice sizes
|
||||
only (M5-M36, M3-M24 for ISO 4762) and ISO 15 bore numbers 00-10 in the
|
||||
600x / 620x / 630x series. A size outside a table is refused by name by
|
||||
`std_part_insert` with the nearest entries, never interpolated. An unknown
|
||||
`standard` is refused with the list of catalogue standards.
|
||||
"""
|
||||
from engineering.mech.stdparts import FAMILY_STANDARDS, catalogue
|
||||
|
||||
rows = catalogue(query, standard)
|
||||
return {"count": len(rows), "standards": list(FAMILY_STANDARDS), "parts": list(rows)}
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary=(
|
||||
"Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing "
|
||||
"- as a tagged block."
|
||||
),
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Standard Parts: Insert", "readOnlyHint": False},
|
||||
tags={"mech", "create"},
|
||||
)
|
||||
async def std_part_insert(
|
||||
designation: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"'ISO 4014 - M12x60', 'ISO 4032 - M12', 'ISO 7089 - M12', "
|
||||
"'ISO 4762 - M8x30', 'ISO 15 - 6205' (a bare '6205' also works)"
|
||||
)
|
||||
),
|
||||
],
|
||||
x: Annotated[float, Field(description="Insertion X (WCS)")],
|
||||
y: Annotated[float, Field(description="Insertion Y (WCS)")],
|
||||
view: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="side",
|
||||
description=(
|
||||
"Fasteners: side | top. Bearings: simplified (ISO 8826-1) | detailed "
|
||||
"(ISO 8826-2). The default 'side' falls back to the family's first view."
|
||||
),
|
||||
),
|
||||
] = "side",
|
||||
rotation: Annotated[
|
||||
float,
|
||||
Field(default=0.0, description="Degrees CCW; 0 puts the part's axis along +X"),
|
||||
] = 0.0,
|
||||
layer: Annotated[
|
||||
str,
|
||||
Field(
|
||||
default="GEOMETRY",
|
||||
description=(
|
||||
"Layer for the INSERT; the block's own primitives keep GEOMETRY / CENTER / HIDDEN"
|
||||
),
|
||||
),
|
||||
] = "GEOMETRY",
|
||||
material: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Property class or material for the MAT attribute and the XDATA, e.g. '8.8'",
|
||||
),
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Define the part's block once, insert it, and write its `std_part` XDATA.
|
||||
|
||||
The block carries DESIG / STD / SIZE / MAT attributes and an ACADMCP_MECH
|
||||
payload, so a parts list is a read of the drawing rather than a naming
|
||||
convention. Refusals, all before any write: a designation outside the
|
||||
transcribed tables (named, with the nearest catalogue entries - nothing is
|
||||
drawn approximately), a malformed designation, a missing or stray nominal
|
||||
length, a view the family does not have, an attribute tag the block does
|
||||
not carry, and a non-finite coordinate or rotation.
|
||||
"""
|
||||
from engineering.mech.stdparts import insert_std_part
|
||||
|
||||
await ctx.info(f"standard part {designation} ({view}) at ({x}, {y})")
|
||||
return await insert_std_part(
|
||||
_backend(ctx),
|
||||
designation,
|
||||
at=(x, y),
|
||||
view=view,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
material=material,
|
||||
)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary=(
|
||||
"Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole "
|
||||
"- onto existing geometry."
|
||||
),
|
||||
cost="mutate",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Standard Parts: Draw Feature", "readOnlyHint": False},
|
||||
tags={"mech", "create"},
|
||||
)
|
||||
async def std_feature_draw(
|
||||
kind: Annotated[
|
||||
str,
|
||||
Field(description="thread | undercut | ring_groove | centre_hole | oring_groove"),
|
||||
],
|
||||
x: Annotated[float, Field(description="Feature origin X (WCS): a point on the axis")],
|
||||
y: Annotated[float, Field(description="Feature origin Y (WCS): a point on the axis")],
|
||||
params: Annotated[
|
||||
dict,
|
||||
Field(
|
||||
description=(
|
||||
"thread: {d, length, size|pitch, internal} (a size must name d's own "
|
||||
"thread - 'M20' with d=8 is refused, not drawn); "
|
||||
"undercut: {d, form: E|F}; "
|
||||
"ring_groove: {d, kind: shaft|bore}; centre_hole: {size, form: A|B}; "
|
||||
"oring_groove: {d, cord, kind: shaft|bore}"
|
||||
)
|
||||
),
|
||||
],
|
||||
rotation: Annotated[
|
||||
float,
|
||||
Field(
|
||||
default=0.0,
|
||||
description="Degrees CCW applied to the local +X (along the axis, into the feature)",
|
||||
),
|
||||
] = 0.0,
|
||||
layer: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Override the visible primitives' layer; CENTER and HIDDEN keep theirs",
|
||||
),
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Add a standard feature to geometry that already exists - a foreign drawing being finished.
|
||||
|
||||
Local frame: `(x, y)` is on the axis, +X runs along the axis in the
|
||||
direction the feature extends and +Y is radially outward. The thread is the
|
||||
ISO 6410 representation (minor diameter at 0.8 x major, thread-length limit
|
||||
to the major) and its pitch comes from ISO 261 via `size` or from an
|
||||
explicit `pitch` - it is never guessed. Every other kind takes its
|
||||
dimensions from the registered standards table and is refused by name when
|
||||
that table is absent, when the size is outside its coverage, or when the row
|
||||
lacks a dimension the feature needs: no value is interpolated and no profile
|
||||
is reconstructed. DIN 332 form R is refused - only forms A and B are drawn.
|
||||
"""
|
||||
from engineering.mech.stdparts import draw_std_feature
|
||||
|
||||
await ctx.info(f"standard feature {kind} at ({x}, {y})")
|
||||
return await draw_std_feature(
|
||||
_backend(ctx), kind, (x, y), params, rotation=rotation, layer=layer
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""ISO 15 boundary dimensions and the ISO 8826 representation conventions.
|
||||
|
||||
``ISO15_ROWS`` below pins d, D and B for *every* one of the 33 shipped
|
||||
designations, and a completeness test asserts its key set equals the module's.
|
||||
Inequality tests (series growth, the bore-number rule) constrain a row; only a
|
||||
literal pins it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.standards import standards
|
||||
from engineering.mech.standards.bearings import (
|
||||
BALL_SYMBOL_FRACTION,
|
||||
BEARING_SERIES,
|
||||
CROSS_FRACTION,
|
||||
DEEP_GROOVE,
|
||||
SOURCE_ISO_15,
|
||||
SOURCE_ISO_8826_1,
|
||||
SOURCE_ISO_8826_2,
|
||||
bearing,
|
||||
list_bearings,
|
||||
)
|
||||
|
||||
#: ISO 15:2017 Table 4 - every transcribed designation, (d, D, B) in mm.
|
||||
ISO15_ROWS: dict[str, tuple[float, float, float]] = {
|
||||
# dimension series 10 (600x)
|
||||
"6000": (10.0, 26.0, 8.0),
|
||||
"6001": (12.0, 28.0, 8.0),
|
||||
"6002": (15.0, 32.0, 9.0),
|
||||
"6003": (17.0, 35.0, 10.0),
|
||||
"6004": (20.0, 42.0, 12.0),
|
||||
"6005": (25.0, 47.0, 12.0),
|
||||
"6006": (30.0, 55.0, 13.0),
|
||||
"6007": (35.0, 62.0, 14.0),
|
||||
"6008": (40.0, 68.0, 15.0),
|
||||
"6009": (45.0, 75.0, 16.0),
|
||||
"6010": (50.0, 80.0, 16.0),
|
||||
# dimension series 02 (620x)
|
||||
"6200": (10.0, 30.0, 9.0),
|
||||
"6201": (12.0, 32.0, 10.0),
|
||||
"6202": (15.0, 35.0, 11.0),
|
||||
"6203": (17.0, 40.0, 12.0),
|
||||
"6204": (20.0, 47.0, 14.0),
|
||||
"6205": (25.0, 52.0, 15.0),
|
||||
"6206": (30.0, 62.0, 16.0),
|
||||
"6207": (35.0, 72.0, 17.0),
|
||||
"6208": (40.0, 80.0, 18.0),
|
||||
"6209": (45.0, 85.0, 19.0),
|
||||
"6210": (50.0, 90.0, 20.0),
|
||||
# dimension series 03 (630x)
|
||||
"6300": (10.0, 35.0, 11.0),
|
||||
"6301": (12.0, 37.0, 12.0),
|
||||
"6302": (15.0, 42.0, 13.0),
|
||||
"6303": (17.0, 47.0, 14.0),
|
||||
"6304": (20.0, 52.0, 15.0),
|
||||
"6305": (25.0, 62.0, 17.0),
|
||||
"6306": (30.0, 72.0, 19.0),
|
||||
"6307": (35.0, 80.0, 21.0),
|
||||
"6308": (40.0, 90.0, 23.0),
|
||||
"6309": (45.0, 100.0, 25.0),
|
||||
"6310": (50.0, 110.0, 27.0),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("designation", "expected"), sorted(ISO15_ROWS.items()))
|
||||
def test_named_rows_match_iso15(designation, expected):
|
||||
row = bearing(designation)
|
||||
assert (row["d"], row["D"], row["B"]) == expected
|
||||
assert row["designation"] == designation
|
||||
assert row["source"] == SOURCE_ISO_15
|
||||
|
||||
|
||||
def test_every_shipped_bearing_is_pinned_by_a_literal():
|
||||
"""No shipped designation may escape the table above - that is where a wrong row hides."""
|
||||
assert set(DEEP_GROOVE) == set(ISO15_ROWS)
|
||||
|
||||
|
||||
def test_bore_number_rule_holds_across_every_series():
|
||||
"""ISO 15: bore 00/01/02/03 = 10/12/15/17 mm; 04 and up = 5 x bore number."""
|
||||
for series in ("60", "62", "63"):
|
||||
assert bearing(f"{series}00")["d"] == 10.0
|
||||
assert bearing(f"{series}01")["d"] == 12.0
|
||||
assert bearing(f"{series}02")["d"] == 15.0
|
||||
assert bearing(f"{series}03")["d"] == 17.0
|
||||
for n in range(4, 11):
|
||||
assert bearing(f"{series}{n:02d}")["d"] == 5.0 * n
|
||||
|
||||
|
||||
def test_outside_diameter_and_width_grow_with_the_series():
|
||||
for n in range(0, 11):
|
||||
light = bearing(f"60{n:02d}")
|
||||
medium = bearing(f"62{n:02d}")
|
||||
heavy = bearing(f"63{n:02d}")
|
||||
assert light["d"] == medium["d"] == heavy["d"]
|
||||
assert light["D"] < medium["D"] < heavy["D"]
|
||||
assert light["B"] < medium["B"] < heavy["B"]
|
||||
|
||||
|
||||
def test_coverage_refusals_name_the_table():
|
||||
with pytest.raises(ValueError, match="ISO 15"):
|
||||
bearing("6011")
|
||||
with pytest.raises(ValueError, match="ISO 15"):
|
||||
bearing("6211")
|
||||
with pytest.raises(ValueError, match="ISO 15"):
|
||||
bearing("6311")
|
||||
with pytest.raises(ValueError, match="ISO 15"):
|
||||
bearing("61805") # the 618x light series is not transcribed
|
||||
with pytest.raises(ValueError, match="ISO 15"):
|
||||
bearing("6205-2RS") # a seal suffix is a variant this table does not carry
|
||||
|
||||
|
||||
def test_representation_fractions_are_declared_conventions_not_table_values():
|
||||
assert 0.0 < CROSS_FRACTION < 1.0
|
||||
assert 0.0 < BALL_SYMBOL_FRACTION < 1.0
|
||||
assert "ISO 8826-1" in SOURCE_ISO_8826_1
|
||||
assert "ISO 8826-2" in SOURCE_ISO_8826_2
|
||||
assert "ISO 15:2017" in SOURCE_ISO_15
|
||||
|
||||
|
||||
def test_the_element_symbol_always_fits_inside_the_ring_rectangle():
|
||||
"""The declared fractions must never draw a symbol outside the envelope."""
|
||||
for name in DEEP_GROOVE:
|
||||
row = bearing(name)
|
||||
half_width = row["B"] / 2.0
|
||||
half_gap = (row["D"] - row["d"]) / 4.0
|
||||
assert BALL_SYMBOL_FRACTION * half_gap < half_width
|
||||
assert CROSS_FRACTION * half_width < half_width
|
||||
assert CROSS_FRACTION * half_gap < half_gap
|
||||
|
||||
|
||||
def test_list_bearings_filters_by_series():
|
||||
assert len(DEEP_GROOVE) == 33
|
||||
assert len(list_bearings()) == 33
|
||||
assert BEARING_SERIES == ("600x", "620x", "630x")
|
||||
assert {row["designation"] for row in list_bearings("620x")} == {
|
||||
f"62{n:02d}" for n in range(11)
|
||||
}
|
||||
with pytest.raises(ValueError, match="620x"):
|
||||
list_bearings("622x")
|
||||
|
||||
|
||||
def test_the_table_registers_itself():
|
||||
assert "ISO 15" in standards()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Provenance and coverage of the ISO fastener tables (the table rule, spec 5).
|
||||
|
||||
Every value asserted here is a named row of a named table, and the converse holds
|
||||
too: the ``ISO*_ROWS`` literals below carry *every* dimension of *every* shipped
|
||||
size, and a completeness test asserts each table's key set equals its literal's.
|
||||
A row nobody pinned is exactly where a wrong across-flats would hide. A row that
|
||||
could not be verified against the standard is not in the module at all, so the
|
||||
coverage refusals below are as load-bearing as the values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.standards import standards
|
||||
from engineering.mech.standards.fasteners import (
|
||||
COARSE_PITCH,
|
||||
HEX_HEAD,
|
||||
HEX_NUT,
|
||||
SOCKET_HEAD,
|
||||
SOURCE_ISO_4014,
|
||||
WASHER,
|
||||
across_corners,
|
||||
hex_head,
|
||||
hex_nut,
|
||||
nominal_diameter,
|
||||
sizes,
|
||||
socket_head,
|
||||
thread_length,
|
||||
thread_pitch,
|
||||
washer,
|
||||
)
|
||||
|
||||
#: ISO 4014:2011 / ISO 4017:2014 Table 1 - every transcribed size, (s, k).
|
||||
ISO4014_ROWS: dict[str, tuple[float, float]] = {
|
||||
"M5": (8.0, 3.5),
|
||||
"M6": (10.0, 4.0),
|
||||
"M8": (13.0, 5.3),
|
||||
"M10": (16.0, 6.4),
|
||||
"M12": (18.0, 7.5),
|
||||
"M16": (24.0, 10.0),
|
||||
"M20": (30.0, 12.5),
|
||||
"M24": (36.0, 15.0),
|
||||
"M30": (46.0, 18.7),
|
||||
"M36": (55.0, 22.5),
|
||||
}
|
||||
|
||||
#: ISO 4032:2012 Table 1 - every transcribed size, (s, m).
|
||||
ISO4032_ROWS: dict[str, tuple[float, float]] = {
|
||||
"M5": (8.0, 4.7),
|
||||
"M6": (10.0, 5.2),
|
||||
"M8": (13.0, 6.8),
|
||||
"M10": (16.0, 8.4),
|
||||
"M12": (18.0, 10.8),
|
||||
"M16": (24.0, 14.8),
|
||||
"M20": (30.0, 18.0),
|
||||
"M24": (36.0, 21.5),
|
||||
"M30": (46.0, 25.6),
|
||||
"M36": (55.0, 31.0),
|
||||
}
|
||||
|
||||
#: ISO 7089:2000 Table 1 - every transcribed size, (d1, d2, h).
|
||||
ISO7089_ROWS: dict[str, tuple[float, float, float]] = {
|
||||
"M5": (5.3, 10.0, 1.0),
|
||||
"M6": (6.4, 12.0, 1.6),
|
||||
"M8": (8.4, 16.0, 1.6),
|
||||
"M10": (10.5, 20.0, 2.0),
|
||||
"M12": (13.0, 24.0, 2.5),
|
||||
"M16": (17.0, 30.0, 3.0),
|
||||
"M20": (21.0, 37.0, 3.0),
|
||||
"M24": (25.0, 44.0, 4.0),
|
||||
"M30": (31.0, 56.0, 4.0),
|
||||
"M36": (37.0, 66.0, 5.0),
|
||||
}
|
||||
|
||||
#: ISO 4762:2004 Table 1 - every transcribed size, (dk, k, s, b).
|
||||
ISO4762_ROWS: dict[str, tuple[float, float, float, float]] = {
|
||||
"M3": (5.5, 3.0, 2.5, 18.0),
|
||||
"M4": (7.0, 4.0, 3.0, 20.0),
|
||||
"M5": (8.5, 5.0, 4.0, 22.0),
|
||||
"M6": (10.0, 6.0, 5.0, 24.0),
|
||||
"M8": (13.0, 8.0, 6.0, 28.0),
|
||||
"M10": (16.0, 10.0, 8.0, 32.0),
|
||||
"M12": (18.0, 12.0, 10.0, 36.0),
|
||||
"M16": (24.0, 16.0, 14.0, 44.0),
|
||||
"M20": (30.0, 20.0, 17.0, 52.0),
|
||||
"M24": (36.0, 24.0, 19.0, 60.0),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("size", "expected"), sorted(ISO4014_ROWS.items()))
|
||||
def test_iso4014_named_rows_match_the_standard(size, expected):
|
||||
"""ISO 4014:2011 Table 1 - width across flats s and head height k."""
|
||||
row = hex_head(size)
|
||||
assert (row["s"], row["k"]) == expected
|
||||
|
||||
|
||||
def test_every_iso4014_row_is_pinned_by_a_literal():
|
||||
"""No shipped size may escape the table above - that is where a wrong row hides."""
|
||||
assert set(HEX_HEAD) == set(ISO4014_ROWS)
|
||||
assert sizes("ISO 4014") == tuple(HEX_HEAD)
|
||||
|
||||
|
||||
def test_iso4017_shares_the_iso4014_head():
|
||||
"""ISO 4017 screws and ISO 4014 bolts have the same head at every size here."""
|
||||
assert sizes("ISO 4017") == sizes("ISO 4014")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("size", "expected"), sorted(ISO4032_ROWS.items()))
|
||||
def test_iso4032_named_rows_match_the_standard(size, expected):
|
||||
"""ISO 4032:2012 Table 1 - width across flats s and nut height m (max)."""
|
||||
row = hex_nut(size)
|
||||
assert (row["s"], row["m"]) == expected
|
||||
|
||||
|
||||
def test_every_iso4032_row_is_pinned_and_shares_the_bolt_across_flats():
|
||||
assert set(HEX_NUT) == set(ISO4032_ROWS)
|
||||
for size in HEX_NUT:
|
||||
assert hex_nut(size)["s"] == hex_head(size)["s"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("size", "expected"), sorted(ISO7089_ROWS.items()))
|
||||
def test_iso7089_named_rows_match_the_standard(size, expected):
|
||||
"""ISO 7089:2000 Table 1 - d1, d2, h (nominal), normal series, grade A."""
|
||||
row = washer(size)
|
||||
assert (row["d1"], row["d2"], row["h"]) == expected
|
||||
|
||||
|
||||
def test_every_iso7089_row_is_pinned_by_a_literal():
|
||||
assert set(WASHER) == set(ISO7089_ROWS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("size", "expected"), sorted(ISO4762_ROWS.items()))
|
||||
def test_iso4762_named_rows_match_the_standard(size, expected):
|
||||
"""ISO 4762:2004 Table 1 - head dk (max), head height k (max), socket s, thread length b."""
|
||||
row = socket_head(size)
|
||||
assert (row["dk"], row["k"], row["s"], row["b"]) == expected
|
||||
|
||||
|
||||
def test_every_iso4762_row_is_pinned_and_its_head_height_equals_the_thread_diameter():
|
||||
assert set(SOCKET_HEAD) == set(ISO4762_ROWS)
|
||||
# ISO 4762 head height equals the nominal thread diameter at every size here.
|
||||
for size in SOCKET_HEAD:
|
||||
assert socket_head(size)["k"] == nominal_diameter(size)
|
||||
|
||||
|
||||
def test_iso261_coarse_pitch_column():
|
||||
assert COARSE_PITCH == {
|
||||
"M3": 0.5,
|
||||
"M4": 0.7,
|
||||
"M5": 0.8,
|
||||
"M6": 1.0,
|
||||
"M8": 1.25,
|
||||
"M10": 1.5,
|
||||
"M12": 1.75,
|
||||
"M16": 2.0,
|
||||
"M20": 2.5,
|
||||
"M24": 3.0,
|
||||
"M30": 3.5,
|
||||
"M36": 4.0,
|
||||
}
|
||||
assert thread_pitch("M12") == 1.75
|
||||
assert nominal_diameter("M12") == 12.0
|
||||
|
||||
|
||||
def test_coverage_is_refused_at_both_ends_by_name():
|
||||
with pytest.raises(ValueError, match="ISO 4014"):
|
||||
hex_head("M3")
|
||||
with pytest.raises(ValueError, match="ISO 4014"):
|
||||
hex_head("M42")
|
||||
with pytest.raises(ValueError, match="ISO 4032"):
|
||||
hex_nut("M42")
|
||||
with pytest.raises(ValueError, match="ISO 7089"):
|
||||
washer("M3")
|
||||
with pytest.raises(ValueError, match="ISO 4762"):
|
||||
socket_head("M2")
|
||||
with pytest.raises(ValueError, match="ISO 4762"):
|
||||
socket_head("M30")
|
||||
|
||||
|
||||
def test_second_choice_sizes_are_absent_not_interpolated():
|
||||
for absent in ("M14", "M18", "M22", "M27", "M33"):
|
||||
with pytest.raises(ValueError):
|
||||
hex_head(absent)
|
||||
with pytest.raises(ValueError):
|
||||
hex_nut(absent)
|
||||
|
||||
|
||||
def test_thread_length_follows_the_standards_own_b_rule():
|
||||
assert thread_length("M12", 60.0) == pytest.approx(30.0) # 2d + 6, l <= 125
|
||||
assert thread_length("M12", 150.0) == pytest.approx(36.0) # 2d + 12, 125 < l <= 200
|
||||
assert thread_length("M12", 260.0) == pytest.approx(49.0) # 2d + 25, l > 200
|
||||
assert thread_length("M12", 25.0) == pytest.approx(25.0) # never longer than the bolt
|
||||
assert thread_length("M12", 60.0, "ISO 4017") == pytest.approx(60.0)
|
||||
assert thread_length("M8", 30.0, "ISO 4762") == pytest.approx(28.0)
|
||||
assert thread_length("M8", 20.0, "ISO 4762") == pytest.approx(20.0)
|
||||
with pytest.raises(ValueError):
|
||||
thread_length("M12", 0.0)
|
||||
with pytest.raises(ValueError):
|
||||
thread_length("M12", float("inf"))
|
||||
with pytest.raises(ValueError, match="ISO 4014"):
|
||||
thread_length("M12", 60.0, "DIN 931")
|
||||
|
||||
|
||||
def test_across_corners_is_derived_geometry_not_a_transcribed_column():
|
||||
assert across_corners(18.0) == pytest.approx(2.0 * 18.0 / math.sqrt(3.0))
|
||||
assert across_corners(18.0) == pytest.approx(20.7846, abs=1e-4)
|
||||
with pytest.raises(ValueError):
|
||||
across_corners(0.0)
|
||||
|
||||
|
||||
def test_every_table_registers_itself_with_the_shared_registry():
|
||||
for name in ("ISO 4014", "ISO 4017", "ISO 4032", "ISO 7089", "ISO 4762"):
|
||||
assert name in standards()
|
||||
assert "ISO 4014:2011" in SOURCE_ISO_4014
|
||||
|
||||
|
||||
def test_sizes_refuses_a_standard_this_module_does_not_carry():
|
||||
assert sizes("ISO 4014")[0] == "M5"
|
||||
with pytest.raises(ValueError, match="ISO 4014"):
|
||||
sizes("ISO 15")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""std_feature_draw: the ISO 6410 thread, and grooves drawn from registry rows.
|
||||
|
||||
The DIN 471 / DIN 509 / DIN 332 / ISO 3601-2 tables themselves land with Task 3
|
||||
(group M). These tests register **synthetic** tables under obviously fake
|
||||
standard names, so what is pinned here is that the feature draws exactly the
|
||||
dimensions the registry publishes - never a number this module invented.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.standards import Coverage, register, standards
|
||||
from engineering.mech.stdparts import (
|
||||
STD_FEATURE_KINDS,
|
||||
draw_std_feature,
|
||||
feature_prims,
|
||||
feature_spec,
|
||||
place_prims,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def synthetic_tables():
|
||||
"""Round, obviously synthetic rows - NOT DIN or ISO values."""
|
||||
if "TEST 471" in standards():
|
||||
return
|
||||
register(
|
||||
"TEST 471",
|
||||
{30.0: {"m": 2.0, "d2": 27.0}},
|
||||
Coverage(8.0, 100.0),
|
||||
"synthetic test fixture - the real DIN 471 table lands with Task 3",
|
||||
)
|
||||
register(
|
||||
"TEST 472",
|
||||
{30.0: {"m": 2.0, "d2": 33.0}},
|
||||
Coverage(8.0, 100.0),
|
||||
"synthetic test fixture - the real DIN 472 table lands with Task 3",
|
||||
)
|
||||
register(
|
||||
"TEST 509",
|
||||
{30.0: {"profile": [[0.0, 0.0], [0.0, 0.4], [2.0, 0.4], [2.5, 0.0]]}},
|
||||
Coverage(18.0, 80.0),
|
||||
"synthetic test fixture - the real DIN 509 table lands with Task 3",
|
||||
)
|
||||
register(
|
||||
"TEST 332 A",
|
||||
{2.5: {"d1": 2.5, "d2": 5.3, "t": 5.0}},
|
||||
Coverage(1.0, 10.0),
|
||||
"synthetic test fixture - the real DIN 332-1 table lands with Task 3",
|
||||
)
|
||||
register(
|
||||
"TEST 332 B",
|
||||
{2.5: {"d1": 2.5, "d2": 5.3, "d3": 6.3, "t": 6.0}},
|
||||
Coverage(1.0, 10.0),
|
||||
"synthetic test fixture - the real DIN 332-1 table lands with Task 3",
|
||||
)
|
||||
register(
|
||||
"TEST 3601",
|
||||
{3.53: {"b": 4.7, "h": 2.7}},
|
||||
Coverage(1.0, 10.0),
|
||||
"synthetic test fixture - the real ISO 3601-2 table lands with Task 3",
|
||||
)
|
||||
|
||||
|
||||
def test_feature_kinds_are_the_five_the_spec_names():
|
||||
assert STD_FEATURE_KINDS == (
|
||||
"thread",
|
||||
"undercut",
|
||||
"ring_groove",
|
||||
"centre_hole",
|
||||
"oring_groove",
|
||||
)
|
||||
with pytest.raises(ValueError, match="oring_groove"):
|
||||
feature_prims("spline", {})
|
||||
|
||||
|
||||
def test_external_thread_is_the_iso6410_representation():
|
||||
spec = feature_spec("thread", {"d": 20.0, "length": 30.0, "size": "M20"})
|
||||
assert spec["dims"]["pitch"] == 2.5 # ISO 261 coarse, from the fastener table
|
||||
assert "ISO 6410" in spec["source"]
|
||||
assert spec["prims"] == (
|
||||
{"type": "line", "x1": 0.0, "y1": 8.0, "x2": 30.0, "y2": 8.0, "layer": "GEOMETRY"},
|
||||
{"type": "line", "x1": 0.0, "y1": -8.0, "x2": 30.0, "y2": -8.0, "layer": "GEOMETRY"},
|
||||
{"type": "line", "x1": 30.0, "y1": -10.0, "x2": 30.0, "y2": 10.0, "layer": "GEOMETRY"},
|
||||
)
|
||||
|
||||
|
||||
def test_internal_thread_draws_the_major_diameter_instead():
|
||||
prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5, "internal": True})
|
||||
assert prims[0]["y1"] == 10.0 and prims[1]["y1"] == -10.0
|
||||
|
||||
|
||||
def test_a_thread_without_a_size_or_a_pitch_is_refused():
|
||||
with pytest.raises(ValueError, match="never guessed"):
|
||||
feature_prims("thread", {"d": 20.0, "length": 30.0})
|
||||
with pytest.raises(ValueError, match="ISO 261"):
|
||||
feature_prims("thread", {"d": 14.0, "length": 30.0, "size": "M14"})
|
||||
|
||||
|
||||
def test_a_size_that_does_not_match_d_is_refused():
|
||||
"""Otherwise the returned dims report M20's pitch on M8 geometry, citing ISO 261."""
|
||||
with pytest.raises(ValueError, match=r"params\['size'\]='M20' is a 20.0 mm thread"):
|
||||
feature_spec("thread", {"d": 8.0, "length": 20.0, "size": "M20"})
|
||||
matching = feature_spec("thread", {"d": 8.0, "length": 20.0, "size": "M8"})
|
||||
assert matching["dims"]["pitch"] == 1.25 # ISO 261 coarse pitch of M8
|
||||
|
||||
|
||||
def test_ring_groove_is_drawn_from_the_registry_row():
|
||||
prims = feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 471"})
|
||||
assert len(prims) == 6
|
||||
assert prims[0] == {
|
||||
"type": "line",
|
||||
"x1": 0.0,
|
||||
"y1": 15.0,
|
||||
"x2": 0.0,
|
||||
"y2": 13.5,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert prims[1] == {
|
||||
"type": "line",
|
||||
"x1": 0.0,
|
||||
"y1": 13.5,
|
||||
"x2": 2.0,
|
||||
"y2": 13.5,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert prims[3]["y1"] == -15.0
|
||||
bore = feature_prims("ring_groove", {"d": 30.0, "kind": "bore", "standard": "TEST 472"})
|
||||
assert bore[1]["y1"] == 16.5
|
||||
|
||||
|
||||
def test_ring_groove_refuses_a_row_that_is_the_wrong_way_round():
|
||||
with pytest.raises(ValueError, match="not a shaft groove"):
|
||||
feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 472"})
|
||||
with pytest.raises(ValueError, match="DIN 471"):
|
||||
feature_prims("ring_groove", {"d": 30.0, "kind": "shaft"})
|
||||
|
||||
|
||||
def test_oring_groove_is_a_rectangular_groove_from_the_row():
|
||||
prims = feature_prims(
|
||||
"oring_groove", {"d": 30.0, "cord": 3.53, "kind": "shaft", "standard": "TEST 3601"}
|
||||
)
|
||||
assert len(prims) == 6
|
||||
assert prims[0]["y1"] == 15.0 and prims[0]["y2"] == pytest.approx(12.3)
|
||||
assert prims[1]["x2"] == pytest.approx(4.7)
|
||||
|
||||
|
||||
def test_centre_hole_form_a_is_a_sixty_degree_countersink():
|
||||
spec = feature_spec("centre_hole", {"size": 2.5, "form": "A", "standard": "TEST 332 A"})
|
||||
cone = math.sqrt(3.0) * (5.3 - 2.5) / 2.0
|
||||
assert spec["dims"]["cone"] == pytest.approx(cone)
|
||||
prims = spec["prims"]
|
||||
assert len(prims) == 5
|
||||
assert prims[0] == {
|
||||
"type": "line",
|
||||
"x1": 0.0,
|
||||
"y1": 2.65,
|
||||
"x2": pytest.approx(cone),
|
||||
"y2": 1.25,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert prims[2]["x2"] == 5.0 and prims[2]["y1"] == 1.25
|
||||
assert prims[4] == {
|
||||
"type": "line",
|
||||
"x1": 5.0,
|
||||
"y1": -1.25,
|
||||
"x2": 5.0,
|
||||
"y2": 1.25,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
|
||||
|
||||
def test_centre_hole_form_b_adds_the_protecting_chamfer():
|
||||
prims = feature_prims("centre_hole", {"size": 2.5, "form": "B", "standard": "TEST 332 B"})
|
||||
chamfer = (6.3 - 5.3) / (2.0 * math.sqrt(3.0))
|
||||
assert len(prims) == 7
|
||||
assert prims[0]["y1"] == 3.15 # d3 / 2
|
||||
assert prims[0]["x2"] == pytest.approx(chamfer)
|
||||
|
||||
|
||||
def test_centre_hole_form_r_is_refused_not_approximated():
|
||||
with pytest.raises(ValueError, match="forms A and B"):
|
||||
feature_prims("centre_hole", {"size": 2.5, "form": "R", "standard": "TEST 332 A"})
|
||||
|
||||
|
||||
def test_undercut_draws_the_profile_the_table_publishes():
|
||||
prims = feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 509"})
|
||||
assert len(prims) == 2
|
||||
assert prims[0] == {
|
||||
"type": "polyline",
|
||||
"points": [[0.0, 15.0], [0.0, 14.6], [2.0, 14.6], [2.5, 15.0]],
|
||||
"closed": False,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert prims[1]["points"][1] == [0.0, -14.6]
|
||||
|
||||
|
||||
def test_undercut_refuses_a_row_without_a_profile():
|
||||
with pytest.raises(ValueError, match="carries no profile"):
|
||||
feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 471"})
|
||||
with pytest.raises(ValueError, match="DIN 509"):
|
||||
feature_prims("undercut", {"d": 30.0, "form": "E"})
|
||||
|
||||
|
||||
def test_place_prims_rotates_about_the_insertion_point():
|
||||
prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5})
|
||||
placed = place_prims(prims, (100.0, 50.0), 90.0)
|
||||
assert placed[0]["x1"] == pytest.approx(92.0)
|
||||
assert placed[0]["y1"] == pytest.approx(50.0)
|
||||
assert placed[0]["x2"] == pytest.approx(92.0)
|
||||
assert placed[0]["y2"] == pytest.approx(80.0)
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
place_prims(prims, (float("inf"), 0.0))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draw_std_feature_writes_the_primitives(backend):
|
||||
result = await draw_std_feature(
|
||||
backend, "thread", (100.0, 50.0), {"d": 20.0, "length": 30.0, "size": "M20"}
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["primitive_count"] == 3
|
||||
assert len(result["handles"]) == 3
|
||||
assert result["layers"] == ["GEOMETRY"]
|
||||
assert result["dims"]["pitch"] == 2.5
|
||||
info = await backend.entity_get(result["handles"][0])
|
||||
assert info.layer == "GEOMETRY"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draw_std_feature_refuses_before_writing_anything(backend):
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError, match="never guessed"):
|
||||
await draw_std_feature(backend, "thread", (0.0, 0.0), {"d": 20.0, "length": 30.0})
|
||||
with pytest.raises(ValueError, match="DIN 509"):
|
||||
await draw_std_feature(backend, "undercut", (0.0, 0.0), {"d": 30.0})
|
||||
assert len(await backend.entity_list()) == before
|
||||
@@ -0,0 +1,382 @@
|
||||
"""The standard-parts catalogue: search, designations, block specs, insertion.
|
||||
|
||||
Geometry is asserted as emitted primitive dicts - counts, layers, coordinates -
|
||||
never as a screenshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.block_specs import validate_attdef_specs, validate_entity_specs
|
||||
from engineering.mech.stdparts import (
|
||||
FAMILY_STANDARDS,
|
||||
FAMILY_VIEWS,
|
||||
THREAD_MINOR_RATIO,
|
||||
block_spec,
|
||||
catalogue,
|
||||
parse_designation,
|
||||
)
|
||||
|
||||
R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head
|
||||
MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread
|
||||
|
||||
|
||||
def test_the_iso6410_minor_radius_is_four_point_eight():
|
||||
"""Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary."""
|
||||
assert MINOR12 == pytest.approx(4.8)
|
||||
|
||||
|
||||
def test_the_catalogue_holds_every_transcribed_size():
|
||||
rows = catalogue()
|
||||
assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings
|
||||
assert len(catalogue(standard="ISO 15")) == 33
|
||||
assert len(catalogue("M12")) == 5
|
||||
assert len(catalogue("6205")) == 1
|
||||
assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS)
|
||||
|
||||
|
||||
def test_a_catalogue_row_carries_what_a_drawing_needs():
|
||||
(row,) = catalogue("6205")
|
||||
assert row["designation"] == "ISO 15 - 6205"
|
||||
assert row["standard"] == "ISO 15"
|
||||
assert row["family"] == "deep_groove_bearing"
|
||||
assert row["views"] == ["simplified", "detailed"]
|
||||
assert row["length_required"] is False
|
||||
assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0}
|
||||
assert "ISO 15:2017" in row["source"]
|
||||
|
||||
bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12")
|
||||
assert bolt["length_required"] is True
|
||||
assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5
|
||||
|
||||
|
||||
def test_catalogue_refuses_an_unknown_standard():
|
||||
with pytest.raises(ValueError, match="ISO 4014"):
|
||||
catalogue(standard="DIN 931")
|
||||
|
||||
|
||||
def test_parse_designation_normalises_and_splits():
|
||||
assert parse_designation("iso4014 - m12x60") == {
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"family": "hex_bolt",
|
||||
"size": "M12x60",
|
||||
"thread": "M12",
|
||||
"length": 60.0,
|
||||
}
|
||||
assert parse_designation("6205")["designation"] == "ISO 15 - 6205"
|
||||
assert parse_designation("ISO 4032 - M12")["length"] is None
|
||||
|
||||
|
||||
def test_parse_designation_refuses_a_missing_or_stray_length():
|
||||
with pytest.raises(ValueError, match="nominal length"):
|
||||
parse_designation("ISO 4014 - M12")
|
||||
with pytest.raises(ValueError, match="takes no length"):
|
||||
parse_designation("ISO 4032 - M12x60")
|
||||
with pytest.raises(ValueError, match="catalogue standard"):
|
||||
parse_designation("ISO 9999 - M12")
|
||||
|
||||
|
||||
def test_an_unknown_size_is_refused_with_the_nearest_entries():
|
||||
with pytest.raises(ValueError, match=r"ISO 4014 - M12"):
|
||||
block_spec("ISO 4014 - M13x60", "side")
|
||||
with pytest.raises(ValueError, match=r"ISO 15 - 6210"):
|
||||
block_spec("ISO 15 - 6211", "simplified")
|
||||
with pytest.raises(ValueError, match="approximately"):
|
||||
block_spec("ISO 4014 - M13x60", "side")
|
||||
|
||||
|
||||
def test_hex_bolt_side_view_is_the_iso6410_representation():
|
||||
spec = block_spec("ISO 4014 - M12x60", "side")
|
||||
assert spec["name"] == "STD_ISO_4014_M12X60_SIDE"
|
||||
assert spec["view"] == "side"
|
||||
assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125
|
||||
ents = spec["entities"]
|
||||
assert len(ents) == 10
|
||||
assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True
|
||||
assert ents[0]["points"] == [
|
||||
[-7.5, -R12],
|
||||
[0.0, -R12],
|
||||
[0.0, R12],
|
||||
[-7.5, R12],
|
||||
]
|
||||
assert ents[3] == {
|
||||
"type": "line",
|
||||
"x1": 0.0,
|
||||
"y1": 6.0,
|
||||
"x2": 60.0,
|
||||
"y2": 6.0,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
# ISO 6410: minor diameter at 0.8 x major, over the useful thread length.
|
||||
assert ents[6] == {
|
||||
"type": "line",
|
||||
"x1": 30.0,
|
||||
"y1": MINOR12,
|
||||
"x2": 60.0,
|
||||
"y2": MINOR12,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
# ISO 6410: the thread-length limit is a line to the major diameter.
|
||||
assert ents[8] == {
|
||||
"type": "line",
|
||||
"x1": 30.0,
|
||||
"y1": -6.0,
|
||||
"x2": 30.0,
|
||||
"y2": 6.0,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert ents[9]["layer"] == "CENTER"
|
||||
assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12))
|
||||
|
||||
|
||||
def test_a_fully_threaded_screw_has_no_thread_limit_line():
|
||||
spec = block_spec("ISO 4017 - M12x60", "side")
|
||||
assert len(spec["entities"]) == 9
|
||||
assert spec["dims"]["b"] == pytest.approx(60.0)
|
||||
minor = [e for e in spec["entities"] if e.get("y1") == MINOR12]
|
||||
assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0
|
||||
|
||||
|
||||
def test_hex_top_view_uses_the_three_quarter_minor_arc():
|
||||
spec = block_spec("ISO 4014 - M12x60", "top")
|
||||
ents = spec["entities"]
|
||||
assert len(ents) == 5
|
||||
assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6
|
||||
assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"}
|
||||
assert ents[2] == {
|
||||
"type": "arc",
|
||||
"cx": 0.0,
|
||||
"cy": 0.0,
|
||||
"r": MINOR12,
|
||||
"start_deg": 90.0,
|
||||
"end_deg": 360.0,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER"
|
||||
|
||||
|
||||
def _hexagon(spec: dict) -> list[list[float]]:
|
||||
return next(e for e in spec["entities"] if e["type"] == "polyline")["points"]
|
||||
|
||||
|
||||
def _height(points) -> float:
|
||||
return max(y for _, y in points) - min(y for _, y in points)
|
||||
|
||||
|
||||
def _width(points) -> float:
|
||||
return max(x for x, _ in points) - min(x for x, _ in points)
|
||||
|
||||
|
||||
def test_the_hexagon_has_vertices_at_twelve_and_six_oclock():
|
||||
"""The orientation the elevation is drawn as the projection of."""
|
||||
points = _hexagon(block_spec("ISO 4014 - M12x60", "top"))
|
||||
assert [pytest.approx(v, abs=1e-9) for v in points[0]] == [
|
||||
R12 * math.sqrt(3.0) / 2.0,
|
||||
R12 / 2.0,
|
||||
]
|
||||
assert [pytest.approx(v, abs=1e-9) for v in points[1]] == [0.0, R12]
|
||||
assert _height(points) == pytest.approx(2.0 * R12) # across corners e
|
||||
assert _width(points) == pytest.approx(18.0) # across flats s
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"designation", ["ISO 4014 - M12x60", "ISO 4017 - M12x60", "ISO 4032 - M12"]
|
||||
)
|
||||
def test_the_two_views_of_one_fastener_agree_on_the_silhouette_height(designation):
|
||||
"""Plan and elevation of the same part must measure the same across the vertical.
|
||||
|
||||
Place the two blocks in projection and a disagreement here is a wrong
|
||||
workshop drawing: the head would read 20.78 mm in the elevation and 18 mm in
|
||||
the plan. The elevation's outline polyline spans the full head height, and
|
||||
the plan's hexagon must span the same.
|
||||
"""
|
||||
elevation = _height(_hexagon(block_spec(designation, "side")))
|
||||
assert elevation == pytest.approx(2.0 * R12)
|
||||
assert _height(_hexagon(block_spec(designation, "top"))) == pytest.approx(elevation)
|
||||
|
||||
|
||||
def test_the_socket_head_views_agree_on_the_head_diameter():
|
||||
"""The socket screw's silhouette is dk in both views; its hexagon is the key socket."""
|
||||
elevation = _height(_hexagon(block_spec("ISO 4762 - M12x60", "side")))
|
||||
plan = block_spec("ISO 4762 - M12x60", "top")["entities"]
|
||||
assert plan[0]["type"] == "circle" and 2.0 * plan[0]["r"] == pytest.approx(elevation)
|
||||
socket = plan[1]["points"]
|
||||
assert _height(socket) > _width(socket) # same orientation as every other hexagon here
|
||||
|
||||
|
||||
def test_hex_nut_side_view_shows_the_thread_as_hidden_lines():
|
||||
spec = block_spec("ISO 4032 - M12", "side")
|
||||
ents = spec["entities"]
|
||||
assert len(ents) == 8
|
||||
hidden = [e for e in ents if e["layer"] == "HIDDEN"]
|
||||
assert len(hidden) == 4
|
||||
assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12}
|
||||
assert ents[0]["points"][1] == [10.8, -R12] # nut height m
|
||||
|
||||
|
||||
def test_washer_views():
|
||||
side = block_spec("ISO 7089 - M12", "side")
|
||||
assert len(side["entities"]) == 3
|
||||
assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]]
|
||||
top = block_spec("ISO 7089 - M12", "top")
|
||||
assert top["entities"][0] == {
|
||||
"type": "circle",
|
||||
"cx": 0.0,
|
||||
"cy": 0.0,
|
||||
"r": 12.0,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert top["entities"][1]["r"] == 6.5
|
||||
|
||||
|
||||
def test_socket_head_screw_views():
|
||||
side = block_spec("ISO 4762 - M8x30", "side")
|
||||
assert len(side["entities"]) == 8
|
||||
assert side["dims"]["b"] == pytest.approx(28.0)
|
||||
assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5
|
||||
top = block_spec("ISO 4762 - M8x30", "top")
|
||||
assert top["entities"][0]["r"] == 6.5
|
||||
assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6
|
||||
|
||||
|
||||
def test_bearing_simplified_is_the_iso8826_1_outline_and_cross():
|
||||
spec = block_spec("ISO 15 - 6205", "simplified")
|
||||
ents = spec["entities"]
|
||||
assert len(ents) == 7
|
||||
assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]]
|
||||
assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]]
|
||||
assert ents[2] == {
|
||||
"type": "line",
|
||||
"x1": -4.5,
|
||||
"y1": 19.25,
|
||||
"x2": 4.5,
|
||||
"y2": 19.25,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert ents[3] == {
|
||||
"type": "line",
|
||||
"x1": 0.0,
|
||||
"y1": 15.2,
|
||||
"x2": 0.0,
|
||||
"y2": 23.3,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert ents[6]["layer"] == "CENTER"
|
||||
|
||||
|
||||
def test_bearing_detailed_draws_the_element_symbol():
|
||||
spec = block_spec("ISO 15 - 6205", "detailed")
|
||||
ents = spec["entities"]
|
||||
assert len(ents) == 5
|
||||
assert ents[2] == {
|
||||
"type": "circle",
|
||||
"cx": 0.0,
|
||||
"cy": 19.25,
|
||||
"r": 4.05,
|
||||
"layer": "GEOMETRY",
|
||||
}
|
||||
assert ents[3]["cy"] == -19.25
|
||||
|
||||
|
||||
def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back():
|
||||
assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified"
|
||||
with pytest.raises(ValueError, match="simplified"):
|
||||
block_spec("ISO 15 - 6205", "top")
|
||||
with pytest.raises(ValueError, match="side"):
|
||||
block_spec("ISO 4014 - M12x60", "simplified")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("designation", "view"),
|
||||
[
|
||||
("ISO 4014 - M12x60", "side"),
|
||||
("ISO 4014 - M12x60", "top"),
|
||||
("ISO 4017 - M12x60", "side"),
|
||||
("ISO 4032 - M12", "side"),
|
||||
("ISO 4032 - M12", "top"),
|
||||
("ISO 7089 - M12", "side"),
|
||||
("ISO 7089 - M12", "top"),
|
||||
("ISO 4762 - M8x30", "side"),
|
||||
("ISO 4762 - M8x30", "top"),
|
||||
("ISO 15 - 6205", "simplified"),
|
||||
("ISO 15 - 6205", "detailed"),
|
||||
],
|
||||
)
|
||||
def test_every_block_spec_validates_against_block_define(designation, view):
|
||||
spec = block_spec(designation, view)
|
||||
validate_entity_specs(spec["entities"])
|
||||
validate_attdef_specs(spec["attdefs"])
|
||||
assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"]
|
||||
assert spec["attdefs"][0]["default"] == spec["designation"]
|
||||
assert spec["attdefs"][0]["invisible"] is False
|
||||
assert all(att["invisible"] for att in spec["attdefs"][1:])
|
||||
assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3]
|
||||
assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_defines_the_block_once_and_writes_the_payload(backend):
|
||||
from engineering.mech.stdparts import insert_std_part
|
||||
from engineering.mech.xdata import APP_ID, decode
|
||||
|
||||
result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8")
|
||||
assert result["ok"] is True
|
||||
assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE"
|
||||
assert result["defined"] is True
|
||||
assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"]
|
||||
assert result["view"] == "side"
|
||||
assert result["layer"] == "GEOMETRY"
|
||||
assert result["dims"]["s"] == 18.0
|
||||
|
||||
stored = await backend.entity_get_xdata(result["handle"], APP_ID)
|
||||
payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]])
|
||||
assert payload == {
|
||||
"v": 1,
|
||||
"kind": "std_part",
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"size": "M12x60",
|
||||
"qty": 1,
|
||||
"material": "8.8",
|
||||
}
|
||||
assert await backend.block_get_attributes(result["handle"]) == {
|
||||
"DESIG": "ISO 4014 - M12x60",
|
||||
"STD": "ISO 4014",
|
||||
"SIZE": "M12x60",
|
||||
"MAT": "8.8",
|
||||
}
|
||||
|
||||
again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0))
|
||||
assert again["defined"] is False and again["layers_created"] == []
|
||||
assert again["payload"]["material"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearing_inserts_with_its_own_view_name(backend):
|
||||
from engineering.mech.stdparts import insert_std_part
|
||||
|
||||
result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0)
|
||||
assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED"
|
||||
assert result["view"] == "simplified"
|
||||
assert result["rotation"] == 90.0
|
||||
assert result["dims"]["B"] == 15.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insert_refuses_before_writing_anything(backend):
|
||||
from engineering.mech.stdparts import insert_std_part
|
||||
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError, match="Nearest entries"):
|
||||
await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0))
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0))
|
||||
with pytest.raises(ValueError, match="no attribute"):
|
||||
await insert_std_part(
|
||||
backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"}
|
||||
)
|
||||
assert len(await backend.entity_list()) == before
|
||||
+24
-11
@@ -5,6 +5,9 @@ v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools.
|
||||
Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting
|
||||
essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment
|
||||
surface and nothing else.
|
||||
|
||||
v1.6 tracks B+G add the `mech` pack: SECTION 21's six part-model tools and
|
||||
SECTION 22's three standard-parts tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -78,6 +81,18 @@ SETTINGS_TOOLS = {
|
||||
"system_prompt_message",
|
||||
}
|
||||
|
||||
MECH_TOOLS = {
|
||||
"mech_part_draw",
|
||||
"mech_view_add",
|
||||
"mech_dimension_part",
|
||||
"mech_hole_pattern",
|
||||
"mech_part_from_spec",
|
||||
"mech_part_inspect",
|
||||
"std_part_list",
|
||||
"std_part_insert",
|
||||
"std_feature_draw",
|
||||
}
|
||||
|
||||
LEAN_SETTINGS_ESSENTIALS = {
|
||||
"drawing_apply_standard",
|
||||
"page_setup_apply",
|
||||
@@ -86,16 +101,10 @@ LEAN_SETTINGS_ESSENTIALS = {
|
||||
"textstyle_set_current",
|
||||
}
|
||||
|
||||
#: Declaration order - what `tool_packs["available"]` reports.
|
||||
ALL_PACKS = ["core", "pid", "settings", "mech"]
|
||||
ALL_PACKS_SORTED = sorted(ALL_PACKS)
|
||||
MECH_TOOLS = {
|
||||
"mech_part_draw",
|
||||
"mech_view_add",
|
||||
"mech_dimension_part",
|
||||
"mech_hole_pattern",
|
||||
"mech_part_from_spec",
|
||||
"mech_part_inspect",
|
||||
}
|
||||
#: `tool_packs["enabled"]` is sorted, which is no longer the same list.
|
||||
ALL_PACKS_ENABLED = sorted(ALL_PACKS)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
@@ -111,7 +120,10 @@ def test_pack_registry_names_real_tools_and_only_them():
|
||||
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
|
||||
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
|
||||
assert len(SETTINGS_TOOLS) == 22
|
||||
assert len(MECH_TOOLS) == 9
|
||||
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
|
||||
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
|
||||
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
|
||||
|
||||
|
||||
async def test_every_pack_member_is_a_registered_tool():
|
||||
@@ -127,6 +139,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(
|
||||
disabled = set(info["disabled_tools"])
|
||||
assert PID_TOOLS <= disabled
|
||||
assert SETTINGS_TOOLS <= disabled
|
||||
assert MECH_TOOLS <= disabled
|
||||
assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
|
||||
assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
|
||||
assert "block_define" not in disabled and "entity_set_xdata" not in disabled
|
||||
@@ -146,8 +159,8 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
|
||||
monkeypatch.setattr(config.settings, "tool_packs", "all")
|
||||
info = await server._apply_tool_profile("full")
|
||||
disabled = set(info["disabled_tools"])
|
||||
assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
|
||||
assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED
|
||||
assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS) & disabled)
|
||||
assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED
|
||||
|
||||
|
||||
async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
|
||||
|
||||
+15
-11
@@ -527,15 +527,19 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
merge task tagged them, the four groups had filed those 28 tools under
|
||||
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
||||
their secondary tags; those counts returned to their pre-track-E values
|
||||
when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's
|
||||
SECTION 21 opened with the part model and its view engine
|
||||
(`mech_part_draw` / `mech_view_add` / `mech_dimension_part` /
|
||||
`mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the
|
||||
`mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so
|
||||
those tools file under `mech` rather than under their secondary `create` /
|
||||
`dimension` / `query` tags. This snapshot is branch-local: the merge task
|
||||
recomputes it once all four track B+G branches have landed. Every other
|
||||
number here has been unchanged since the snapshot was taken.
|
||||
when the section tags landed.) Every other number here has been unchanged
|
||||
since the snapshot was taken.
|
||||
|
||||
`mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with
|
||||
the part model and its view engine (`mech_part_draw` / `mech_view_add` /
|
||||
`mech_dimension_part` / `mech_hole_pattern` / `mech_part_from_spec` /
|
||||
`mech_part_inspect`); the `mech` tag is ranked directly after `pid` in
|
||||
`_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under
|
||||
their secondary `create` / `dimension` / `query` tags.
|
||||
|
||||
SECTION 22 added the standard-parts catalogue (`std_part_list` /
|
||||
`std_part_insert` / `std_feature_draw`) to the same group, filed under
|
||||
`mech` rather than under their secondary `query` / `create` tags.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
@@ -552,7 +556,7 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"environment": 22,
|
||||
"layers": 14,
|
||||
"layouts": 12,
|
||||
"mech": 6,
|
||||
"mech": 9,
|
||||
"page_setup": 6,
|
||||
"pid": 9,
|
||||
"premium": 12,
|
||||
@@ -564,4 +568,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 210
|
||||
assert sum(sizes.values()) == 213
|
||||
|
||||
Reference in New Issue
Block a user