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
+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