From bf8b3baf74f8cbcbe279c0f09ad54c7944bfb69b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 20:01:51 +0300 Subject: [PATCH] fix(task-9): put the fastener plan in projection with its elevation, and refuse a thread size that is not d 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/stdparts.py | 27 +++++++++++++++++- server.py | 4 ++- tests/test_mech_std_features.py | 8 ++++++ tests/test_mech_stdparts.py | 49 +++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) 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"]