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:
Umut CAN
2026-09-23 12:25:44 +03:00
co-authored by Claude Opus 5.5
13 changed files with 2781 additions and 28 deletions
+3 -3
View File
@@ -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.
+55
View File
@@ -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",
),
),
}
+44 -2
View File
@@ -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",
+136
View File
@@ -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)
+269
View File
@@ -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
+198 -1
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+145
View File
@@ -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()
+223
View File
@@ -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")
+240
View File
@@ -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
+382
View File
@@ -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
View File
@@ -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
View File
@@ -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