Files
autocad-mcp-pro/tests/test_mech_threads.py
Umut CANandClaude Opus 5 ac6c4075ba feat(task-3): the feature catalogue and its standards tables
engineering/mech/features.py is the nineteen typed features of spec section 5,
each answering the same four questions - is my placement legal, what do I
contribute to this view, to a cut face, and what dimension do I want - plus the
five structural hooks the view engine needs: removal_box, radial_edit,
corner_edit, outline_edit and omission. Pure arithmetic; a test asserts emitted
primitive tuples, not a screenshot.

Nothing restates a table the repository already owns: Keyway takes its width
and depth from engineering.keyway (DIN 6885-1) and is refused by name outside
that table's bore range; GearTeeth takes its outline from engineering.gear's
involute generator and is refused when its tip diameter disagrees with the
segment it sits on.

standards/threads.py transcribes ISO 261 Table 1 and Table 2 - first-choice
M1.6-M64 plus second-choice M14/M18/M22/M27/M33/M39/M45/M52/M60 - and computes
ISO 724's basic minor and pitch diameters from ISO 68-1's own formula, pinned
against the published values (M20x2.5 -> 17.294 / 18.376). The remaining
second-choice diameters, every third-choice diameter and fine pitches below M8
are not transcribed and are refused by name, never interpolated. The drawing
rules are ISO 6410: minor at 0.8 x major, a thread-length limit line across the
major, a three-quarter circle in the end view, and an internal thread whose
section hatch runs to the major diameter.

standards/{undercuts,rings,centres,orings}.py ship DIN 509, DIN 471/472,
DIN 332-1 and ISO 3601-2 with their SOURCE, their row-key contract and an
**empty** row set. That is deliberate and is the track's table rule taken at its
word: a row that could not be verified against the standard is not shipped,
because a wrong relief depth or groove diameter is a wrong workshop drawing
while a narrow table is merely narrow. Every lookup is refused by name and says
what to transcribe and where; the features accept explicit dimensions so the
geometry is fully exercised, and DIN 332 form R is refused outright rather than
approximated by the straight 60 degree cone of forms A and B. Filling any of
these tables later is a data edit with no code change, and a shape test already
fails any row that lands without a column the drawing code reads.

Three deviations from the plan text, each measured rather than assumed
(the merge agent should keep exactly one copy of the first):

1. standards/__init__.py (Task 1, on main) refused a registration with no rows
   ("registered no rows"), which would have made the four not-transcribed
   tables impossible to register at all - and registering them is what keeps a
   later transcription a data edit. register() now refuses a rows argument that
   is not a dict and accepts an empty one; two tests in tests/test_mech_standards.py
   pin both halves. No existing test asserted the old refusal.

2. ORingGroove's housing kind is carried by `housing`, not `kind`. A dataclass
   field named `kind` overwrites the discriminator _Base.to_dict() writes:
   measured, Probe(id='og').to_dict() == {'kind': 'radial', 'id': 'og'}, so
   feature_from_dict would then refuse its own payload as an unknown feature
   kind and no part carrying an O-ring groove could round-trip through XDATA.
   A new test pins the round trip.

3. The plan's end-chamfer test asserted after == (75.0, 10.0) - the corner
   itself - and before == (72.0, 8.268), a point on neither surface. The real
   edit is before (72.0, 10.0) on the cylinder and after (75.0, 8.268) on the
   end face, consistent with the 45-degree start chamfer the same file pins.
   The test was corrected to the measured geometry; the behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:12:00 +03:00

188 lines
5.8 KiB
Python

"""ISO 261 pitches, ISO 724 basic diameters and the ISO 6410 representation.
Provenance: every asserted pitch is a value the module transcribed from
ISO 261:2022; every asserted basic diameter is a published ISO 724 value that
the ISO 68-1 formula has to reproduce exactly. The coverage refusals are
asserted at both ends, because a size outside the transcribed table must be
refused by name and never interpolated.
"""
from __future__ import annotations
import math
import pytest
from engineering.mech.primitives import Arc, Line
from engineering.mech.standards import lookup
from engineering.mech.standards.threads import (
COARSE,
DIAMETERS,
FINE,
MINOR_RATIO,
basic_minor_diameter,
basic_pitch_diameter,
parse_designation,
pitch_for,
thread_axial_prims,
thread_end_prims,
)
@pytest.mark.parametrize(
("d", "pitch"),
[
(1.6, 0.35),
(2.0, 0.40),
(3.0, 0.50),
(5.0, 0.80),
(6.0, 1.00),
(8.0, 1.25),
(10.0, 1.50),
(12.0, 1.75),
(14.0, 2.00),
(16.0, 2.00),
(20.0, 2.50),
(24.0, 3.00),
(30.0, 3.50),
(33.0, 3.50),
(36.0, 4.00),
(42.0, 4.50),
(48.0, 5.00),
(56.0, 5.50),
(64.0, 6.00),
],
)
def test_iso261_coarse_pitches_are_the_transcribed_values(d, pitch):
assert COARSE[d] == pitch
def test_the_transcribed_diameters_are_first_choice_plus_six_second_choice():
assert DIAMETERS[0] == 1.6
assert DIAMETERS[-1] == 64.0
assert {14.0, 18.0, 22.0, 27.0, 33.0, 39.0} <= set(DIAMETERS)
# third-choice diameters are deliberately absent
assert 7.0 not in DIAMETERS
assert 9.0 not in DIAMETERS
assert 3.5 not in DIAMETERS
@pytest.mark.parametrize(
("d", "pitches"),
[
(8.0, (1.0, 0.75)),
(10.0, (1.25, 1.0, 0.75)),
(12.0, (1.5, 1.25, 1.0)),
(16.0, (1.5, 1.0)),
(20.0, (2.0, 1.5, 1.0)),
(24.0, (2.0, 1.5, 1.0)),
(36.0, (3.0, 2.0, 1.5)),
(64.0, (4.0, 3.0, 2.0, 1.5)),
],
)
def test_iso261_fine_pitch_series(d, pitches):
assert FINE[d] == pitches
def test_a_bare_designation_resolves_to_the_coarse_pitch():
assert parse_designation("M20") == {
"designation": "M20",
"d": 20.0,
"pitch": 2.5,
"series": "coarse",
"hand": "right",
}
def test_a_fine_designation_resolves_and_keeps_its_hand():
parsed = parse_designation("M20x1.5-LH")
assert parsed["d"] == 20.0
assert parsed["pitch"] == 1.5
assert parsed["series"] == "fine"
assert parsed["hand"] == "left"
def test_a_diameter_outside_the_transcribed_table_is_refused_by_name():
with pytest.raises(ValueError) as excinfo:
parse_designation("M7")
message = str(excinfo.value)
assert "ISO 261" in message
assert "M7" in message
with pytest.raises(ValueError, match="ISO 261"):
parse_designation("M70")
def test_a_fine_pitch_that_is_not_in_the_series_is_refused_and_lists_the_series():
with pytest.raises(ValueError) as excinfo:
parse_designation("M20x1.25")
message = str(excinfo.value)
assert "M20" in message
assert "2.0" in message and "1.5" in message and "1.0" in message
def test_a_malformed_designation_is_refused():
with pytest.raises(ValueError, match="designation"):
parse_designation("20mm")
def test_pitch_for_matches_parse_designation():
assert pitch_for(20.0) == 2.5
assert pitch_for(20.0, 1.5) == 1.5
with pytest.raises(ValueError):
pitch_for(20.0, 1.25)
@pytest.mark.parametrize(
("d", "pitch", "d1", "d2"),
[
(20.0, 2.50, 17.294, 18.376),
(10.0, 1.50, 8.376, 9.026),
(8.0, 1.25, 6.647, 7.188),
(12.0, 1.75, 10.106, 10.863),
(6.0, 1.00, 4.917, 5.350),
],
)
def test_iso68_1_reproduces_the_published_iso724_basic_diameters(d, pitch, d1, d2):
assert basic_minor_diameter(d, pitch) == pytest.approx(d1, abs=5e-4)
assert basic_pitch_diameter(d, pitch) == pytest.approx(d2, abs=5e-4)
def test_the_registry_carries_the_pitch_table_and_refuses_outside_its_coverage():
assert lookup("ISO 261", 20.0)["pitch"] == 2.5
with pytest.raises(ValueError, match="ISO 261"):
lookup("ISO 261", 70.0)
def test_iso6410_axial_representation_of_an_external_thread():
prims = thread_axial_prims(x0=10.0, length=30.0, d=20.0)
lines = [p for p in prims if isinstance(p, Line)]
assert len(prims) == 3 and len(lines) == 3
minor = MINOR_RATIO * 20.0 / 2.0
assert {round(line.p1[1], 9) for line in lines[:2]} == {round(minor, 9), round(-minor, 9)}
assert lines[0].p1 == (10.0, minor) and lines[0].p2 == (40.0, minor)
assert lines[1].p1 == (10.0, -minor) and lines[1].p2 == (40.0, -minor)
# the thread-length limit line runs the full major diameter
assert lines[2].p1 == (40.0, -10.0) and lines[2].p2 == (40.0, 10.0)
assert {line.role for line in lines} == {"visible"}
def test_iso6410_axial_representation_of_an_internal_thread_is_hidden():
prims = thread_axial_prims(x0=0.0, length=20.0, d=12.0, internal=True)
assert {p.role for p in prims} == {"hidden"}
assert prims[0].p1 == (0.0, 6.0) and prims[0].p2 == (20.0, 6.0)
def test_iso6410_end_view_is_three_quarters_of_a_circle():
(arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0)
assert isinstance(arc, Arc)
assert arc.radius == pytest.approx(8.0)
assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0)
assert arc.start_deg == pytest.approx(90.0)
def test_the_end_view_gap_can_be_moved_and_stays_a_quarter():
(arc,) = thread_end_prims(center=(0.0, 0.0), d=20.0, gap_center_deg=135.0)
assert arc.start_deg == pytest.approx(180.0)
assert (arc.end_deg - arc.start_deg) % 360.0 == pytest.approx(270.0)
assert math.isclose(arc.radius, 8.0)