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
This commit is contained in:
Umut CAN
2026-09-22 19:12:00 +03:00
co-authored by Claude Opus 5
parent 04d269318a
commit ac6c4075ba
12 changed files with 3563 additions and 2 deletions
+638
View File
@@ -0,0 +1,638 @@
"""Every feature of spec section 5: validation, emitted primitives, dimensions.
Geometry is asserted as emitted primitive tuples - counts, roles, coordinates
to 1e-9 - never as a screenshot. The two features with a real table
(`Keyway` -> DIN 6885 via engineering.keyway, `GearTeeth` -> the involute
generator in engineering.gear) are pinned against those modules rather than
against numbers restated here; the four not-transcribed tables are exercised
through their refusals.
"""
from __future__ import annotations
import math
import pytest
from engineering.gear import generate_full_gear_outline
from engineering.keyway import keyway_dimensions
from engineering.mech.features import (
FEATURE_KINDS,
AxialBore,
BendLine,
CentreHole,
Chamfer,
CornerChamfer,
CornerFillet,
CrossHole,
Fillet,
GearTeeth,
Hole,
HoleCircle,
HolePattern,
Keyway,
ORingGroove,
Pocket,
RetainingGroove,
Slot,
Thread,
Undercut,
feature_from_dict,
mirror_about_x,
)
from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part
from engineering.mech.primitives import Arc, Circle, Line, Poly, Text
CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"}
def shaft(*features) -> RevolvedPart:
return RevolvedPart(
name="shaft",
segments=(Segment(20.0, 25.0), Segment(40.0, 30.0, 10.0), Segment(15.0, 20.0)),
features=tuple(features),
)
def plate(*features) -> PrismaticPart:
return PrismaticPart(
name="plate",
outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
thickness=8.0,
features=tuple(features),
)
def test_the_catalogue_is_the_nineteen_kinds_spec_section_5_names():
assert FEATURE_KINDS == (
"axial_bore",
"bend_line",
"centre_hole",
"chamfer",
"corner_chamfer",
"corner_fillet",
"cross_hole",
"fillet",
"gear_teeth",
"hole",
"hole_circle",
"hole_pattern",
"keyway",
"oring_groove",
"pocket",
"retaining_groove",
"slot",
"thread",
"undercut",
)
def test_feature_from_dict_round_trips_every_kind_it_builds():
payload = {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0, "angle": 45.0}
feature = feature_from_dict(payload)
assert isinstance(feature, Chamfer)
assert feature.to_dict() == payload
def test_feature_from_dict_refuses_an_unknown_kind_and_an_unknown_key():
with pytest.raises(ValueError, match="unknown feature"):
feature_from_dict({"kind": "spline", "id": "s"})
with pytest.raises(ValueError, match="unknown key"):
feature_from_dict({"kind": "chamfer", "id": "c", "at": "start", "size": 1.0, "deg": 30})
def test_mirror_about_x_flips_every_primitive_type():
line, arc, circle, poly, text = mirror_about_x(
[
Line((0.0, 5.0), (10.0, 5.0)),
Arc((0.0, 4.0), 2.0, 30.0, 90.0),
Circle((1.0, 3.0), 2.0),
Poly(((0.0, 1.0), (2.0, 3.0)), True),
Text((1.0, 2.0), "A"),
]
)
assert line.p1 == (0.0, -5.0) and line.p2 == (10.0, -5.0)
assert arc.center == (0.0, -4.0)
assert (arc.start_deg, arc.end_deg) == (270.0, 330.0)
assert circle.center == (1.0, -3.0)
assert poly.points == ((0.0, -1.0), (2.0, -3.0))
assert text.at == (1.0, -2.0)
# -- chamfer and fillet ------------------------------------------------------
def test_a_45_degree_chamfer_at_the_start_replaces_the_corner_with_equal_legs():
feature = Chamfer(id="c1", at="start", size=2.0)
part = shaft(feature)
edit = feature.corner_edit(part)
assert edit["corner"] == (0.0, 12.5)
assert edit["before"] == (0.0, 10.5)
assert edit["after"] == (2.0, 12.5)
assert edit["arc"] is None
assert feature.removal_box(part) == (0.0, 10.5, 2.0, 12.5)
def test_a_30_degree_chamfer_at_the_end_cuts_toward_minus_x():
"""The two replacement points sit on the two surfaces the corner joined.
The silhouette runs left to right, so ``before`` is the point left on the
cylinder (x = 75 - size, r unchanged) and ``after`` is the point left on
the end face (x = 75, r reduced by the radial leg). Neither of them is the
corner itself.
"""
feature = Chamfer(id="c2", at="end", size=3.0, angle=30.0)
part = shaft(feature)
edit = feature.corner_edit(part)
radial = 3.0 * math.tan(math.radians(30.0))
assert edit["corner"] == (75.0, 10.0)
assert edit["before"][0] == pytest.approx(72.0)
assert edit["before"][1] == pytest.approx(10.0)
assert edit["after"][0] == pytest.approx(75.0)
assert edit["after"][1] == pytest.approx(10.0 - radial)
assert edit["arc"] is None
def test_a_chamfer_shows_as_an_extra_circle_in_the_end_view():
feature = Chamfer(id="c1", at="end", size=2.0)
part = shaft(feature)
(circle,) = feature.prims(part, "side", CTX)
assert isinstance(circle, Circle)
assert circle.radius == pytest.approx(8.0)
def test_a_chamfer_bigger_than_the_step_is_refused_by_name():
feature = Chamfer(id="c1", at="start", size=40.0)
with pytest.raises(ValueError, match="c1|size"):
feature.validate(shaft(feature))
def test_a_fillet_at_a_step_up_emits_a_tangent_arc_in_the_lower_right_quadrant():
feature = Fillet(id="f1", at="step:0", radius=2.0)
part = shaft(feature)
edit = feature.corner_edit(part)
assert edit["corner"] == (20.0, 12.5)
assert edit["before"] == (18.0, 12.5)
assert edit["after"] == (20.0, 14.5)
arc = edit["arc"]
assert isinstance(arc, Arc)
assert arc.center == (18.0, 14.5)
assert (arc.radius, arc.start_deg, arc.end_deg) == (2.0, 270.0, 360.0)
def test_a_fillet_at_a_step_down_puts_the_arc_in_the_lower_left_quadrant():
feature = Fillet(id="f2", at="step:1", radius=1.5)
part = shaft(feature)
edit = feature.corner_edit(part)
assert edit["corner"] == (60.0, 10.0)
assert edit["before"] == (60.0, 11.5)
assert edit["after"] == (61.5, 10.0)
assert edit["arc"].center == (61.5, 11.5)
assert (edit["arc"].start_deg, edit["arc"].end_deg) == (180.0, 270.0)
def test_a_fillet_is_only_defined_at_a_step():
feature = Fillet(id="f3", at="start", radius=1.0)
with pytest.raises(ValueError, match="step"):
feature.validate(shaft(feature))
# -- keyway (DIN 6885 through engineering.keyway) ----------------------------
def test_a_keyway_takes_its_width_and_depth_from_din_6885():
feature = Keyway(id="kw", segment=1, length=25.0, offset=5.0)
part = shaft(feature)
feature.validate(part)
table = keyway_dimensions(30.0)
prims = feature.prims(part, "front", CTX)
assert len(prims) == 3
assert all(isinstance(p, Line) and p.role == "visible" for p in prims)
bottom = 15.0 - table["depth_shaft"]
assert prims[0].p1 == (25.0, 15.0) and prims[0].p2 == (25.0, bottom)
assert prims[1].p1 == (25.0, bottom) and prims[1].p2 == (50.0, bottom)
assert prims[2].p1 == (50.0, bottom) and prims[2].p2 == (50.0, 15.0)
assert feature.removal_box(part) == (25.0, bottom, 50.0, 15.0)
def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch():
feature = Keyway(id="kw", segment=1, length=25.0)
part = shaft(feature)
prims = feature.prims(part, "side", CTX)
arcs = [p for p in prims if isinstance(p, Arc)]
lines = [p for p in prims if isinstance(p, Line)]
assert len(arcs) == 1 and len(lines) == 3
width = keyway_dimensions(30.0)["width"]
assert lines[1].p1[0] == pytest.approx(-width / 2.0)
assert lines[1].p2[0] == pytest.approx(width / 2.0)
def test_a_keyway_longer_than_its_segment_is_refused_by_name():
feature = Keyway(id="kw", segment=1, length=50.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
assert "40" in str(excinfo.value)
def test_a_keyway_on_a_segment_that_does_not_exist_is_refused_by_range():
feature = Keyway(id="kw", segment=5, length=10.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
assert "segments 0-2" in str(excinfo.value)
def test_a_keyway_outside_the_din_6885_bore_range_is_refused():
part = RevolvedPart(name="pin", segments=(Segment(20.0, 4.0),))
feature = Keyway(id="kw", segment=0, length=10.0)
with pytest.raises(ValueError, match="DIN 6885"):
feature.validate(part)
# -- thread ------------------------------------------------------------------
def test_an_external_thread_draws_the_iso6410_minor_lines_and_the_limit_line():
feature = Thread(id="th", segment=2, designation="M20", length=12.0)
part = shaft(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
assert len(prims) == 3
assert prims[0].p1 == (60.0, 8.0) and prims[0].p2 == (72.0, 8.0)
assert prims[2].p1 == (72.0, -10.0) and prims[2].p2 == (72.0, 10.0)
def test_a_thread_whose_diameter_does_not_match_its_segment_is_refused_with_both_numbers():
feature = Thread(id="th", segment=1, designation="M20", length=10.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
message = str(excinfo.value)
assert "M20" in message and "30" in message
def test_an_internal_thread_enlarges_the_bore_to_the_major_for_the_section_hatch():
feature = Thread(id="th", segment=0, designation="M10", length=20.0, internal=True)
part = RevolvedPart(
name="boss",
segments=(Segment(30.0, 40.0, 8.0),), # drilled to the ISO 6410 minor, 0.8 x 10
features=(feature,),
)
feature.validate(part)
(edit,) = feature.radial_edit(part)
assert edit == {"kind": "inner", "x0": 0.0, "x1": 20.0, "radius": 5.0}
def test_a_thread_designation_outside_iso_261_is_refused_by_the_table():
feature = Thread(id="th", segment=2, designation="M7", length=5.0)
with pytest.raises(ValueError, match="ISO 261"):
feature.validate(shaft(feature))
# -- the four not-transcribed tables, through their features -----------------
def test_an_undercut_without_an_explicit_profile_is_refused_by_din_509():
feature = Undercut(id="uc", at="step:0", form="E")
with pytest.raises(ValueError, match="DIN 509"):
feature.validate(shaft(feature))
def test_an_undercut_draws_exactly_the_profile_it_is_handed():
profile = ((0.0, 0.0), (0.5, 0.4), (2.0, 0.4), (2.5, 0.0))
feature = Undercut(id="uc", at="step:0", form="E", profile=profile)
part = shaft(feature)
feature.validate(part)
(poly,) = feature.prims(part, "front", CTX)
assert isinstance(poly, Poly) and poly.closed is False
assert poly.points[0] == (20.0, 12.5)
assert poly.points[1] == (19.5, 12.1)
assert poly.points[-1] == (17.5, 12.5)
assert feature.removal_box(part) == (17.5, 12.1, 20.0, 12.5)
def test_a_retaining_groove_without_explicit_dimensions_is_refused_by_din_471():
feature = RetainingGroove(id="rg", x=30.0, d=30.0)
with pytest.raises(ValueError, match="DIN 471"):
feature.validate(shaft(feature))
def test_a_retaining_groove_with_explicit_dimensions_cuts_the_profile():
feature = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
part = shaft(feature)
feature.validate(part)
(edit,) = feature.radial_edit(part)
assert edit["kind"] == "outer"
assert edit["radius"] == pytest.approx(14.3)
assert (edit["x0"], edit["x1"]) == (pytest.approx(29.2), pytest.approx(30.8))
lines = feature.prims(part, "front", CTX)
assert len(lines) == 3
assert lines[1].p1[1] == pytest.approx(14.3)
def test_a_centre_hole_without_explicit_dimensions_is_refused_by_din_332():
feature = CentreHole(id="ch", at="end", form="A", size=2.5)
with pytest.raises(ValueError, match="DIN 332"):
feature.validate(shaft(feature))
def test_a_centre_hole_form_r_is_refused_outright():
feature = CentreHole(id="ch", at="end", form="R", size=2.5, d1=2.5, d2=5.3, t=5.0)
with pytest.raises(ValueError, match="form R"):
feature.validate(shaft(feature))
def test_a_centre_hole_with_explicit_dimensions_draws_a_hidden_60_degree_cone():
feature = CentreHole(id="ch", at="end", form="A", size=2.5, d1=2.5, d2=5.3, t=5.0)
part = shaft(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
assert {p.role for p in prims} == {"hidden"}
cone_depth = (5.3 - 2.5) / 2.0 / math.tan(math.radians(30.0))
assert prims[0].p1 == (75.0, 2.65)
assert prims[0].p2[0] == pytest.approx(75.0 - cone_depth)
assert prims[0].p2[1] == pytest.approx(1.25)
def test_an_oring_groove_without_explicit_dimensions_is_refused_by_iso_3601():
feature = ORingGroove(id="og", x=30.0, cord=3.53)
with pytest.raises(ValueError, match="ISO 3601-2"):
feature.validate(shaft(feature))
def test_an_axial_oring_groove_is_refused_rather_than_drawn_on_the_silhouette():
feature = ORingGroove(id="og", x=30.0, cord=3.53, housing="axial", b=4.7, h=2.7)
with pytest.raises(ValueError, match="face groove"):
feature.validate(shaft(feature))
def test_an_oring_groove_round_trips_because_its_field_is_not_called_kind():
"""``to_dict`` writes the feature kind under ``kind``.
A field of the same name would overwrite the discriminator - the payload
would come back as ``{"kind": "radial", ...}`` and ``feature_from_dict``
would refuse it - so the housing kind is carried by ``housing``.
"""
feature = ORingGroove(id="og", x=30.0, cord=3.53, b=4.7, h=2.7)
payload = feature.to_dict()
assert payload["kind"] == "oring_groove"
assert payload["housing"] == "radial"
assert feature_from_dict(payload) == feature
# -- holes, bores, bolt circles, teeth ---------------------------------------
def test_a_cross_hole_draws_two_hidden_lines_through_the_silhouette():
feature = CrossHole(id="xh", x=30.0, diameter=6.0)
part = shaft(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
assert len(prims) == 2
assert {p.role for p in prims} == {"hidden"}
assert prims[0].p1 == (27.0, -15.0) and prims[0].p2 == (27.0, 15.0)
assert prims[1].p1 == (33.0, -15.0) and prims[1].p2 == (33.0, 15.0)
def test_an_inclined_cross_hole_is_refused_rather_than_drawn_as_if_radial():
feature = CrossHole(id="xh", x=30.0, diameter=6.0, angle=45.0)
with pytest.raises(ValueError, match="angle"):
feature.validate(shaft(feature))
def test_an_axial_bore_edits_the_radial_profile_and_is_not_flagged_as_omitted():
feature = AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)
part = shaft(feature)
feature.validate(part)
(edit,) = feature.radial_edit(part)
assert edit == {"kind": "inner", "x0": 0.0, "x1": 12.0, "radius": 4.0}
assert feature.removal_box(part) is None
def test_two_axial_bores_from_the_same_end_are_refused_with_both_names():
a = AxialBore(id="ab1", at="start", diameter=8.0, depth=12.0)
b = AxialBore(id="ab2", at="start", diameter=6.0, depth=20.0)
part = shaft(a, b)
with pytest.raises(ValueError) as excinfo:
a.validate(part)
assert "ab1" in str(excinfo.value) and "ab2" in str(excinfo.value)
def test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view():
feature = HoleCircle(id="bc", x=10.0, pcd=36.0, count=6, diameter=5.0)
part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
feature.validate(part)
prims = feature.prims(part, "side", CTX)
holes = [p for p in prims if isinstance(p, Circle) and p.role == "visible"]
pitch = [p for p in prims if isinstance(p, Circle) and p.role == "center"]
assert len(holes) == 6 and len(pitch) == 1
assert pitch[0].radius == pytest.approx(18.0)
assert holes[0].center == (pytest.approx(18.0), pytest.approx(0.0))
assert holes[1].center[0] == pytest.approx(18.0 * math.cos(math.radians(60.0)))
def test_a_bolt_circle_that_does_not_fit_the_flange_is_refused_by_name():
feature = HoleCircle(id="bc", x=10.0, pcd=58.0, count=6, diameter=5.0)
part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
with pytest.raises(ValueError, match="bc|pcd"):
feature.validate(part)
def test_gear_teeth_reuse_the_repository_involute_generator():
feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
part = RevolvedPart(name="pinion", segments=(Segment(15.0, 44.0),), features=(feature,))
feature.validate(part)
(poly, pitch) = feature.prims(part, "side", CTX)[:2]
expected = generate_full_gear_outline(2.0, 20, 20.0, 0.0, "RH", (0.0, 0.0))
assert poly.points == tuple(expected)
assert poly.closed is True
assert pitch.radius == pytest.approx(20.0)
def test_gear_teeth_whose_tip_diameter_disagrees_with_the_segment_are_refused():
feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
part = RevolvedPart(name="pinion", segments=(Segment(15.0, 40.0),), features=(feature,))
with pytest.raises(ValueError) as excinfo:
feature.validate(part)
assert "44" in str(excinfo.value) and "40" in str(excinfo.value)
# -- prismatic features ------------------------------------------------------
def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view():
feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0)
part = plate(feature)
feature.validate(part)
front = feature.prims(part, "front", CTX)
circle = next(p for p in front if isinstance(p, Circle))
assert circle.center == (20.0, 25.0) and circle.radius == 5.0
assert sum(1 for p in front if isinstance(p, Line) and p.role == "center") == 2
side = feature.prims(part, "side", CTX)
assert {p.role for p in side} == {"hidden"}
assert side[0].p1 == (20.0, 0.0) and side[0].p2 == (20.0, 8.0)
top = feature.prims(part, "top", CTX)
assert top[0].p1 == (15.0, 0.0)
def test_a_hole_outside_the_outline_is_refused_by_name():
feature = Hole(id="h1", x=200.0, y=25.0, diameter=10.0)
with pytest.raises(ValueError, match="outline"):
feature.validate(plate(feature))
def test_a_tapped_hole_draws_the_minor_circle_and_a_three_quarter_major_arc():
feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0, thread="M10")
part = plate(feature)
feature.validate(part)
front = feature.prims(part, "front", CTX)
circles = [p for p in front if isinstance(p, Circle)]
arcs = [p for p in front if isinstance(p, Arc)]
assert circles[0].radius == pytest.approx(4.0)
assert len(arcs) == 1 and arcs[0].radius == pytest.approx(5.0)
def test_a_slot_is_an_obround_of_two_lines_and_two_semicircles():
feature = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0)
part = plate(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
lines = [p for p in prims if isinstance(p, Line) and p.role == "visible"]
arcs = [p for p in prims if isinstance(p, Arc)]
assert len(lines) == 2 and len(arcs) == 2
assert lines[0].p1 == (pytest.approx(30.0), pytest.approx(30.0))
assert arcs[0].center == (pytest.approx(50.0), pytest.approx(25.0))
assert feature.removal_box(part) == (25.0, 20.0, 55.0, 30.0)
def test_a_slot_narrower_than_it_is_long_is_required():
feature = Slot(id="s1", x=40.0, y=25.0, length=5.0, width=10.0)
with pytest.raises(ValueError, match="length"):
feature.validate(plate(feature))
def test_a_pocket_can_declare_itself_unhatched_in_a_longitudinal_section():
feature = Pocket(
id="p1",
outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)),
depth=4.0,
no_section_hatch=True,
)
part = plate(feature)
feature.validate(part)
(poly,) = feature.prims(part, "front", CTX)
assert poly.closed is True and poly.role == "visible"
assert feature.no_section_hatch is True
assert feature.removal_box(part) == (10.0, 10.0, 30.0, 30.0)
def test_a_pocket_deeper_than_the_plate_is_refused():
feature = Pocket(
id="p1",
outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0)),
depth=20.0,
)
with pytest.raises(ValueError, match="thickness"):
feature.validate(plate(feature))
def test_a_corner_fillet_replaces_a_vertex_with_two_tangent_points_and_an_arc():
feature = CornerFillet(id="cf", vertex=1, radius=10.0)
part = plate(feature)
feature.validate(part)
edit = feature.outline_edit(part)
assert edit["vertex"] == 1
assert edit["before"] == (pytest.approx(70.0), pytest.approx(0.0))
assert edit["after"] == (pytest.approx(80.0), pytest.approx(10.0))
assert edit["arc"].center == (pytest.approx(70.0), pytest.approx(10.0))
assert edit["arc"].radius == pytest.approx(10.0)
def test_a_corner_chamfer_replaces_a_vertex_with_two_points_and_no_arc():
feature = CornerChamfer(id="cc", vertex=1, size=6.0)
part = plate(feature)
feature.validate(part)
edit = feature.outline_edit(part)
assert edit["before"] == (pytest.approx(74.0), pytest.approx(0.0))
assert edit["after"] == (pytest.approx(80.0), pytest.approx(6.0))
assert edit["arc"] is None
def test_a_corner_radius_larger_than_its_edges_is_refused():
feature = CornerFillet(id="cf", vertex=1, radius=60.0)
with pytest.raises(ValueError, match="edge"):
feature.validate(plate(feature))
def test_a_bend_line_is_representation_only_and_says_so():
feature = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0)
part = plate(feature)
feature.validate(part)
line, text = feature.prims(part, "front", CTX)
assert isinstance(line, Line) and line.role == "phantom"
assert isinstance(text, Text)
assert "R2" in text.text and "90" in text.text
assert feature.dims(part, "front", CTX) == ()
def test_a_hole_pattern_expands_before_it_reaches_the_part():
spec = {
"kind": "prismatic",
"name": "plate",
"thickness": 8.0,
"outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
"features": [
{
"kind": "hole_pattern",
"id": "hp",
"pattern": "grid",
"base": {"x": 10.0, "y": 10.0, "diameter": 6.0},
"count": 3,
"spacing": 20.0,
"count_y": 2,
"spacing_y": 25.0,
}
],
}
part = build_part(spec)
assert len(part.features) == 6
assert all(isinstance(f, Hole) for f in part.features)
assert [f.id for f in part.features][:3] == ["hp_0_0", "hp_1_0", "hp_2_0"]
assert part.features[2].x == pytest.approx(50.0)
assert part.features[3].y == pytest.approx(35.0)
def test_a_polar_hole_pattern_places_its_holes_on_the_pitch_circle():
pattern = HolePattern(
id="hp",
pattern="polar",
base={"x": 40.0, "y": 25.0, "diameter": 5.0},
count=4,
pcd=30.0,
)
holes = pattern.expand()
assert len(holes) == 4
assert holes[0].x == pytest.approx(55.0) and holes[0].y == pytest.approx(25.0)
assert holes[1].x == pytest.approx(40.0) and holes[1].y == pytest.approx(40.0)
def test_a_linear_pattern_without_a_spacing_is_refused():
pattern = HolePattern(
id="hp",
pattern="linear",
base={"x": 10.0, "y": 10.0, "diameter": 6.0},
count=3,
)
with pytest.raises(ValueError, match="spacing"):
pattern.expand()
def test_every_feature_declares_its_dimension_intents_against_its_own_id():
features = (
Chamfer(id="c1", at="start", size=2.0),
Keyway(id="kw", segment=1, length=25.0),
Thread(id="th", segment=2, designation="M20", length=12.0),
CrossHole(id="xh", x=30.0, diameter=6.0),
)
part = shaft(*features)
for feature in features:
for intent in feature.dims(part, "front", CTX):
assert intent.feature == feature.id
+13
View File
@@ -89,6 +89,19 @@ def test_a_source_that_does_not_name_its_table_is_refused():
register("TEST 4", {1: {"a": 1}}, Coverage(1.0, 1.0), "made up")
def test_a_table_may_register_its_structure_before_its_rows_are_transcribed():
"""DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 ship exactly this way."""
register("TEST 5", {}, Coverage(0.0, 0.0), SOURCE)
assert "TEST 5" in standards()
with pytest.raises(ValueError, match="TEST 5"):
lookup("TEST 5", 12)
def test_a_rows_argument_that_is_not_a_dict_is_refused():
with pytest.raises(ValueError, match="rows dict"):
register("TEST 6", [{"d": 1.0}], Coverage(1.0, 1.0), SOURCE)
def test_registering_the_same_standard_twice_is_refused():
with pytest.raises(ValueError, match="already registered"):
register("TEST 1", {10: {"d": 10.0}}, Coverage(10.0, 10.0), SOURCE)
+160
View File
@@ -0,0 +1,160 @@
"""The four tables this build does NOT transcribe, and how they refuse.
The track's table rule is absolute: a row that cannot be sourced against the
standard is not shipped, because a wrong relief depth or a wrong groove
diameter is a wrong workshop drawing, while a narrow table is merely narrow.
DIN 509, DIN 471/472, DIN 332-1 and ISO 3601-2 therefore ship with their
structure, their SOURCE and an empty row set, and every lookup is refused by
name and says what to transcribe.
These tests are the gate that keeps that honest in both directions: the
refusal must name the standard and the transcription route, and the shape gate
must fail the moment somebody adds a row that is missing a column the drawing
code reads.
"""
from __future__ import annotations
import pytest
from engineering.mech.standards import standards
from engineering.mech.standards.centres import (
CENTRE_ROW_KEYS,
ROWS_A,
ROWS_B,
centre_hole_dims,
)
from engineering.mech.standards.centres import (
SOURCE as SOURCE_CENTRES,
)
from engineering.mech.standards.orings import (
ORING_ROW_KEYS,
oring_groove_dims,
)
from engineering.mech.standards.orings import (
ROWS as ROWS_ORING,
)
from engineering.mech.standards.orings import (
SOURCE as SOURCE_ORING,
)
from engineering.mech.standards.rings import (
RING_ROW_KEYS,
ROWS_471,
ROWS_472,
ring_groove_dims,
)
from engineering.mech.standards.rings import (
SOURCE as SOURCE_RINGS,
)
from engineering.mech.standards.undercuts import (
ROWS_E,
ROWS_F,
UNDERCUT_ROW_KEYS,
undercut_profile,
)
from engineering.mech.standards.undercuts import (
SOURCE as SOURCE_UNDERCUTS,
)
def test_every_table_is_registered_so_a_later_transcription_is_a_data_edit():
registered = standards()
for name in (
"DIN 509-E",
"DIN 509-F",
"DIN 471",
"DIN 472",
"DIN 332-A",
"DIN 332-B",
"ISO 3601-2",
):
assert name in registered
@pytest.mark.parametrize(
("source", "standard"),
[
(SOURCE_UNDERCUTS, "DIN 509"),
(SOURCE_RINGS, "DIN 471"),
(SOURCE_CENTRES, "DIN 332"),
(SOURCE_ORING, "ISO 3601-2"),
],
)
def test_every_source_names_its_standard_and_edition(source, standard):
assert standard in source
assert any(ch.isdigit() for ch in source.split(":")[-1])
def test_no_row_is_shipped_in_this_build():
assert ROWS_E == {} and ROWS_F == {}
assert ROWS_471 == {} and ROWS_472 == {}
assert ROWS_A == {} and ROWS_B == {}
assert ROWS_ORING == {}
def test_the_undercut_lookup_is_refused_by_name_and_says_what_to_transcribe():
with pytest.raises(ValueError) as excinfo:
undercut_profile("E", 40.0)
message = str(excinfo.value)
assert "DIN 509" in message
assert "form E" in message
assert "40" in message
assert "undercuts.py" in message
def test_an_unknown_undercut_form_is_refused_before_the_coverage_refusal():
with pytest.raises(ValueError, match="forms E and F"):
undercut_profile("G", 40.0)
def test_the_ring_groove_lookup_is_refused_by_name_for_shaft_and_bore():
for standard in ("DIN 471", "DIN 472"):
with pytest.raises(ValueError) as excinfo:
ring_groove_dims(standard, 30.0)
assert standard in str(excinfo.value)
assert "rings.py" in str(excinfo.value)
with pytest.raises(ValueError, match="DIN 471 or DIN 472"):
ring_groove_dims("DIN 6799", 30.0)
def test_the_centre_hole_lookup_is_refused_by_name_and_form_r_is_refused_outright():
with pytest.raises(ValueError) as excinfo:
centre_hole_dims("A", 2.5)
assert "DIN 332" in str(excinfo.value)
assert "centres.py" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
centre_hole_dims("R", 2.5)
assert "form R" in str(excinfo.value)
def test_the_oring_lookup_is_refused_by_name():
with pytest.raises(ValueError) as excinfo:
oring_groove_dims(3.53)
assert "ISO 3601-2" in str(excinfo.value)
assert "orings.py" in str(excinfo.value)
def test_the_row_key_contracts_are_the_ones_the_drawing_code_reads():
assert UNDERCUT_ROW_KEYS == ("profile",)
assert RING_ROW_KEYS == ("m", "d2")
assert CENTRE_ROW_KEYS == ("d1", "d2", "d3", "t")
assert ORING_ROW_KEYS == ("b", "h")
@pytest.mark.parametrize(
("rows", "keys"),
[
(ROWS_E, ("profile",)),
(ROWS_F, ("profile",)),
(ROWS_471, ("m", "d2")),
(ROWS_472, ("m", "d2")),
(ROWS_A, ("d1", "d2", "t")),
(ROWS_B, ("d1", "d2", "d3", "t")),
(ROWS_ORING, ("b", "h")),
],
)
def test_any_row_that_is_ever_added_must_carry_every_key_the_drawing_code_reads(rows, keys):
"""Empty today; this gate fires the moment a row lands without a column."""
for size, row in rows.items():
missing = [key for key in keys if key not in row]
assert not missing, f"row {size} is missing {missing}"
+187
View File
@@ -0,0 +1,187 @@
"""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)