Two wrong-drawing bugs in SECTION 22.
1. _hex_points defaulted to start_deg=0, putting vertices at 3 and 9 o'clock,
so the plan of an M12 hex head measured 18 mm (across flats) across the
vertical while the elevation drew that same head 20.78 mm tall (across
corners e, silhouette edges at +/- e/4 - the projection of a hexagon with
vertices top and bottom). Place the two blocks in projection and the two
views of one part disagree. The default is now 30 deg, named HEX_START_DEG
and documented on both the constant and _hex_points as the convention the
elevation is the projection of, so a future fourth caller cannot pick the
other one by accident. The three top builders (_hex_fastener_top,
_hex_nut_top, _socket_head_top) inherit it.
2. _thread_feature read the pitch from params['size'] via the ISO 261 table
while drawing the geometry from params['d'], never cross-checking the two.
feature_spec('thread', {'d': 8.0, 'length': 20.0, 'size': 'M20'}) drew an M8
minor diameter and returned dims['pitch'] = 2.5 with ISO 261 cited as the
source - one thread's pitch on another thread's geometry, a silent wrong
number carrying a standards citation. A mismatch is now refused by name,
like every other check in the module. std_feature_draw's params description
says so.
Guard tests: the old top-view test asserted only len(points) == 6 and never a
vertex coordinate, so the orientation was untested. Added the vertex
coordinates, the plan-vs-elevation silhouette-height invariant across ISO 4014
/ 4017 / 4032, the socket screw's dk invariant, and the refused thread pair.
No ActiveX member is added or changed by either fix; both are pure geometry and
validation, so no live measurement is cited. The live AutoCAD could not be
probed for a confirmation run - GetActiveObject blocked past 150 s under
sibling contention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
241 lines
8.5 KiB
Python
241 lines
8.5 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_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
|