diff --git a/engineering/mech/stdparts.py b/engineering/mech/stdparts.py index 4f969c3..31ea527 100644 --- a/engineering/mech/stdparts.py +++ b/engineering/mech/stdparts.py @@ -49,6 +49,7 @@ from engineering.mech.standards.fasteners import ( across_corners, hex_head, hex_nut, + nominal_diameter, sizes, socket_head, thread_length, @@ -70,6 +71,12 @@ DEFAULT_VIEW = "side" #: ISO 6410 draws the minor diameter of a thread at 0.8 x the major diameter. THREAD_MINOR_RATIO = 0.8 +#: Hexagon orientation shared by the two views of one fastener: vertices at 12 +#: and 6 o'clock, so the plan is ``e`` tall across the vertical and the +#: elevation - which draws the head ``e`` tall with its silhouette edges at +#: ``+/- e / 4`` - is the projection of that same hexagon. +HEX_START_DEG = 30.0 + SOURCE_ISO_6410 = ( "ISO 6410-1:1993 - technical drawings, screw threads and threaded parts, general conventions" ) @@ -143,7 +150,16 @@ def _poly(points, closed: bool = True, layer: str = LAYER_VISIBLE) -> dict: } -def _hex_points(radius: float, start_deg: float = 0.0): +def _hex_points(radius: float, start_deg: float = HEX_START_DEG): + """The six corners of a hexagon of across-corners radius ``radius``. + + The default ``start_deg`` puts vertices at 12 and 6 o'clock, so the plan's + vertical extent is ``2 * radius = e`` and the two remaining vertex pairs sit + at ``+/- radius / 2``. That is exactly the silhouette the elevation builders + draw (full height ``e``, chamfer lines at ``+/- e / 4``), which is what keeps + the two views of one fastener in projection. Do not change it without + changing the elevation to match. + """ return [ ( radius * math.cos(math.radians(start_deg + 60.0 * i)), @@ -743,6 +759,15 @@ def _thread_feature(params: dict): internal = bool(params.get("internal", False)) size = params.get("size") if size: + nominal = nominal_diameter(size) + if abs(nominal - d) > 1e-9: + raise ValueError( + f"thread: params['size']={size!r} is a {nominal} mm thread but " + f"params['d']={d}. The geometry is drawn from d and the pitch is read " + "from the ISO 261 table for size, so a mismatch would report one " + "thread's pitch on another thread's geometry; give the size that " + "matches d, or give params['pitch'] instead." + ) pitch = thread_pitch(size) elif "pitch" in params: pitch = _positive(params, "pitch", "thread") diff --git a/server.py b/server.py index db13456..e7298d4 100644 --- a/server.py +++ b/server.py @@ -8282,7 +8282,9 @@ async def std_feature_draw( dict, Field( description=( - "thread: {d, length, size|pitch, internal}; undercut: {d, form: E|F}; " + "thread: {d, length, size|pitch, internal} (a size must name d's own " + "thread - 'M20' with d=8 is refused, not drawn); " + "undercut: {d, form: E|F}; " "ring_groove: {d, kind: shaft|bore}; centre_hole: {size, form: A|B}; " "oring_groove: {d, cord, kind: shaft|bore}" ) diff --git a/tests/test_mech_std_features.py b/tests/test_mech_std_features.py index a156aea..4744149 100644 --- a/tests/test_mech_std_features.py +++ b/tests/test_mech_std_features.py @@ -100,6 +100,14 @@ def test_a_thread_without_a_size_or_a_pitch_is_refused(): 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 diff --git a/tests/test_mech_stdparts.py b/tests/test_mech_stdparts.py index ba41da0..fdd1424 100644 --- a/tests/test_mech_stdparts.py +++ b/tests/test_mech_stdparts.py @@ -159,6 +159,55 @@ def test_hex_top_view_uses_the_three_quarter_minor_arc(): 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"]