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
+48
View File
@@ -6,6 +6,31 @@ and alphabetical — keep every side's names.
from __future__ import annotations
from .features import (
FEATURE_KINDS,
AxialBore,
BendLine,
CentreHole,
Chamfer,
CornerChamfer,
CornerFillet,
CrossHole,
Feature,
Fillet,
GearTeeth,
Hole,
HoleCircle,
HolePattern,
Keyway,
ORingGroove,
Pocket,
RetainingGroove,
Slot,
Thread,
Undercut,
feature_from_dict,
mirror_about_x,
)
from .part import (
PART_KINDS,
PrismaticPart,
@@ -44,26 +69,49 @@ from .primitives import (
)
__all__ = [
"FEATURE_KINDS",
"PART_KINDS",
"ROLES",
"ROLE_LAYER",
"Arc",
"AxialBore",
"BendLine",
"CentreHole",
"Chamfer",
"Circle",
"CornerChamfer",
"CornerFillet",
"CrossHole",
"DimIntent",
"Feature",
"Fillet",
"GearTeeth",
"HatchArea",
"Hole",
"HoleCircle",
"HolePattern",
"Keyway",
"Line",
"ORingGroove",
"Pocket",
"Poly",
"Prim",
"PrismaticPart",
"Pt",
"RetainingGroove",
"RevolvedPart",
"Role",
"Segment",
"Slot",
"Text",
"Thread",
"Undercut",
"bbox",
"build_part",
"feature_from_dict",
"inner_radius_at",
"layer_for",
"mirror_about_x",
"outer_radius_at",
"outline_bbox",
"part_length",
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -13,6 +13,12 @@ wrong row ships a wrong workshop drawing; a narrow table is merely narrow.
variants at the same size (DIN 509 forms E and F, DIN 332 forms A/B/R). ``**kw``
selects among those variants and is refused when it selects none or more than
one — an ambiguous lookup never quietly picks the first row.
``rows`` may be **empty**: that is a table whose structure, coverage and SOURCE
ship before any row could be verified against the standard (DIN 509, DIN 471,
DIN 472, DIN 332-1 and ISO 3601-2 in this build). Registering it keeps the
later transcription a data edit, and every lookup against it is refused by
name — which is exactly the table rule, not an exception to it.
"""
from __future__ import annotations
@@ -62,8 +68,11 @@ def register(standard: str, rows: dict, coverage: Coverage, source: str) -> None
raise ValueError("standards.register: standard must be a non-empty name")
if standard in _REGISTRY:
raise ValueError(f"standards.register: {standard!r} is already registered")
if not rows:
raise ValueError(f"standards.register: {standard!r} registered no rows")
if not isinstance(rows, dict):
raise ValueError(
f"standards.register: {standard!r} must register a rows dict; an empty one is "
"the table whose structure ships before its rows are transcribed"
)
if not isinstance(source, str) or "table" not in source.lower():
raise ValueError(
f"standards.register: {standard!r} needs a SOURCE naming the standard, its "
+67
View File
@@ -0,0 +1,67 @@
"""DIN 332-1 centre holes, forms A and B.
SOURCE
------
DIN 332-1:1986-04, *Centre holes 60 degrees - Forms R, A and B*, Table 1.
COVERAGE
--------
**No rows are transcribed in this build** (same rule as ``undercuts.py``).
Form R is not drawn by this build at all: its profile is an arc form whose
radius is not transcribed here, and it is refused outright rather than
approximated by the straight 60 degree cone of forms A and B.
Extending is a data edit: fill ``ROWS_A`` / ``ROWS_B`` with
``{pilot_diameter_mm: {"d1": ..., "d2": ..., "d3": ..., "t": ...}}`` (``d3``
only for form B's protective 120 degree countersink) and widen the coverage.
"""
from __future__ import annotations
from engineering.mech.standards import Coverage, register
SOURCE = "DIN 332-1:1986-04, Table 1 (forms A and B) - NOT TRANSCRIBED in this build"
#: The keys a DIN 332 row must carry; form A rows may omit "d3".
CENTRE_ROW_KEYS: tuple[str, ...] = ("d1", "d2", "d3", "t")
FORMS: tuple[str, ...] = ("A", "B")
ROWS_A: dict[float, dict] = {}
ROWS_B: dict[float, dict] = {}
COVERAGE_A = Coverage(0.0, 0.0, "mm")
COVERAGE_B = Coverage(0.0, 0.0, "mm")
register("DIN 332-A", ROWS_A, COVERAGE_A, SOURCE)
register("DIN 332-B", ROWS_B, COVERAGE_B, SOURCE)
_ROWS = {"A": ROWS_A, "B": ROWS_B}
def centre_hole_dims(form: str, size: float) -> dict:
"""``{"d1","d2","d3","t"}`` for a pilot diameter, or a named refusal."""
key = str(form or "").strip().upper()
if key == "R":
raise ValueError(
"DIN 332 form R: this build draws forms A and B only. Form R's arc profile "
"is not transcribed here and is refused rather than approximated by the "
"straight 60 degree cone."
)
if key not in FORMS:
raise ValueError(f"DIN 332: expected form A or B, got {form!r}.")
rows = _ROWS[key]
if not rows:
raise ValueError(
f"DIN 332 form {key}: no rows are transcribed in this build, so no centre hole "
f"can be looked up for d1 = {float(size):g} mm. Pass d1, d2 (and d3 for form B) "
f"and t explicitly, or transcribe {SOURCE.split(' - ')[0]} into "
"engineering/mech/standards/centres.py. Nothing is interpolated."
)
pilot = float(size)
if pilot in rows:
row = rows[pilot]
return {name: (float(row[name]) if name in row else None) for name in CENTRE_ROW_KEYS}
raise ValueError(
f"DIN 332 form {key}: d1 = {pilot:g} mm has no transcribed row (sizes: {sorted(rows)})."
)
+54
View File
@@ -0,0 +1,54 @@
"""ISO 3601-2 O-ring housing (groove) dimensions.
SOURCE
------
ISO 3601-2:2016, *Fluid power systems - O-rings - Part 2: Housing dimensions
for general applications*, Tables 1 and 2 (radial static housings).
COVERAGE
--------
**No rows are transcribed in this build** (same rule as ``undercuts.py``): the
groove width and depth per cord diameter decide whether the seal works, and
they are not written here from memory. Every lookup is refused by name.
Extending is a data edit: fill ``ROWS`` with
``{cord_diameter_mm: {"b": groove_width_mm, "h": groove_depth_mm}}`` - the two
keys group S's ``std_feature_draw`` already reads - and widen ``COVERAGE``.
"""
from __future__ import annotations
from engineering.mech.standards import Coverage, register
SOURCE = "ISO 3601-2:2016, Tables 1-2 (radial static housings) - NOT TRANSCRIBED in this build"
#: The keys every ISO 3601-2 row must carry.
ORING_ROW_KEYS: tuple[str, ...] = ("b", "h")
KINDS: tuple[str, ...] = ("radial", "axial")
ROWS: dict[float, dict] = {}
COVERAGE = Coverage(0.0, 0.0, "mm")
register("ISO 3601-2", ROWS, COVERAGE, SOURCE)
def oring_groove_dims(cord: float, kind: str = "radial") -> dict:
"""``{"b": width, "h": depth}`` for a cord diameter, or a named refusal."""
key = str(kind or "").strip().lower()
if key not in KINDS:
raise ValueError(f"O-ring housing: expected kind 'radial' or 'axial', got {kind!r}.")
if not ROWS:
raise ValueError(
f"ISO 3601-2: no rows are transcribed in this build, so no {key} housing can be "
f"looked up for a {float(cord):g} mm cord. Pass b and h explicitly, or transcribe "
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/orings.py. "
"Nothing is interpolated."
)
size = float(cord)
if size in ROWS:
return {name: float(ROWS[size][name]) for name in ORING_ROW_KEYS}
raise ValueError(
f"ISO 3601-2: cord {size:g} mm has no transcribed row (sizes: {sorted(ROWS)})."
)
+60
View File
@@ -0,0 +1,60 @@
"""DIN 471 / DIN 472 retaining-ring groove dimensions.
SOURCE
------
DIN 471:2011-04, *Retaining rings for shafts*, Table 1 (groove diameter d2 and
groove width m) and DIN 472:2011-04, *Retaining rings for bores*, Table 1.
COVERAGE
--------
**No rows are transcribed in this build**, for the reason given in
``undercuts.py``: the groove diameter is the number a wrong drawing gets wrong,
and it is not written here from memory. Every lookup is refused by name.
Extending is a data edit: fill ``ROWS_471`` / ``ROWS_472`` with
``{nominal_shaft_or_bore_mm: {"m": groove_width_mm, "d2": groove_diameter_mm}}``
and widen the coverage. Those two keys are the contract group S's
``std_feature_draw`` already reads.
"""
from __future__ import annotations
from engineering.mech.standards import Coverage, register
SOURCE = (
"DIN 471:2011-04 Table 1 (shafts) and DIN 472:2011-04 Table 1 (bores) "
"- NOT TRANSCRIBED in this build"
)
#: The keys every DIN 471/472 row must carry.
RING_ROW_KEYS: tuple[str, ...] = ("m", "d2")
ROWS_471: dict[float, dict] = {}
ROWS_472: dict[float, dict] = {}
COVERAGE_471 = Coverage(0.0, 0.0, "mm")
COVERAGE_472 = Coverage(0.0, 0.0, "mm")
register("DIN 471", ROWS_471, COVERAGE_471, SOURCE)
register("DIN 472", ROWS_472, COVERAGE_472, SOURCE)
_ROWS = {"DIN 471": ROWS_471, "DIN 472": ROWS_472}
def ring_groove_dims(standard: str, d: float) -> dict:
"""``{"m": ..., "d2": ...}`` for a nominal size, or a named refusal."""
key = " ".join(str(standard or "").split()).upper()
if key not in _ROWS:
raise ValueError(f"retaining-ring groove: expected DIN 471 or DIN 472, got {standard!r}.")
rows = _ROWS[key]
if not rows:
raise ValueError(
f"{key}: no rows are transcribed in this build, so no groove can be looked up "
f"for d = {float(d):g} mm. Pass m and d2 explicitly, or transcribe "
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/rings.py. "
"Nothing is interpolated."
)
size = float(d)
if size in rows:
return {name: float(rows[size][name]) for name in RING_ROW_KEYS}
raise ValueError(f"{key}: d = {size:g} mm has no transcribed row (sizes: {sorted(rows)}).")
+250
View File
@@ -0,0 +1,250 @@
"""ISO 261 metric thread pitches and the ISO 6410 drawing representation.
SOURCE
------
**Pitches.** ISO 261:2022, *ISO general purpose metric screw threads - General
plan*, Table 1 (coarse pitch series) and Table 2 (fine pitch series).
**Transcribed coverage.** The first-choice diameters M1.6 to M64, plus the
second-choice diameters M14, M18, M22, M27, M33, M39, M45, M52 and M60. The
small second-choice diameters (M1.8, M2.2, M3.5, M4.5, M5.5, M7, M9, M11),
every third-choice diameter, everything above M64 and the fine pitch series
below M8 are **not** transcribed and are refused by name rather than
interpolated - the table rule of this track. Where a diameter's fine series
here is shorter than the standard's, the pitch that is missing is refused by
name as well; widening it is a data edit in ``FINE`` with no code change.
**Basic diameters.** ISO 68-1:1998 fundamental triangle, H = (sqrt(3)/2) * P,
with ISO 724's basic minor diameter d1 = d - 1.25*H = d - 1.082532*P and basic
pitch diameter d2 = d - 0.75*H = d - 0.649519*P. That is the standard's own
formula rather than transcribed table data, which is why it can be pinned
against published ISO 724 values instead of trusted: M20x2.5 reproduces
17.294 / 18.376 exactly, M10x1.5 reproduces 8.376 / 9.026, M8x1.25 reproduces
6.647 / 7.188.
**Representation.** ISO 6410-1:1993, *Technical drawings - Screw threads and
threaded parts*: the minor diameter is drawn as a thin continuous line at
0.8 x the major diameter (`MINOR_RATIO`); in a view along the axis that same
circle is drawn as three quarters of a circle with the gap in one quadrant;
the thread run-out / thread-length limit is a line across the full major
diameter. An internal thread shown without a section is drawn hidden
throughout.
"""
from __future__ import annotations
import math
import re
from engineering.mech.primitives import Arc, Line, Prim
from engineering.mech.standards import Coverage, register
SOURCE_PITCH = (
"ISO 261:2022 Table 1 (coarse) and Table 2 (fine); first-choice M1.6-M64 "
"plus second-choice M14, M18, M22, M27, M33, M39, M45, M52, M60"
)
SOURCE_BASIC = "ISO 68-1:1998 fundamental triangle; values pinned against ISO 724 Table 1"
SOURCE_REPRESENTATION = "ISO 6410-1:1993"
#: ISO 6410-1: the minor diameter is represented at 0.8 x the major diameter.
MINOR_RATIO = 0.8
#: ISO 261 Table 1 - coarse pitch series, major diameter (mm) -> pitch (mm).
COARSE: dict[float, float] = {
1.6: 0.35,
2.0: 0.40,
2.5: 0.45,
3.0: 0.50,
4.0: 0.70,
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,
18.0: 2.50,
20.0: 2.50,
22.0: 2.50,
24.0: 3.00,
27.0: 3.00,
30.0: 3.50,
33.0: 3.50,
36.0: 4.00,
39.0: 4.00,
42.0: 4.50,
45.0: 4.50,
48.0: 5.00,
52.0: 5.00,
56.0: 5.50,
60.0: 5.50,
64.0: 6.00,
}
#: ISO 261 Table 2 - fine pitch series, major diameter (mm) -> pitches (mm),
#: coarsest first. Transcribed from M8 upwards only.
FINE: dict[float, tuple[float, ...]] = {
8.0: (1.0, 0.75),
10.0: (1.25, 1.0, 0.75),
12.0: (1.5, 1.25, 1.0),
14.0: (1.5, 1.0),
16.0: (1.5, 1.0),
18.0: (2.0, 1.5, 1.0),
20.0: (2.0, 1.5, 1.0),
22.0: (2.0, 1.5, 1.0),
24.0: (2.0, 1.5, 1.0),
27.0: (2.0, 1.5, 1.0),
30.0: (2.0, 1.5, 1.0),
33.0: (2.0, 1.5),
36.0: (3.0, 2.0, 1.5),
39.0: (3.0, 2.0, 1.5),
42.0: (3.0, 2.0, 1.5),
45.0: (3.0, 2.0, 1.5),
48.0: (3.0, 2.0, 1.5),
52.0: (3.0, 2.0, 1.5),
56.0: (4.0, 3.0, 2.0, 1.5),
60.0: (4.0, 3.0, 2.0, 1.5),
64.0: (4.0, 3.0, 2.0, 1.5),
}
DIAMETERS: tuple[float, ...] = tuple(sorted(COARSE))
COVERAGE = Coverage(DIAMETERS[0], DIAMETERS[-1], "mm")
_DESIGNATION_RE = re.compile(
r"^\s*M\s*(\d+(?:[.,]\d+)?)\s*(?:[x×X]\s*(\d+(?:[.,]\d+)?))?\s*(?:-\s*(LH|RH))?\s*$",
re.IGNORECASE,
)
register(
"ISO 261",
{d: {"pitch": COARSE[d], "fine": FINE.get(d, ())} for d in DIAMETERS},
COVERAGE,
SOURCE_PITCH,
)
def _number(text: str) -> float:
return float(text.replace(",", "."))
def _pitch_text(pitch: float) -> str:
"""A pitch in millimetres, always carrying a decimal: 2.5, 2.0, 0.75."""
text = f"{float(pitch):g}"
return text if "." in text else f"{text}.0"
def pitch_for(d: float, pitch: float | None = None) -> float:
"""The coarse pitch for ``d``, or validate an explicit fine pitch against the series."""
diameter = float(d)
if diameter not in COARSE:
raise ValueError(
f"ISO 261: M{diameter:g} is not in the transcribed table. "
f"Coverage is M{DIAMETERS[0]:g}-M{DIAMETERS[-1]:g}, the first-choice diameters plus "
"the second-choice M14, M18, M22, M27, M33, M39, M45, M52 and M60; the remaining "
"second-choice diameters and every third-choice diameter are not transcribed and "
"are not interpolated."
)
if pitch is None:
return COARSE[diameter]
value = float(pitch)
if abs(value - COARSE[diameter]) < 1e-9:
return COARSE[diameter]
series = FINE.get(diameter, ())
for candidate in series:
if abs(value - candidate) < 1e-9:
return candidate
known = ", ".join(_pitch_text(p) for p in (COARSE[diameter], *series))
raise ValueError(
f"ISO 261: M{diameter:g} has no pitch {value:g} mm in the transcribed series. "
f"Transcribed pitches for M{diameter:g}: {known}."
)
def parse_designation(designation: str) -> dict:
"""``M20`` / ``M20x1.5`` / ``M20x1.5-LH`` -> d, pitch, series, hand."""
match = _DESIGNATION_RE.match(str(designation or ""))
if not match:
raise ValueError(
f"designation {designation!r}: expected an ISO metric thread such as "
"'M20', 'M20x1.5' or 'M20x1.5-LH'."
)
diameter = _number(match.group(1))
raw_pitch = _number(match.group(2)) if match.group(2) else None
pitch = pitch_for(diameter, raw_pitch)
hand = "left" if (match.group(3) or "RH").upper() == "LH" else "right"
series = "coarse" if abs(pitch - COARSE[diameter]) < 1e-9 else "fine"
text = f"M{diameter:g}" if raw_pitch is None else f"M{diameter:g}x{pitch:g}"
if hand == "left":
text += "-LH"
return {
"designation": text,
"d": diameter,
"pitch": pitch,
"series": series,
"hand": hand,
}
def _h(pitch: float) -> float:
"""ISO 68-1 fundamental triangle height."""
return math.sqrt(3.0) / 2.0 * float(pitch)
def basic_minor_diameter(d: float, pitch: float) -> float:
"""ISO 724 basic minor diameter d1 = d - 1.25 * H."""
return float(d) - 1.25 * _h(pitch)
def basic_pitch_diameter(d: float, pitch: float) -> float:
"""ISO 724 basic pitch diameter d2 = d - 0.75 * H."""
return float(d) - 0.75 * _h(pitch)
def thread_axial_prims(
*,
x0: float,
length: float,
d: float,
internal: bool = False,
minor_ratio: float = MINOR_RATIO,
limit_line: bool = True,
) -> tuple[Prim, ...]:
"""ISO 6410 representation in a view along the axis (axis on y = 0, +X along it).
External: the two minor-diameter lines at 0.8 x d plus the thread-length
limit line across the full major diameter. Internal (not sectioned): the
same pair at the *major* diameter, hidden, because the drawn bore already
carries the minor.
"""
x1 = float(x0) + float(length)
role = "hidden" if internal else "visible"
radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0
prims: list[Prim] = [
Line((float(x0), radius), (x1, radius), role),
Line((float(x0), -radius), (x1, -radius), role),
]
if limit_line:
major = float(d) / 2.0
prims.append(Line((x1, -major), (x1, major), role))
return tuple(prims)
def thread_end_prims(
*,
center: tuple[float, float],
d: float,
internal: bool = False,
gap_center_deg: float = 45.0,
minor_ratio: float = MINOR_RATIO,
) -> tuple[Prim, ...]:
"""ISO 6410 three-quarter circle for a view along the thread axis.
External: three quarters of the *minor* circle. Internal: three quarters of
the *major* circle. The quarter-circle gap is centred on ``gap_center_deg``
(45 deg = the upper-right quadrant, the usual placement).
"""
radius = float(d) / 2.0 if internal else float(minor_ratio) * float(d) / 2.0
start = (float(gap_center_deg) + 45.0) % 360.0
end = (start + 270.0) % 360.0
return (Arc((float(center[0]), float(center[1])), radius, start, end, "visible"),)
+67
View File
@@ -0,0 +1,67 @@
"""DIN 509 relief grooves (Freistiche), forms E and F.
SOURCE
------
DIN 509:1998-06, *Technische Zeichnungen - Freistiche*, Table 1 (forms E and F).
COVERAGE
--------
**No rows are transcribed in this build.** The track's table rule is absolute:
a row that cannot be verified against the standard's own table is not shipped,
because a wrong relief depth is a wrong workshop drawing while a narrow table
is merely narrow. Every lookup here is therefore refused by name and names the
route to fix it.
Extending this module is a **data edit with no code change**: fill ``ROWS_E`` /
``ROWS_F`` with ``{shaft_diameter_mm: {"profile": [[along, depth], ...]}}`` and
widen ``COVERAGE_E`` / ``COVERAGE_F`` to the transcribed range. ``profile`` is
the standard's own published relief profile expressed as (distance along the
axis from the shoulder corner, depth below the smaller cylinder) pairs in
millimetres - the same ``profile`` key group S's ``std_feature_draw`` reads -
so nothing downstream ever reconstructs a profile it was not handed.
"""
from __future__ import annotations
from engineering.mech.standards import Coverage, register
SOURCE = "DIN 509:1998-06, Table 1 (forms E and F) - NOT TRANSCRIBED in this build"
#: The keys every DIN 509 row must carry for the drawing code to use it.
UNDERCUT_ROW_KEYS: tuple[str, ...] = ("profile",)
FORMS: tuple[str, ...] = ("E", "F")
ROWS_E: dict[float, dict] = {}
ROWS_F: dict[float, dict] = {}
COVERAGE_E = Coverage(0.0, 0.0, "mm")
COVERAGE_F = Coverage(0.0, 0.0, "mm")
register("DIN 509-E", ROWS_E, COVERAGE_E, SOURCE)
register("DIN 509-F", ROWS_F, COVERAGE_F, SOURCE)
_ROWS = {"E": ROWS_E, "F": ROWS_F}
def undercut_profile(form: str, d: float) -> tuple[tuple[float, float], ...]:
"""The published relief profile for a shaft diameter, or a named refusal."""
key = str(form or "").strip().upper()
if key not in FORMS:
raise ValueError(f"DIN 509 defines forms E and F; got {form!r}.")
rows = _ROWS[key]
if not rows:
raise ValueError(
f"DIN 509 form {key}: no rows are transcribed in this build, so no relief "
f"profile can be looked up for d = {float(d):g} mm. Pass the profile "
"explicitly (Undercut(profile=[[along, depth], ...])), or transcribe "
f"{SOURCE.split(' - ')[0]} into engineering/mech/standards/undercuts.py. "
"Nothing is interpolated and no profile is invented."
)
size = float(d)
if size in rows:
return tuple(tuple(float(v) for v in pair) for pair in rows[size]["profile"])
raise ValueError(
f"DIN 509 form {key}: d = {size:g} mm has no transcribed row "
f"(transcribed sizes: {sorted(rows)})."
)
+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)