feat(task-9): SECTION 22 - std_part_list, std_part_insert, std_feature_draw

engineering/mech/stdparts.py turns the Task 8 tables into real blocks. catalogue()
searches 83 transcribed parts by designation, standard, size or family;
block_spec() emits the typed primitives and DESIG/STD/SIZE/MAT ATTDEFs
block_define takes, in side/top for fasteners and ISO 8826-1 simplified /
ISO 8826-2 detailed for bearings; insert_std_part() defines the block once,
inserts it and writes the std_part ACADMCP_MECH payload the skeleton pins, so
the parts list is a read of the drawing rather than a naming convention.
Threads follow ISO 6410 - minor at 0.8 x major, a thread-length limit line to
the major, a three-quarter minor arc in the end view.

std_feature_draw() adds a thread, a DIN 509 undercut, a DIN 471/472 groove, a
DIN 332 centre hole or an ISO 3601-2 O-ring groove to geometry that already
exists. Its dimensions come from the shared standards registry, never from this
module: an absent table, a size outside coverage or a row missing a dimension is
refused by name, and the DIN 509 relief is drawn from the profile its table
publishes rather than reconstructed here. DIN 332 form R is refused outright.
An unknown designation is refused with the nearest catalogue entries; nothing is
drawn approximately.

SECTION 22 registers the three tools in the new mech pack, with alias records
(INSERT plus shop-floor synonyms in English and Turkish), a regenerated tool
inventory, the README release snapshot at 207 tools in 24 groups, and the frozen
group-size snapshot gaining mech: 3.

Two plan corrections, both verified by running the code:

* the plan's @cad_tool summaries for std_part_insert (118 chars) and
  std_feature_draw (130) overran discovery.serialize.MAX_SUMMARY_CHARS = 110, so
  tests/test_tool_registry.py::test_no_authored_summary_is_long_enough_to_be_truncated
  failed - they would have shipped rendered as "...". Both are shortened to 99
  and 104 characters with the same content.
* the plan's file list omitted tests/test_tool_packs.py, which pins the pack
  registry, the tool_packs report and the group count. It now carries MECH_TOOLS
  and distinguishes ALL_PACKS (declaration order, what "available" reports) from
  ALL_PACKS_ENABLED (sorted, what "enabled" reports) - those two lists stopped
  being the same list when "mech" was appended after "settings".

No new ActiveX member is called: the module drives block_list / block_define /
block_insert / entity_set_xdata / entity_create_* only. The COM path was still
executed live once on AutoCAD 2026 in a scratch document created and closed by
the probe: the bolt block defined with layers CENTER+GEOMETRY, attributes read
back as DESIG/STD/SIZE/MAT = ISO 4014 - M12x60 / ISO 4014 / M12x60 / 8.8, the
ACADMCP_MECH std_part payload round-tripped through entity_get_xdata,
STD_ISO_15_6205_SIMPLIFIED inserted, and the M20 thread's three primitives
landing on GEOMETRY.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 19:26:49 +03:00
co-authored by Claude Opus 5
parent 6024723d6b
commit 84c1ef42fb
9 changed files with 1923 additions and 12 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:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
> **v1.5 release snapshot:** 207 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 207 is the **registered** count; a default install advertises 202 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>204 tools in 23 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>207 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
@@ -1859,6 +1859,61 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"save a detail view",
),
),
# ── Standard parts (SECTION 22, tracks B+G group S) ─────────────────────
"std_part_list": ToolAliases(
acad=(),
synonyms=(
"standard parts catalogue",
"what bolts do you have",
"bolt sizes",
"hex bolt table",
"bearing list",
"iso 4014 sizes",
"find a washer",
"civata listesi",
"rulman listesi",
),
),
"std_part_insert": ToolAliases(
acad=("INSERT",),
synonyms=(
"insert a bolt",
"place a screw",
"draw a nut",
"add a washer",
"put a bearing in",
"hex bolt",
"socket head cap screw",
"allen screw",
"deep groove ball bearing",
"standard part",
"civata",
"somun",
"rondela",
"rulman",
),
),
"std_feature_draw": ToolAliases(
acad=(),
synonyms=(
"draw a thread",
"thread representation",
"undercut",
"relief groove",
"retaining ring groove",
"circlip groove",
"snap ring groove",
"centre hole",
"center drill",
"o-ring groove",
"din 509",
"din 471",
"din 332",
"vida disi",
"segman kanali",
"punta deligi",
),
),
}
+45 -2
View File
@@ -3,10 +3,10 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 204,
"tools": 207,
"resources": 8,
"prompts": 5,
"groups": 23
"groups": 24
},
"groups": {
"analysis": 12,
@@ -22,6 +22,7 @@
"environment": 22,
"layers": 14,
"layouts": 12,
"mech": 3,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -2333,6 +2334,48 @@
"destructiveHint": false
}
},
{
"name": "std_feature_draw",
"group": "mech",
"cost": "mutate",
"summary": "Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole - onto existing geometry.",
"tags": [
"create",
"mech"
],
"annotations": {
"title": "Standard Parts: Draw Feature",
"readOnlyHint": false
}
},
{
"name": "std_part_insert",
"group": "mech",
"cost": "mutate",
"summary": "Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing - as a tagged block.",
"tags": [
"create",
"mech"
],
"annotations": {
"title": "Standard Parts: Insert",
"readOnlyHint": false
}
},
{
"name": "std_part_list",
"group": "mech",
"cost": "read",
"summary": "Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
"tags": [
"mech",
"query"
],
"annotations": {
"title": "Standard Parts: Catalogue",
"readOnlyHint": true
}
},
{
"name": "system_about",
"group": "system",
File diff suppressed because it is too large Load Diff
+202 -1
View File
@@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None:
# entity/drawing tags. Tools whose tags match none fall into "other".
_GROUP_TAG_PRIORITY = (
"pid",
"mech",
"style",
"pagesetup",
"environment",
@@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = (
# Map the winning tag to a human-readable group label for the breakdown.
_GROUP_TAG_LABELS = {
"pid": "pid",
"mech": "mech",
"style": "styles",
"pagesetup": "page_setup",
"environment": "environment",
@@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset(
# mechanically should not pay for the P&ID surface, and a client that never
# talks to a live seat should not pay for the environment surface. Styles and
# page setup (SECTION 18/19) are drafting essentials and stay in core.
TOOL_PACK_NAMES = ("core", "pid", "settings")
TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"pid": frozenset(
{
@@ -1002,6 +1004,14 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"system_prompt_message",
}
),
# SECTION 22 - the standard-parts catalogue (tracks B+G).
"mech": frozenset(
{
"std_part_list",
"std_part_insert",
"std_feature_draw",
}
),
}
_active_tool_profile: dict | None = None
@@ -8123,6 +8133,197 @@ async def drawing_properties_set(
return await _backend(ctx).drawing_properties_set(summary, custom)
# ---------------------------------------------------------------------------
# ── SECTION 22: Standard Parts (3 tools) ────────────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Search the standard-parts catalogue: ISO bolts, nuts, washers, cap screws, bearings.",
cost="read",
)
@mcp.tool(
annotations={"title": "Standard Parts: Catalogue", "readOnlyHint": True},
tags={"mech", "query"},
)
async def std_part_list(
query: Annotated[
str | None,
Field(
default=None,
description=(
"Free text matched against designation, standard, size and family - "
"'M12', 'ISO 4014', '6205', 'washer'"
),
),
] = None,
standard: Annotated[
str | None,
Field(
default=None,
description="One of ISO 4014, ISO 4017, ISO 4032, ISO 7089, ISO 4762, ISO 15",
),
] = None,
ctx: Context = None,
) -> dict:
"""Catalogue rows with the dimensions a drawing needs, and their provenance.
Every row is transcribed from the named standard's own table and carries
its `source`. Coverage is deliberately narrow: ISO 261 first-choice sizes
only (M5-M36, M3-M24 for ISO 4762) and ISO 15 bore numbers 00-10 in the
600x / 620x / 630x series. A size outside a table is refused by name by
`std_part_insert` with the nearest entries, never interpolated. An unknown
`standard` is refused with the list of catalogue standards.
"""
from engineering.mech.stdparts import FAMILY_STANDARDS, catalogue
rows = catalogue(query, standard)
return {"count": len(rows), "standards": list(FAMILY_STANDARDS), "parts": list(rows)}
@cad_tool(
summary=(
"Insert an ISO standard part - hex bolt, nut, washer, cap screw or ball bearing "
"- as a tagged block."
),
cost="mutate",
)
@mcp.tool(
annotations={"title": "Standard Parts: Insert", "readOnlyHint": False},
tags={"mech", "create"},
)
async def std_part_insert(
designation: Annotated[
str,
Field(
description=(
"'ISO 4014 - M12x60', 'ISO 4032 - M12', 'ISO 7089 - M12', "
"'ISO 4762 - M8x30', 'ISO 15 - 6205' (a bare '6205' also works)"
)
),
],
x: Annotated[float, Field(description="Insertion X (WCS)")],
y: Annotated[float, Field(description="Insertion Y (WCS)")],
view: Annotated[
str,
Field(
default="side",
description=(
"Fasteners: side | top. Bearings: simplified (ISO 8826-1) | detailed "
"(ISO 8826-2). The default 'side' falls back to the family's first view."
),
),
] = "side",
rotation: Annotated[
float,
Field(default=0.0, description="Degrees CCW; 0 puts the part's axis along +X"),
] = 0.0,
layer: Annotated[
str,
Field(
default="GEOMETRY",
description=(
"Layer for the INSERT; the block's own primitives keep GEOMETRY / CENTER / HIDDEN"
),
),
] = "GEOMETRY",
material: Annotated[
str | None,
Field(
default=None,
description="Property class or material for the MAT attribute and the XDATA, e.g. '8.8'",
),
] = None,
ctx: Context = None,
) -> dict:
"""Define the part's block once, insert it, and write its `std_part` XDATA.
The block carries DESIG / STD / SIZE / MAT attributes and an ACADMCP_MECH
payload, so a parts list is a read of the drawing rather than a naming
convention. Refusals, all before any write: a designation outside the
transcribed tables (named, with the nearest catalogue entries - nothing is
drawn approximately), a malformed designation, a missing or stray nominal
length, a view the family does not have, an attribute tag the block does
not carry, and a non-finite coordinate or rotation.
"""
from engineering.mech.stdparts import insert_std_part
await ctx.info(f"standard part {designation} ({view}) at ({x}, {y})")
return await insert_std_part(
_backend(ctx),
designation,
at=(x, y),
view=view,
rotation=rotation,
layer=layer,
material=material,
)
@cad_tool(
summary=(
"Draw a standard feature - thread, undercut, ring or O-ring groove, centre hole "
"- onto existing geometry."
),
cost="mutate",
)
@mcp.tool(
annotations={"title": "Standard Parts: Draw Feature", "readOnlyHint": False},
tags={"mech", "create"},
)
async def std_feature_draw(
kind: Annotated[
str,
Field(description="thread | undercut | ring_groove | centre_hole | oring_groove"),
],
x: Annotated[float, Field(description="Feature origin X (WCS): a point on the axis")],
y: Annotated[float, Field(description="Feature origin Y (WCS): a point on the axis")],
params: Annotated[
dict,
Field(
description=(
"thread: {d, length, size|pitch, internal}; undercut: {d, form: E|F}; "
"ring_groove: {d, kind: shaft|bore}; centre_hole: {size, form: A|B}; "
"oring_groove: {d, cord, kind: shaft|bore}"
)
),
],
rotation: Annotated[
float,
Field(
default=0.0,
description="Degrees CCW applied to the local +X (along the axis, into the feature)",
),
] = 0.0,
layer: Annotated[
str | None,
Field(
default=None,
description="Override the visible primitives' layer; CENTER and HIDDEN keep theirs",
),
] = None,
ctx: Context = None,
) -> dict:
"""Add a standard feature to geometry that already exists - a foreign drawing being finished.
Local frame: `(x, y)` is on the axis, +X runs along the axis in the
direction the feature extends and +Y is radially outward. The thread is the
ISO 6410 representation (minor diameter at 0.8 x major, thread-length limit
to the major) and its pitch comes from ISO 261 via `size` or from an
explicit `pitch` - it is never guessed. Every other kind takes its
dimensions from the registered standards table and is refused by name when
that table is absent, when the size is outside its coverage, or when the row
lacks a dimension the feature needs: no value is interpolated and no profile
is reconstructed. DIN 332 form R is refused - only forms A and B are drawn.
"""
from engineering.mech.stdparts import draw_std_feature
await ctx.info(f"standard feature {kind} at ({x}, {y})")
return await draw_std_feature(
_backend(ctx), kind, (x, y), params, rotation=rotation, layer=layer
)
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+232
View File
@@ -0,0 +1,232 @@
"""std_feature_draw: the ISO 6410 thread, and grooves drawn from registry rows.
The DIN 471 / DIN 509 / DIN 332 / ISO 3601-2 tables themselves land with Task 3
(group M). These tests register **synthetic** tables under obviously fake
standard names, so what is pinned here is that the feature draws exactly the
dimensions the registry publishes - never a number this module invented.
"""
from __future__ import annotations
import math
import pytest
from engineering.mech.standards import Coverage, register, standards
from engineering.mech.stdparts import (
STD_FEATURE_KINDS,
draw_std_feature,
feature_prims,
feature_spec,
place_prims,
)
@pytest.fixture(scope="module", autouse=True)
def synthetic_tables():
"""Round, obviously synthetic rows - NOT DIN or ISO values."""
if "TEST 471" in standards():
return
register(
"TEST 471",
{30.0: {"m": 2.0, "d2": 27.0}},
Coverage(8.0, 100.0),
"synthetic test fixture - the real DIN 471 table lands with Task 3",
)
register(
"TEST 472",
{30.0: {"m": 2.0, "d2": 33.0}},
Coverage(8.0, 100.0),
"synthetic test fixture - the real DIN 472 table lands with Task 3",
)
register(
"TEST 509",
{30.0: {"profile": [[0.0, 0.0], [0.0, 0.4], [2.0, 0.4], [2.5, 0.0]]}},
Coverage(18.0, 80.0),
"synthetic test fixture - the real DIN 509 table lands with Task 3",
)
register(
"TEST 332 A",
{2.5: {"d1": 2.5, "d2": 5.3, "t": 5.0}},
Coverage(1.0, 10.0),
"synthetic test fixture - the real DIN 332-1 table lands with Task 3",
)
register(
"TEST 332 B",
{2.5: {"d1": 2.5, "d2": 5.3, "d3": 6.3, "t": 6.0}},
Coverage(1.0, 10.0),
"synthetic test fixture - the real DIN 332-1 table lands with Task 3",
)
register(
"TEST 3601",
{3.53: {"b": 4.7, "h": 2.7}},
Coverage(1.0, 10.0),
"synthetic test fixture - the real ISO 3601-2 table lands with Task 3",
)
def test_feature_kinds_are_the_five_the_spec_names():
assert STD_FEATURE_KINDS == (
"thread",
"undercut",
"ring_groove",
"centre_hole",
"oring_groove",
)
with pytest.raises(ValueError, match="oring_groove"):
feature_prims("spline", {})
def test_external_thread_is_the_iso6410_representation():
spec = feature_spec("thread", {"d": 20.0, "length": 30.0, "size": "M20"})
assert spec["dims"]["pitch"] == 2.5 # ISO 261 coarse, from the fastener table
assert "ISO 6410" in spec["source"]
assert spec["prims"] == (
{"type": "line", "x1": 0.0, "y1": 8.0, "x2": 30.0, "y2": 8.0, "layer": "GEOMETRY"},
{"type": "line", "x1": 0.0, "y1": -8.0, "x2": 30.0, "y2": -8.0, "layer": "GEOMETRY"},
{"type": "line", "x1": 30.0, "y1": -10.0, "x2": 30.0, "y2": 10.0, "layer": "GEOMETRY"},
)
def test_internal_thread_draws_the_major_diameter_instead():
prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5, "internal": True})
assert prims[0]["y1"] == 10.0 and prims[1]["y1"] == -10.0
def test_a_thread_without_a_size_or_a_pitch_is_refused():
with pytest.raises(ValueError, match="never guessed"):
feature_prims("thread", {"d": 20.0, "length": 30.0})
with pytest.raises(ValueError, match="ISO 261"):
feature_prims("thread", {"d": 14.0, "length": 30.0, "size": "M14"})
def test_ring_groove_is_drawn_from_the_registry_row():
prims = feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 471"})
assert len(prims) == 6
assert prims[0] == {
"type": "line",
"x1": 0.0,
"y1": 15.0,
"x2": 0.0,
"y2": 13.5,
"layer": "GEOMETRY",
}
assert prims[1] == {
"type": "line",
"x1": 0.0,
"y1": 13.5,
"x2": 2.0,
"y2": 13.5,
"layer": "GEOMETRY",
}
assert prims[3]["y1"] == -15.0
bore = feature_prims("ring_groove", {"d": 30.0, "kind": "bore", "standard": "TEST 472"})
assert bore[1]["y1"] == 16.5
def test_ring_groove_refuses_a_row_that_is_the_wrong_way_round():
with pytest.raises(ValueError, match="not a shaft groove"):
feature_prims("ring_groove", {"d": 30.0, "kind": "shaft", "standard": "TEST 472"})
with pytest.raises(ValueError, match="DIN 471"):
feature_prims("ring_groove", {"d": 30.0, "kind": "shaft"})
def test_oring_groove_is_a_rectangular_groove_from_the_row():
prims = feature_prims(
"oring_groove", {"d": 30.0, "cord": 3.53, "kind": "shaft", "standard": "TEST 3601"}
)
assert len(prims) == 6
assert prims[0]["y1"] == 15.0 and prims[0]["y2"] == pytest.approx(12.3)
assert prims[1]["x2"] == pytest.approx(4.7)
def test_centre_hole_form_a_is_a_sixty_degree_countersink():
spec = feature_spec("centre_hole", {"size": 2.5, "form": "A", "standard": "TEST 332 A"})
cone = math.sqrt(3.0) * (5.3 - 2.5) / 2.0
assert spec["dims"]["cone"] == pytest.approx(cone)
prims = spec["prims"]
assert len(prims) == 5
assert prims[0] == {
"type": "line",
"x1": 0.0,
"y1": 2.65,
"x2": pytest.approx(cone),
"y2": 1.25,
"layer": "GEOMETRY",
}
assert prims[2]["x2"] == 5.0 and prims[2]["y1"] == 1.25
assert prims[4] == {
"type": "line",
"x1": 5.0,
"y1": -1.25,
"x2": 5.0,
"y2": 1.25,
"layer": "GEOMETRY",
}
def test_centre_hole_form_b_adds_the_protecting_chamfer():
prims = feature_prims("centre_hole", {"size": 2.5, "form": "B", "standard": "TEST 332 B"})
chamfer = (6.3 - 5.3) / (2.0 * math.sqrt(3.0))
assert len(prims) == 7
assert prims[0]["y1"] == 3.15 # d3 / 2
assert prims[0]["x2"] == pytest.approx(chamfer)
def test_centre_hole_form_r_is_refused_not_approximated():
with pytest.raises(ValueError, match="forms A and B"):
feature_prims("centre_hole", {"size": 2.5, "form": "R", "standard": "TEST 332 A"})
def test_undercut_draws_the_profile_the_table_publishes():
prims = feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 509"})
assert len(prims) == 2
assert prims[0] == {
"type": "polyline",
"points": [[0.0, 15.0], [0.0, 14.6], [2.0, 14.6], [2.5, 15.0]],
"closed": False,
"layer": "GEOMETRY",
}
assert prims[1]["points"][1] == [0.0, -14.6]
def test_undercut_refuses_a_row_without_a_profile():
with pytest.raises(ValueError, match="carries no profile"):
feature_prims("undercut", {"d": 30.0, "form": "E", "standard": "TEST 471"})
with pytest.raises(ValueError, match="DIN 509"):
feature_prims("undercut", {"d": 30.0, "form": "E"})
def test_place_prims_rotates_about_the_insertion_point():
prims = feature_prims("thread", {"d": 20.0, "length": 30.0, "pitch": 2.5})
placed = place_prims(prims, (100.0, 50.0), 90.0)
assert placed[0]["x1"] == pytest.approx(92.0)
assert placed[0]["y1"] == pytest.approx(50.0)
assert placed[0]["x2"] == pytest.approx(92.0)
assert placed[0]["y2"] == pytest.approx(80.0)
with pytest.raises(ValueError, match="finite"):
place_prims(prims, (float("inf"), 0.0))
@pytest.mark.asyncio
async def test_draw_std_feature_writes_the_primitives(backend):
result = await draw_std_feature(
backend, "thread", (100.0, 50.0), {"d": 20.0, "length": 30.0, "size": "M20"}
)
assert result["ok"] is True
assert result["primitive_count"] == 3
assert len(result["handles"]) == 3
assert result["layers"] == ["GEOMETRY"]
assert result["dims"]["pitch"] == 2.5
info = await backend.entity_get(result["handles"][0])
assert info.layer == "GEOMETRY"
@pytest.mark.asyncio
async def test_draw_std_feature_refuses_before_writing_anything(backend):
before = len(await backend.entity_list())
with pytest.raises(ValueError, match="never guessed"):
await draw_std_feature(backend, "thread", (0.0, 0.0), {"d": 20.0, "length": 30.0})
with pytest.raises(ValueError, match="DIN 509"):
await draw_std_feature(backend, "undercut", (0.0, 0.0), {"d": 30.0})
assert len(await backend.entity_list()) == before
+333
View File
@@ -0,0 +1,333 @@
"""The standard-parts catalogue: search, designations, block specs, insertion.
Geometry is asserted as emitted primitive dicts - counts, layers, coordinates -
never as a screenshot.
"""
from __future__ import annotations
import math
import pytest
from backends.block_specs import validate_attdef_specs, validate_entity_specs
from engineering.mech.stdparts import (
FAMILY_STANDARDS,
FAMILY_VIEWS,
THREAD_MINOR_RATIO,
block_spec,
catalogue,
parse_designation,
)
R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head
MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread
def test_the_iso6410_minor_radius_is_four_point_eight():
"""Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary."""
assert MINOR12 == pytest.approx(4.8)
def test_the_catalogue_holds_every_transcribed_size():
rows = catalogue()
assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings
assert len(catalogue(standard="ISO 15")) == 33
assert len(catalogue("M12")) == 5
assert len(catalogue("6205")) == 1
assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS)
def test_a_catalogue_row_carries_what_a_drawing_needs():
(row,) = catalogue("6205")
assert row["designation"] == "ISO 15 - 6205"
assert row["standard"] == "ISO 15"
assert row["family"] == "deep_groove_bearing"
assert row["views"] == ["simplified", "detailed"]
assert row["length_required"] is False
assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0}
assert "ISO 15:2017" in row["source"]
bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12")
assert bolt["length_required"] is True
assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5
def test_catalogue_refuses_an_unknown_standard():
with pytest.raises(ValueError, match="ISO 4014"):
catalogue(standard="DIN 931")
def test_parse_designation_normalises_and_splits():
assert parse_designation("iso4014 - m12x60") == {
"designation": "ISO 4014 - M12x60",
"standard": "ISO 4014",
"family": "hex_bolt",
"size": "M12x60",
"thread": "M12",
"length": 60.0,
}
assert parse_designation("6205")["designation"] == "ISO 15 - 6205"
assert parse_designation("ISO 4032 - M12")["length"] is None
def test_parse_designation_refuses_a_missing_or_stray_length():
with pytest.raises(ValueError, match="nominal length"):
parse_designation("ISO 4014 - M12")
with pytest.raises(ValueError, match="takes no length"):
parse_designation("ISO 4032 - M12x60")
with pytest.raises(ValueError, match="catalogue standard"):
parse_designation("ISO 9999 - M12")
def test_an_unknown_size_is_refused_with_the_nearest_entries():
with pytest.raises(ValueError, match=r"ISO 4014 - M12"):
block_spec("ISO 4014 - M13x60", "side")
with pytest.raises(ValueError, match=r"ISO 15 - 6210"):
block_spec("ISO 15 - 6211", "simplified")
with pytest.raises(ValueError, match="approximately"):
block_spec("ISO 4014 - M13x60", "side")
def test_hex_bolt_side_view_is_the_iso6410_representation():
spec = block_spec("ISO 4014 - M12x60", "side")
assert spec["name"] == "STD_ISO_4014_M12X60_SIDE"
assert spec["view"] == "side"
assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125
ents = spec["entities"]
assert len(ents) == 10
assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True
assert ents[0]["points"] == [
[-7.5, -R12],
[0.0, -R12],
[0.0, R12],
[-7.5, R12],
]
assert ents[3] == {
"type": "line",
"x1": 0.0,
"y1": 6.0,
"x2": 60.0,
"y2": 6.0,
"layer": "GEOMETRY",
}
# ISO 6410: minor diameter at 0.8 x major, over the useful thread length.
assert ents[6] == {
"type": "line",
"x1": 30.0,
"y1": MINOR12,
"x2": 60.0,
"y2": MINOR12,
"layer": "GEOMETRY",
}
# ISO 6410: the thread-length limit is a line to the major diameter.
assert ents[8] == {
"type": "line",
"x1": 30.0,
"y1": -6.0,
"x2": 30.0,
"y2": 6.0,
"layer": "GEOMETRY",
}
assert ents[9]["layer"] == "CENTER"
assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12))
def test_a_fully_threaded_screw_has_no_thread_limit_line():
spec = block_spec("ISO 4017 - M12x60", "side")
assert len(spec["entities"]) == 9
assert spec["dims"]["b"] == pytest.approx(60.0)
minor = [e for e in spec["entities"] if e.get("y1") == MINOR12]
assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0
def test_hex_top_view_uses_the_three_quarter_minor_arc():
spec = block_spec("ISO 4014 - M12x60", "top")
ents = spec["entities"]
assert len(ents) == 5
assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6
assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"}
assert ents[2] == {
"type": "arc",
"cx": 0.0,
"cy": 0.0,
"r": MINOR12,
"start_deg": 90.0,
"end_deg": 360.0,
"layer": "GEOMETRY",
}
assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER"
def test_hex_nut_side_view_shows_the_thread_as_hidden_lines():
spec = block_spec("ISO 4032 - M12", "side")
ents = spec["entities"]
assert len(ents) == 8
hidden = [e for e in ents if e["layer"] == "HIDDEN"]
assert len(hidden) == 4
assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12}
assert ents[0]["points"][1] == [10.8, -R12] # nut height m
def test_washer_views():
side = block_spec("ISO 7089 - M12", "side")
assert len(side["entities"]) == 3
assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]]
top = block_spec("ISO 7089 - M12", "top")
assert top["entities"][0] == {
"type": "circle",
"cx": 0.0,
"cy": 0.0,
"r": 12.0,
"layer": "GEOMETRY",
}
assert top["entities"][1]["r"] == 6.5
def test_socket_head_screw_views():
side = block_spec("ISO 4762 - M8x30", "side")
assert len(side["entities"]) == 8
assert side["dims"]["b"] == pytest.approx(28.0)
assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5
top = block_spec("ISO 4762 - M8x30", "top")
assert top["entities"][0]["r"] == 6.5
assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6
def test_bearing_simplified_is_the_iso8826_1_outline_and_cross():
spec = block_spec("ISO 15 - 6205", "simplified")
ents = spec["entities"]
assert len(ents) == 7
assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]]
assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]]
assert ents[2] == {
"type": "line",
"x1": -4.5,
"y1": 19.25,
"x2": 4.5,
"y2": 19.25,
"layer": "GEOMETRY",
}
assert ents[3] == {
"type": "line",
"x1": 0.0,
"y1": 15.2,
"x2": 0.0,
"y2": 23.3,
"layer": "GEOMETRY",
}
assert ents[6]["layer"] == "CENTER"
def test_bearing_detailed_draws_the_element_symbol():
spec = block_spec("ISO 15 - 6205", "detailed")
ents = spec["entities"]
assert len(ents) == 5
assert ents[2] == {
"type": "circle",
"cx": 0.0,
"cy": 19.25,
"r": 4.05,
"layer": "GEOMETRY",
}
assert ents[3]["cy"] == -19.25
def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back():
assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified"
with pytest.raises(ValueError, match="simplified"):
block_spec("ISO 15 - 6205", "top")
with pytest.raises(ValueError, match="side"):
block_spec("ISO 4014 - M12x60", "simplified")
@pytest.mark.parametrize(
("designation", "view"),
[
("ISO 4014 - M12x60", "side"),
("ISO 4014 - M12x60", "top"),
("ISO 4017 - M12x60", "side"),
("ISO 4032 - M12", "side"),
("ISO 4032 - M12", "top"),
("ISO 7089 - M12", "side"),
("ISO 7089 - M12", "top"),
("ISO 4762 - M8x30", "side"),
("ISO 4762 - M8x30", "top"),
("ISO 15 - 6205", "simplified"),
("ISO 15 - 6205", "detailed"),
],
)
def test_every_block_spec_validates_against_block_define(designation, view):
spec = block_spec(designation, view)
validate_entity_specs(spec["entities"])
validate_attdef_specs(spec["attdefs"])
assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"]
assert spec["attdefs"][0]["default"] == spec["designation"]
assert spec["attdefs"][0]["invisible"] is False
assert all(att["invisible"] for att in spec["attdefs"][1:])
assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3]
assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]}
@pytest.mark.asyncio
async def test_insert_defines_the_block_once_and_writes_the_payload(backend):
from engineering.mech.stdparts import insert_std_part
from engineering.mech.xdata import APP_ID, decode
result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8")
assert result["ok"] is True
assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE"
assert result["defined"] is True
assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"]
assert result["view"] == "side"
assert result["layer"] == "GEOMETRY"
assert result["dims"]["s"] == 18.0
stored = await backend.entity_get_xdata(result["handle"], APP_ID)
payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]])
assert payload == {
"v": 1,
"kind": "std_part",
"designation": "ISO 4014 - M12x60",
"standard": "ISO 4014",
"size": "M12x60",
"qty": 1,
"material": "8.8",
}
assert await backend.block_get_attributes(result["handle"]) == {
"DESIG": "ISO 4014 - M12x60",
"STD": "ISO 4014",
"SIZE": "M12x60",
"MAT": "8.8",
}
again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0))
assert again["defined"] is False and again["layers_created"] == []
assert again["payload"]["material"] == ""
@pytest.mark.asyncio
async def test_bearing_inserts_with_its_own_view_name(backend):
from engineering.mech.stdparts import insert_std_part
result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0)
assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED"
assert result["view"] == "simplified"
assert result["rotation"] == 90.0
assert result["dims"]["B"] == 15.0
@pytest.mark.asyncio
async def test_insert_refuses_before_writing_anything(backend):
from engineering.mech.stdparts import insert_std_part
before = len(await backend.entity_list())
with pytest.raises(ValueError, match="Nearest entries"):
await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0))
with pytest.raises(ValueError, match="finite"):
await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0))
with pytest.raises(ValueError, match="no attribute"):
await insert_std_part(
backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"}
)
assert len(await backend.entity_list()) == before
+22 -5
View File
@@ -5,6 +5,8 @@ v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools.
Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting
essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment
surface and nothing else.
v1.6 tracks B+G add the `mech` pack: SECTION 22's three standard-parts tools.
"""
from __future__ import annotations
@@ -78,6 +80,12 @@ SETTINGS_TOOLS = {
"system_prompt_message",
}
MECH_TOOLS = {
"std_part_list",
"std_part_insert",
"std_feature_draw",
}
LEAN_SETTINGS_ESSENTIALS = {
"drawing_apply_standard",
"page_setup_apply",
@@ -86,7 +94,10 @@ LEAN_SETTINGS_ESSENTIALS = {
"textstyle_set_current",
}
ALL_PACKS = ["core", "pid", "settings"]
#: Declaration order - what `tool_packs["available"]` reports.
ALL_PACKS = ["core", "pid", "settings", "mech"]
#: `tool_packs["enabled"]` is sorted, which is no longer the same list.
ALL_PACKS_ENABLED = sorted(ALL_PACKS)
@pytest_asyncio.fixture(autouse=True)
@@ -97,11 +108,15 @@ async def _restore(monkeypatch):
def test_pack_registry_names_real_tools_and_only_them():
assert server.TOOL_PACK_NAMES == ("core", "pid", "settings")
assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")
assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
assert len(SETTINGS_TOOLS) == 22
assert len(MECH_TOOLS) == 3
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["pid"])
assert not (server.PACK_TOOL_NAMES["mech"] & server.PACK_TOOL_NAMES["settings"])
async def test_every_pack_member_is_a_registered_tool():
@@ -117,6 +132,7 @@ async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(
disabled = set(info["disabled_tools"])
assert PID_TOOLS <= disabled
assert SETTINGS_TOOLS <= disabled
assert MECH_TOOLS <= disabled
assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
assert "block_define" not in disabled and "entity_set_xdata" not in disabled
@@ -136,8 +152,8 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
monkeypatch.setattr(config.settings, "tool_packs", "all")
info = await server._apply_tool_profile("full")
disabled = set(info["disabled_tools"])
assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
assert info["tool_packs"]["enabled"] == ALL_PACKS
assert not ((PID_TOOLS | SETTINGS_TOOLS | MECH_TOOLS) & disabled)
assert info["tool_packs"]["enabled"] == ALL_PACKS_ENABLED
async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
@@ -184,7 +200,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["styles"]) == STYLE_TOOLS
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
assert set(groups["environment"]) == SETTINGS_TOOLS
assert len(groups) == 23
assert set(groups["mech"]) == MECH_TOOLS
assert len(groups) == 24
async def test_system_about_reports_packs():
+9 -1
View File
@@ -529,6 +529,13 @@ async def test_tool_group_sizes_are_unchanged():
their secondary tags; those counts returned to their pre-track-E values
when the section tags landed.) Every other number here has been unchanged
since the snapshot was taken.
`mech` appeared (0 -> 3) when v1.6's SECTION 22 opened with the
standard-parts catalogue (`std_part_list` / `std_part_insert` /
`std_feature_draw`); the `mech` tag is ranked directly after `pid` in
`_GROUP_TAG_PRIORITY`, so those tools file under `mech` rather than under
their secondary `query` / `create` tags. This snapshot is branch-local:
the merge task recomputes it once all four track B+G branches have landed.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged():
"environment": 22,
"layers": 14,
"layouts": 12,
"mech": 3,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 204
assert sum(sizes.values()) == 207