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
233 lines
8.1 KiB
Python
233 lines
8.1 KiB
Python
"""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
|