From ac6c4075ba7e358dba1984dc7924d2ac88bf90e3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:12:00 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 48 + engineering/mech/features.py | 2008 +++++++++++++++++++++++ engineering/mech/standards/__init__.py | 13 +- engineering/mech/standards/centres.py | 67 + engineering/mech/standards/orings.py | 54 + engineering/mech/standards/rings.py | 60 + engineering/mech/standards/threads.py | 250 +++ engineering/mech/standards/undercuts.py | 67 + tests/test_mech_features.py | 638 +++++++ tests/test_mech_standards.py | 13 + tests/test_mech_standards_coverage.py | 160 ++ tests/test_mech_threads.py | 187 +++ 12 files changed, 3563 insertions(+), 2 deletions(-) create mode 100644 engineering/mech/features.py create mode 100644 engineering/mech/standards/centres.py create mode 100644 engineering/mech/standards/orings.py create mode 100644 engineering/mech/standards/rings.py create mode 100644 engineering/mech/standards/threads.py create mode 100644 engineering/mech/standards/undercuts.py create mode 100644 tests/test_mech_features.py create mode 100644 tests/test_mech_standards_coverage.py create mode 100644 tests/test_mech_threads.py diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index c8be734..4c71d20 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -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", diff --git a/engineering/mech/features.py b/engineering/mech/features.py new file mode 100644 index 0000000..34cc21d --- /dev/null +++ b/engineering/mech/features.py @@ -0,0 +1,2008 @@ +"""Every feature of the part model: validation, projected geometry, dimensions. + +Pure: no backend, no async, no DXF. A feature answers four questions about the +part it sits on - *is my placement legal*, *what do I contribute to this view*, +*what do I contribute to a cut face*, *what dimension do I want* - and five +structural ones the view engine asks: + +``removal_box(part)`` + the axis-aligned box of material this feature removes, **in the front + view's plane** (revolved: x along the axis, y radial; prismatic: the + outline's own XY). ``None`` opts out - used by features whose removal is + not a box (an annulus, an axisymmetric bore). +``radial_edit(part)`` + ``{"kind": "inner"|"outer", "x0", "x1", "radius"}`` records that change the + revolved radial profile, which is what makes both the silhouette and the + section cut face come out of one calculation. +``corner_edit(part)`` + ``{"corner", "before", "after", "arc"}`` - replace one silhouette corner + with a chamfer line or a tangent fillet arc. +``outline_edit(part)`` + the prismatic equivalent, keyed by outline vertex index. +``omission(part, view)`` + why this feature has no geometry in that view, for the honesty rule: a + feature that contributes nothing and edits nothing is reported in the + view's ``omitted`` list rather than silently dropped. + +Two features reuse the repository rather than restating a table: +``Keyway`` takes its width and depth from ``engineering.keyway`` (DIN 6885-1) +and ``GearTeeth`` takes its outline from ``engineering.gear``'s involute +generator. ``Thread`` takes its pitch from ``standards/threads.py`` (ISO 261) +and its geometry from ISO 6410. ``Undercut``, ``RetainingGroove``, +``CentreHole`` and ``ORingGroove`` take their dimensions from the registered +standards tables, which are not transcribed in this build - so each of them +either receives explicit dimensions or is refused by name. None of them +invents a number. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from dataclasses import dataclass, fields +from typing import Any, ClassVar, Protocol, runtime_checkable + +from engineering.gear import generate_full_gear_outline +from engineering.keyway import keyway_dimensions +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + point_in_outline, + segment_bounds, +) +from engineering.mech.primitives import Arc, Circle, DimIntent, HatchArea, Line, Poly, Prim, Text +from engineering.mech.standards.centres import centre_hole_dims +from engineering.mech.standards.orings import KINDS as ORING_KINDS +from engineering.mech.standards.orings import oring_groove_dims +from engineering.mech.standards.rings import ring_groove_dims +from engineering.mech.standards.threads import ( + MINOR_RATIO, + parse_designation, + thread_axial_prims, + thread_end_prims, +) +from engineering.mech.standards.undercuts import undercut_profile + +_EPS = 1e-9 + + +@runtime_checkable +class Feature(Protocol): + """The surface the part model and the view engine rely on.""" + + id: str + + def validate(self, part) -> None: ... + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: ... + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: ... + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: ... + + +# -- shared helpers ---------------------------------------------------------- + + +def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]: + """Mirror primitives about the y = 0 axis (the revolved part's axis).""" + out: list[Prim] = [] + for prim in prims: + if isinstance(prim, Line): + out.append(Line((prim.p1[0], -prim.p1[1]), (prim.p2[0], -prim.p2[1]), prim.role)) + elif isinstance(prim, Arc): + out.append( + Arc( + (prim.center[0], -prim.center[1]), + prim.radius, + (-prim.end_deg) % 360.0, + (-prim.start_deg) % 360.0, + prim.role, + ) + ) + elif isinstance(prim, Circle): + out.append(Circle((prim.center[0], -prim.center[1]), prim.radius, prim.role)) + elif isinstance(prim, Poly): + out.append(Poly(tuple((x, -y) for x, y in prim.points), prim.closed, prim.role)) + elif isinstance(prim, HatchArea): + out.append( + HatchArea( + tuple(tuple((x, -y) for x, y in loop) for loop in prim.loops), + prim.material, + ) + ) + elif isinstance(prim, Text): + out.append( + Text((prim.at[0], -prim.at[1]), prim.text, prim.height, prim.rotation, prim.role) + ) + else: # pragma: no cover - the union is closed + raise ValueError(f"mirror_about_x: unknown primitive {type(prim).__name__}.") + return tuple(out) + + +def _number(value: Any, path: str, *, positive: bool = True) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{path}: {value!r} is not a finite number.") + if positive and number <= _EPS: + raise ValueError(f"{path}: must be greater than zero, got {number}.") + return number + + +def _require_revolved(part, kind: str) -> RevolvedPart: + if not isinstance(part, RevolvedPart): + raise ValueError(f"{kind} applies to a revolved part, not a {type(part).__name__}.") + return part + + +def _require_prismatic(part, kind: str) -> PrismaticPart: + if not isinstance(part, PrismaticPart): + raise ValueError(f"{kind} applies to a prismatic part, not a {type(part).__name__}.") + return part + + +def _segment_index(part: RevolvedPart, index: Any, path: str = "segment") -> int: + try: + value = int(index) + except (TypeError, ValueError): + raise ValueError(f"{path}: expected a segment index, got {index!r}.") from None + count = len(part.segments) + if not 0 <= value < count: + raise ValueError( + f"{path} {value} is outside the {count}-segment part (segments 0-{count - 1})." + ) + return value + + +def _parse_at(at: str, part: RevolvedPart, *, allow_step: bool = True) -> tuple[str, int]: + text = str(at or "").strip().lower() + if text in ("start", "end"): + return text, 0 + if text.startswith("step:"): + if not allow_step: + raise ValueError(f"at {at!r}: this feature is placed at 'start' or 'end' only.") + try: + index = int(text.split(":", 1)[1]) + except ValueError: + raise ValueError(f"at {at!r}: expected 'step:'.") from None + last = len(part.segments) - 2 + if last < 0 or not 0 <= index <= last: + raise ValueError( + f"at 'step:{index}': the part has {len(part.segments)} segment(s), so its steps " + f"are step:0-step:{max(last, 0)}." + ) + return "step", index + raise ValueError(f"at {at!r}: expected 'start', 'end' or 'step:'.") + + +def _radius_right_end(part: RevolvedPart, index: int) -> float: + segment = part.segments[index] + return (segment.taper_to if segment.taper_to is not None else segment.d_outer) / 2.0 + + +def _radius_left_end(part: RevolvedPart, index: int) -> float: + return part.segments[index].d_outer / 2.0 + + +def _anchor(feature, part: RevolvedPart) -> tuple[float, float, int]: + """(x, radius, direction) of the corner a chamfer/fillet/undercut sits on.""" + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0.0, _radius_left_end(part, 0), +1 + if kind == "end": + last = len(part.segments) - 1 + return part_length(part), _radius_right_end(part, last), -1 + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return (x, left, -1) if left >= right else (x, right, +1) + + +def _shoulder(feature, part: RevolvedPart) -> tuple[float, float, int]: + """(x, radius of the SMALLER cylinder, direction into it) for a relief groove.""" + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0.0, _radius_left_end(part, 0), +1 + if kind == "end": + last = len(part.segments) - 1 + return part_length(part), _radius_right_end(part, last), -1 + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return (x, right, +1) if left >= right else (x, left, -1) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, tuple): + return [_jsonable(item) for item in value] + if isinstance(value, list): + return [_jsonable(item) for item in value] + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + return value + + +def _tuplify(value: Any) -> Any: + if isinstance(value, list): + return tuple(_tuplify(item) for item in value) + return value + + +# -- the base every feature shares ------------------------------------------- + + +_REGISTRY: dict[str, type] = {} + + +def _register(cls): + _REGISTRY[cls.KIND] = cls + return cls + + +@dataclass(frozen=True, kw_only=True) +class _Base: + id: str + + KIND: ClassVar[str] = "" + #: ISO 128-3: a rib or web cut longitudinally is not hatched. Features that + #: can be one override this as a real field. + no_section_hatch = False + #: True when the feature's *front* primitives describe only the +Y half of + #: an axisymmetric cut, so the view engine mirrors them about the axis. A + #: keyway is not axisymmetric (it sits on one face); a ring groove is. + axisymmetric: ClassVar[bool] = False + + def validate(self, part) -> None: + if not isinstance(self.id, str) or not self.id.strip(): + raise ValueError("id: a non-empty feature id is required.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + return () + + def cut_prims(self, part, plane, ctx: dict) -> tuple[Prim, ...]: + return self.prims(part, "front", ctx) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + return () + + def removal_box(self, part) -> tuple[float, float, float, float] | None: + return None + + def radial_edit(self, part) -> tuple[dict, ...]: + return () + + def corner_edit(self, part) -> dict | None: + return None + + def outline_edit(self, part) -> dict | None: + return None + + def omission(self, part, view: str) -> str | None: + return f"{type(self).__name__} has no {view} projection" + + def to_dict(self) -> dict: + payload = {"kind": self.KIND} + for field_ in fields(self): + payload[field_.name] = _jsonable(getattr(self, field_.name)) + return payload + + +def feature_from_dict(d: dict) -> Feature: + """Build a feature from its dict form; the ``kind`` key selects the class.""" + if not isinstance(d, dict): + raise ValueError(f"expected a dict with a 'kind' key, got {type(d).__name__}.") + kind = str(d.get("kind", "")).strip().lower() + if kind not in _REGISTRY: + raise ValueError(f"unknown feature kind {d.get('kind')!r}. Known kinds: {FEATURE_KINDS}.") + cls = _REGISTRY[kind] + names = {field_.name for field_ in fields(cls)} + payload = {key: value for key, value in d.items() if key != "kind"} + unknown = sorted(set(payload) - names) + if unknown: + raise ValueError(f"{kind}: unknown key(s) {unknown}; it takes {sorted(names)}.") + try: + return cls(**{key: _tuplify(value) for key, value in payload.items()}) + except TypeError as exc: + raise ValueError(f"{kind}: {exc}") from exc + + +# -- revolved features ------------------------------------------------------- + + +@_register +@dataclass(frozen=True, kw_only=True) +class Chamfer(_Base): + """ISO 128-3 corner chamfer at an end or a step. ``size`` is the axial leg.""" + + KIND: ClassVar[str] = "chamfer" + at: str + size: float + angle: float = 45.0 + + def _legs(self) -> tuple[float, float]: + """(axial leg, radial leg). + + 45 degrees is special-cased to an exact 1.0 slope: + ``math.tan(math.radians(45))`` is 0.9999999999999999, and the + overwhelmingly common chamfer would otherwise carry that error into + every coordinate it produces. + """ + angle = float(self.angle) + slope = 1.0 if abs(angle - 45.0) < 1e-12 else math.tan(math.radians(angle)) + return float(self.size), float(self.size) * slope + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Chamfer") + _number(self.size, "size") + angle = _number(self.angle, "angle") + if not 0.0 < angle < 90.0: + raise ValueError(f"angle: a chamfer angle is between 0 and 90 degrees, got {angle}.") + x, radius, _ = _anchor(self, part) + axial, radial = self._legs() + if radial >= radius: + raise ValueError( + f"size {self.size} at {self.at!r} cuts {radial:.3f} mm off a {radius:.3f} mm " + "radius: the chamfer is larger than the material it sits on." + ) + if axial > part_length(part): + raise ValueError(f"size {self.size} is longer than the part ({part_length(part)} mm).") + del x + + def corner_edit(self, part) -> dict: + x, radius, direction = _anchor(self, _require_revolved(part, "Chamfer")) + axial, radial = self._legs() + if direction > 0: + before, after = (x, radius - radial), (x + axial, radius) + else: + before, after = (x - axial, radius), (x, radius - radial) + return {"corner": (x, radius), "before": before, "after": after, "arc": None} + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "side": + return () + part = _require_revolved(part, "Chamfer") + _, radius, _ = _anchor(self, part) + _, radial = self._legs() + return (Circle((0.0, 0.0), radius - radial, "visible"),) + + def removal_box(self, part): + edit = self.corner_edit(part) + xs = (edit["before"][0], edit["after"][0]) + ys = (edit["before"][1], edit["after"][1]) + return (min(xs), min(ys), max(xs), max(ys)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.corner_edit(part) + return ( + DimIntent( + kind="aligned", + p1=edit["before"], + p2=edit["after"], + text_override=f"{float(self.size):g}x{float(self.angle):g}°", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a chamfer has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Fillet(_Base): + """A tangent fillet radius in the inside corner of a step.""" + + KIND: ClassVar[str] = "fillet" + at: str + radius: float + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Fillet") + radius = _number(self.radius, "radius") + kind, index = _parse_at(self.at, part) + if kind != "step": + raise ValueError( + f"at {self.at!r}: a fillet is defined at a step ('step:'); an end " + "corner takes a Chamfer." + ) + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + if abs(left - right) < _EPS: + raise ValueError(f"at {self.at!r}: the two segments have the same diameter, no step.") + if radius >= abs(left - right): + raise ValueError( + f"radius {radius} is not smaller than the step height {abs(left - right):.3f} mm." + ) + shortest = min(part.segments[index].length, part.segments[index + 1].length) + if radius >= shortest: + raise ValueError( + f"radius {radius} is not smaller than the shortest segment {shortest}." + ) + + def corner_edit(self, part) -> dict: + part = _require_revolved(part, "Fillet") + _, index = _parse_at(self.at, part) + x = segment_bounds(part)[index][1] + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + radius = float(self.radius) + if left < right: # step up going right: the small cylinder is on the left + corner = (x, left) + before = (x - radius, left) + after = (x, left + radius) + center = (x - radius, left + radius) + arc = Arc(center, radius, 270.0, 360.0, "visible") + else: # step down going right: the small cylinder is on the right + corner = (x, right) + before = (x, right + radius) + after = (x + radius, right) + center = (x + radius, right + radius) + arc = Arc(center, radius, 180.0, 270.0, "visible") + return {"corner": corner, "before": before, "after": after, "arc": arc} + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.corner_edit(part) + return ( + DimIntent( + kind="radius", + p1=edit["arc"].center, + p2=edit["before"], + text_override=f"R{float(self.radius):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + if view == "front": + return None + return "a step fillet projects onto the end view as a circle that coincides with the step" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Keyway(_Base): + """DIN 6885-1 keyway on the +Y face of a segment. + + Width and depth come from ``engineering.keyway.keyway_dimensions`` unless + both are given. Because the slot is modelled on the +Y face, the front view + shows its floor and its two end walls as visible lines; the width edges + coincide with the silhouette and are not drawn twice. + """ + + KIND: ClassVar[str] = "keyway" + segment: int + length: float + offset: float = 0.0 + from_end: str = "left" + width: float | None = None + depth: float | None = None + + def _table(self, part) -> dict: + index = _segment_index(part, self.segment) + diameter = part.segments[index].d_outer + if self.width is not None and self.depth is not None: + return {"width": float(self.width), "depth_shaft": float(self.depth)} + try: + table = keyway_dimensions(diameter) + except ValueError as exc: + raise ValueError(f"DIN 6885: {exc}") from exc + return { + "width": float(self.width) if self.width is not None else table["width"], + "depth_shaft": float(self.depth) if self.depth is not None else table["depth_shaft"], + } + + def _span(self, part) -> tuple[float, float, float, float, float]: + index = _segment_index(part, self.segment) + x0, x1 = segment_bounds(part)[index] + table = self._table(part) + offset = float(self.offset) + length = float(self.length) + if str(self.from_end).strip().lower() == "right": + xa = x1 - offset - length + else: + xa = x0 + offset + radius = part.segments[index].d_outer / 2.0 + return xa, xa + length, radius, table["width"], table["depth_shaft"] + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Keyway") + index = _segment_index(part, self.segment) + _number(self.length, "length") + _number(self.offset, "offset", positive=False) + if str(self.from_end).strip().lower() not in ("left", "right"): + raise ValueError(f"from_end: expected 'left' or 'right', got {self.from_end!r}.") + segment_length = part.segments[index].length + if float(self.offset) + float(self.length) > segment_length + _EPS: + raise ValueError( + f"length {self.length} + offset {self.offset} exceeds segment {index}'s " + f"{segment_length} mm." + ) + table = self._table(part) + radius = part.segments[index].d_outer / 2.0 + if table["depth_shaft"] >= radius: + raise ValueError( + f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "Keyway") + xa, xb, radius, width, depth = self._span(part) + if view == "front": + floor = radius - depth + return ( + Line((xa, radius), (xa, floor), "visible"), + Line((xa, floor), (xb, floor), "visible"), + Line((xb, floor), (xb, radius), "visible"), + ) + if view == "side": + half = width / 2.0 + top = math.sqrt(max(radius * radius - half * half, 0.0)) + floor = radius - depth + start = math.degrees(math.atan2(top, half)) % 360.0 + end = math.degrees(math.atan2(top, -half)) % 360.0 + return ( + Arc((0.0, 0.0), radius, start, end, "visible"), + Line((-half, top), (-half, floor), "visible"), + Line((-half, floor), (half, floor), "visible"), + Line((half, floor), (half, top), "visible"), + ) + return () + + def removal_box(self, part): + xa, xb, radius, _, depth = self._span(part) + return (xa, radius - depth, xb, radius) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + xa, xb, radius, width, depth = self._span(part) + if view == "front": + return (DimIntent(kind="linear", p1=(xa, radius), p2=(xb, radius), feature=self.id),) + if view == "side": + floor = radius - depth + return ( + DimIntent( + kind="linear", + p1=(-width / 2.0, floor), + p2=(width / 2.0, floor), + feature=self.id, + ), + DimIntent( + kind="linear", + p1=(0.0, -radius), + p2=(0.0, floor), + text_override=f"{radius + floor:g}", + feature=self.id, + ), + ) + return () + + def omission(self, part, view: str) -> str | None: + if view in ("front", "side"): + return None + return "a keyway on the +Y face coincides with the outline in the top view" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Thread(_Base): + """An ISO 261 thread drawn with the ISO 6410 representation.""" + + KIND: ClassVar[str] = "thread" + segment: int + designation: str + length: float + offset: float = 0.0 + from_end: str = "left" + internal: bool = False + + def _parsed(self) -> dict: + return parse_designation(self.designation) + + def _span(self, part) -> tuple[float, float, float]: + index = _segment_index(part, self.segment) + x0, x1 = segment_bounds(part)[index] + length = float(self.length) + if str(self.from_end).strip().lower() == "right": + xa = x1 - float(self.offset) - length + else: + xa = x0 + float(self.offset) + return xa, xa + length, self._parsed()["d"] + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Thread") + index = _segment_index(part, self.segment) + parsed = self._parsed() + _number(self.length, "length") + _number(self.offset, "offset", positive=False) + segment = part.segments[index] + if float(self.offset) + float(self.length) > segment.length + _EPS: + raise ValueError( + f"length {self.length} + offset {self.offset} exceeds segment {index}'s " + f"{segment.length} mm." + ) + if self.internal: + # ISO 6410: a tapped hole is modelled with the *drilled* bore, i.e. + # the minor diameter at 0.8 x major, and the major is what the + # section hatch then runs to (radial_edit below). + wanted = MINOR_RATIO * parsed["d"] + if abs(wanted - segment.d_inner) > 0.01: + raise ValueError( + f"{parsed['designation']} internal is drawn with its minor diameter at " + f"{wanted:g} mm (ISO 6410, 0.8 x major) but segment {index}'s bore is " + f"{segment.d_inner:g} mm. Drill the segment to the minor diameter, or " + "name the thread that fits it." + ) + elif abs(parsed["d"] - segment.d_outer) > 0.01: + raise ValueError( + f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but " + f"segment {index}'s outside is {segment.d_outer:g} mm. Cut the segment to the " + "thread diameter, or name the thread that fits it." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "Thread") + xa, xb, d = self._span(part) + if view == "front": + return thread_axial_prims(x0=xa, length=xb - xa, d=d, internal=bool(self.internal)) + if view == "side": + return thread_end_prims(center=(0.0, 0.0), d=d, internal=bool(self.internal)) + return () + + def radial_edit(self, part) -> tuple[dict, ...]: + if not self.internal: + return () + # ISO 6410 section convention: the hatch of an internal thread runs to + # the major diameter, so the cut face's bore is the major, not the minor. + xa, xb, d = self._span(part) + return ({"kind": "inner", "x0": xa, "x1": xb, "radius": d / 2.0},) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + xa, _, d = self._span(part) + return ( + DimIntent( + kind="diameter", + p1=(xa, d / 2.0), + p2=(xa, -d / 2.0), + text_override=self._parsed()["designation"], + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a thread has no {view} projection" + + @property + def minor_ratio(self) -> float: + return MINOR_RATIO + + +@_register +@dataclass(frozen=True, kw_only=True) +class Undercut(_Base): + """A DIN 509 relief groove drawn from the profile the standard publishes. + + ``profile`` is a sequence of ``(along, depth)`` pairs in millimetres, + measured from the shoulder corner into the smaller cylinder. With no + explicit profile the registered DIN 509 table is asked - and refuses by + name, because no row is transcribed in this build. Nothing is reconstructed. + """ + + KIND: ClassVar[str] = "undercut" + axisymmetric: ClassVar[bool] = True + at: str + form: str = "E" + profile: tuple[tuple[float, float], ...] | None = None + + def _profile(self, part) -> tuple[tuple[float, float], ...]: + if self.profile is not None: + return tuple((float(a), float(b)) for a, b in self.profile) + _, radius, _ = _shoulder(self, part) + return undercut_profile(self.form, radius * 2.0) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "Undercut") + _parse_at(self.at, part) + profile = self._profile(part) + if len(profile) < 2: + raise ValueError("profile: a relief profile needs at least two (along, depth) pairs.") + previous = -math.inf + for index, (along, depth) in enumerate(profile): + _number(along, f"profile[{index}].along", positive=False) + _number(depth, f"profile[{index}].depth", positive=False) + if along < previous - _EPS: + raise ValueError(f"profile[{index}].along: the profile must run monotonically.") + previous = along + _, radius, _ = _shoulder(self, part) + deepest = max(depth for _, depth in profile) + if deepest >= radius: + raise ValueError( + f"profile: the deepest relief {deepest} mm is not smaller than the " + f"{radius} mm radius it is cut into." + ) + + def _points(self, part) -> tuple[tuple[float, float], ...]: + x, radius, direction = _shoulder(self, part) + return tuple( + (x + direction * along, radius - depth) for along, depth in self._profile(part) + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + return (Poly(self._points(_require_revolved(part, "Undercut")), False, "visible"),) + + def removal_box(self, part): + points = self._points(part) + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return (min(xs), min(ys), max(xs), max(ys)) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a relief groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class RetainingGroove(_Base): + """A DIN 471 (shaft) or DIN 472 (bore) retaining-ring groove.""" + + KIND: ClassVar[str] = "retaining_groove" + axisymmetric: ClassVar[bool] = True + x: float + d: float + where: str = "shaft" + m: float | None = None + d2: float | None = None + standard: str | None = None + + def _standard(self) -> str: + if self.standard: + return " ".join(str(self.standard).split()).upper() + return "DIN 471" if str(self.where).strip().lower() == "shaft" else "DIN 472" + + def _dims(self) -> dict: + if self.m is not None and self.d2 is not None: + return {"m": float(self.m), "d2": float(self.d2)} + return ring_groove_dims(self._standard(), float(self.d)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "RetainingGroove") + where = str(self.where).strip().lower() + if where not in ("shaft", "bore"): + raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.") + x = _number(self.x, "x", positive=False) + _number(self.d, "d") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + dims = self._dims() + _number(dims["m"], "m") + _number(dims["d2"], "d2") + radius = outer_radius_at(part, x) + bore = inner_radius_at(part, x) + if where == "shaft" and dims["d2"] / 2.0 >= radius: + raise ValueError( + f"d2 {dims['d2']} mm does not cut into the {radius * 2.0:g} mm shaft at x={x}." + ) + if where == "bore" and dims["d2"] / 2.0 <= bore: + raise ValueError( + f"d2 {dims['d2']} mm does not cut into the {bore * 2.0:g} mm bore at x={x}." + ) + + def _span(self, part) -> tuple[float, float, float, float]: + dims = self._dims() + x = float(self.x) + half = dims["m"] / 2.0 + surface = ( + outer_radius_at(part, x) + if str(self.where).strip().lower() == "shaft" + else inner_radius_at(part, x) + ) + return x - half, x + half, dims["d2"] / 2.0, surface + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1, groove_r, _ = self._span(part) + kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner" + return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + x0, x1, groove_r, surface = self._span(_require_revolved(part, "RetainingGroove")) + return ( + Line((x0, surface), (x0, groove_r), "visible"), + Line((x0, groove_r), (x1, groove_r), "visible"), + Line((x1, groove_r), (x1, surface), "visible"), + ) + + def removal_box(self, part): + x0, x1, groove_r, surface = self._span(part) + return (x0, min(groove_r, surface), x1, max(groove_r, surface)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x0, x1, groove_r, _ = self._span(part) + dims = self._dims() + return ( + DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id), + DimIntent( + kind="diameter", + p1=(float(self.x), groove_r), + p2=(float(self.x), -groove_r), + text_override=f"⌀{dims['d2']:g} ({self._standard()})", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a ring groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CentreHole(_Base): + """A DIN 332-1 centre hole, forms A and B. Form R is refused, not approximated.""" + + KIND: ClassVar[str] = "centre_hole" + at: str = "end" + form: str = "A" + size: float = 0.0 + d1: float | None = None + d2: float | None = None + d3: float | None = None + t: float | None = None + + def _dims(self) -> dict: + form = str(self.form).strip().upper() + explicit = self.d1 is not None and self.d2 is not None and self.t is not None + if form == "B": + explicit = explicit and self.d3 is not None + if explicit: + return { + "d1": float(self.d1), + "d2": float(self.d2), + "d3": float(self.d3) if self.d3 is not None else None, + "t": float(self.t), + } + return centre_hole_dims(self.form, float(self.size)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "CentreHole") + _parse_at(self.at, part, allow_step=False) + if str(self.form).strip().upper() == "R": + # the table module owns the wording; ask it so there is one message + centre_hole_dims("R", float(self.size)) + dims = self._dims() + _number(dims["d1"], "d1") + _number(dims["d2"], "d2") + _number(dims["t"], "t") + if dims["d2"] <= dims["d1"]: + raise ValueError(f"d2 {dims['d2']} must be larger than d1 {dims['d1']}.") + kind, _ = _parse_at(self.at, part, allow_step=False) + x = 0.0 if kind == "start" else part_length(part) + if dims["d2"] / 2.0 >= outer_radius_at(part, x): + raise ValueError( + f"d2 {dims['d2']} mm does not fit the {outer_radius_at(part, x) * 2.0:g} mm face." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "CentreHole") + dims = self._dims() + kind, _ = _parse_at(self.at, part, allow_step=False) + face = 0.0 if kind == "start" else part_length(part) + step = 1.0 if kind == "start" else -1.0 + r1, r2 = dims["d1"] / 2.0, dims["d2"] / 2.0 + cone = (r2 - r1) / math.tan(math.radians(30.0)) # 60 deg included angle + if view == "front": + prims: list[Prim] = [ + Line((face, r2), (face + step * cone, r1), "hidden"), + Line((face, -r2), (face + step * cone, -r1), "hidden"), + Line((face + step * cone, r1), (face + step * dims["t"], r1), "hidden"), + Line((face + step * cone, -r1), (face + step * dims["t"], -r1), "hidden"), + Line( + (face + step * dims["t"], -r1), + (face + step * dims["t"], r1), + "hidden", + ), + ] + if str(self.form).strip().upper() == "B" and dims.get("d3"): + r3 = dims["d3"] / 2.0 + guard = (r3 - r2) / math.tan(math.radians(60.0)) # 120 deg protective cone + prims.insert(0, Line((face, r3), (face + step * guard, r2), "hidden")) + prims.insert(1, Line((face, -r3), (face + step * guard, -r2), "hidden")) + return tuple(prims) + if view == "side": + circles: list[Prim] = [ + Circle((0.0, 0.0), r1, "hidden"), + Circle((0.0, 0.0), r2, "hidden"), + ] + if str(self.form).strip().upper() == "B" and dims.get("d3"): + circles.append(Circle((0.0, 0.0), dims["d3"] / 2.0, "hidden")) + return tuple(circles) + return () + + def removal_box(self, part): + dims = self._dims() + kind, _ = _parse_at(self.at, part, allow_step=False) + face = 0.0 if kind == "start" else part_length(part) + step = 1.0 if kind == "start" else -1.0 + far = face + step * dims["t"] + outer = max(dims["d2"], dims.get("d3") or 0.0) / 2.0 + return (min(face, far), -outer, max(face, far), outer) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a centre hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class ORingGroove(_Base): + """An ISO 3601-2 radial O-ring housing. An axial (face) groove is refused.""" + + KIND: ClassVar[str] = "oring_groove" + axisymmetric: ClassVar[bool] = True + x: float + cord: float + where: str = "shaft" + housing: str = "radial" + b: float | None = None + h: float | None = None + + def _dims(self) -> dict: + if self.b is not None and self.h is not None: + return {"b": float(self.b), "h": float(self.h)} + return oring_groove_dims(float(self.cord), str(self.housing)) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "ORingGroove") + if str(self.housing).strip().lower() not in ORING_KINDS: + raise ValueError(f"housing: expected one of {ORING_KINDS}, got {self.housing!r}.") + if str(self.housing).strip().lower() == "axial": + raise ValueError( + "housing 'axial': a face groove is not on the revolved silhouette and is " + "refused rather than drawn as if it were radial. Model the face as a " + "prismatic pocket." + ) + if str(self.where).strip().lower() not in ("shaft", "bore"): + raise ValueError(f"where: expected 'shaft' or 'bore', got {self.where!r}.") + x = _number(self.x, "x", positive=False) + _number(self.cord, "cord") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + dims = self._dims() + _number(dims["b"], "b") + _number(dims["h"], "h") + radius = outer_radius_at(part, x) + if str(self.where).strip().lower() == "shaft" and dims["h"] >= radius: + raise ValueError(f"h {dims['h']} mm is not smaller than the radius {radius} mm.") + + def _span(self, part) -> tuple[float, float, float, float]: + dims = self._dims() + x = float(self.x) + half = dims["b"] / 2.0 + if str(self.where).strip().lower() == "shaft": + surface = outer_radius_at(part, x) + return x - half, x + half, surface - dims["h"], surface + surface = inner_radius_at(part, x) + return x - half, x + half, surface + dims["h"], surface + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1, groove_r, _ = self._span(part) + kind = "outer" if str(self.where).strip().lower() == "shaft" else "inner" + return ({"kind": kind, "x0": x0, "x1": x1, "radius": groove_r},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + x0, x1, groove_r, surface = self._span(_require_revolved(part, "ORingGroove")) + return ( + Line((x0, surface), (x0, groove_r), "visible"), + Line((x0, groove_r), (x1, groove_r), "visible"), + Line((x1, groove_r), (x1, surface), "visible"), + ) + + def removal_box(self, part): + x0, x1, groove_r, surface = self._span(part) + return (x0, min(groove_r, surface), x1, max(groove_r, surface)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x0, x1, groove_r, _ = self._span(part) + return (DimIntent(kind="linear", p1=(x0, groove_r), p2=(x1, groove_r), feature=self.id),) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"an O-ring groove has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CrossHole(_Base): + """A radial hole through (or into) a revolved part. + + Only a radial hole (``angle`` 90 degrees) is projected. An inclined cross + hole is refused rather than drawn as if it were radial. + """ + + KIND: ClassVar[str] = "cross_hole" + x: float + diameter: float + angle: float = 90.0 + depth: float | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "CrossHole") + x = _number(self.x, "x", positive=False) + diameter = _number(self.diameter, "diameter") + angle = _number(self.angle, "angle", positive=False) + if abs(angle - 90.0) > _EPS: + raise ValueError( + f"angle {angle}: only a radial cross hole (angle=90) is projected by this " + "engine; an inclined hole is refused rather than drawn as if it were radial." + ) + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + radius = outer_radius_at(part, x) + if diameter >= 2.0 * radius: + raise ValueError( + f"diameter {diameter} mm is not smaller than the {2.0 * radius:g} mm outside " + f"diameter at x={x}." + ) + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > 2.0 * radius + _EPS: + raise ValueError(f"depth {depth} mm is deeper than the part at x={x}.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "CrossHole") + x = float(self.x) + half = float(self.diameter) / 2.0 + radius = outer_radius_at(part, x) + if view == "front": + bottom = -radius if self.depth is None else radius - float(self.depth) + prims: list[Prim] = [ + Line((x - half, bottom), (x - half, radius), "hidden"), + Line((x + half, bottom), (x + half, radius), "hidden"), + ] + if self.depth is not None: + prims.append(Line((x - half, bottom), (x + half, bottom), "hidden")) + return tuple(prims) + if view == "side": + return ( + Line((-radius, half), (radius, half), "hidden"), + Line((-radius, -half), (radius, -half), "hidden"), + ) + return () + + def removal_box(self, part): + x = float(self.x) + half = float(self.diameter) / 2.0 + radius = outer_radius_at(part, x) + bottom = -radius if self.depth is None else radius - float(self.depth) + return (x - half, bottom, x + half, radius) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x = float(self.x) + half = float(self.diameter) / 2.0 + return ( + DimIntent( + kind="diameter", + p1=(x - half, 0.0), + p2=(x + half, 0.0), + text_override=f"⌀{float(self.diameter):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a cross hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class HoleCircle(_Base): + """A bolt circle on a face: hidden pairs in the axial view, real holes in the end view.""" + + KIND: ClassVar[str] = "hole_circle" + x: float + pcd: float + count: int + diameter: float + start_angle: float = 0.0 + thread: str | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "HoleCircle") + x = _number(self.x, "x", positive=False) + pcd = _number(self.pcd, "pcd") + diameter = _number(self.diameter, "diameter") + _number(self.start_angle, "start_angle", positive=False) + if int(self.count) < 2: + raise ValueError(f"count: a bolt circle needs at least 2 holes, got {self.count}.") + if not 0.0 <= x <= part_length(part) + _EPS: + raise ValueError(f"x {x} is outside the part (0 .. {part_length(part)} mm).") + radius = outer_radius_at(part, x) + bore = inner_radius_at(part, x) + if pcd / 2.0 + diameter / 2.0 > radius + _EPS: + raise ValueError( + f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 + diameter / 2.0:.3f} mm, " + f"past the {radius:.3f} mm outside radius at x={x}." + ) + if pcd / 2.0 - diameter / 2.0 < bore - _EPS: + raise ValueError( + f"pcd {pcd} with ⌀{diameter} reaches {pcd / 2.0 - diameter / 2.0:.3f} mm, " + f"inside the {bore:.3f} mm bore at x={x}." + ) + if self.thread: + parse_designation(self.thread) + + def _centers(self) -> tuple[tuple[float, float], ...]: + count = int(self.count) + radius = float(self.pcd) / 2.0 + start = math.radians(float(self.start_angle)) + return tuple( + ( + radius * math.cos(start + 2.0 * math.pi * k / count), + radius * math.sin(start + 2.0 * math.pi * k / count), + ) + for k in range(count) + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "HoleCircle") + pcd_r = float(self.pcd) / 2.0 + half = float(self.diameter) / 2.0 + if view == "front": + index, _, _, _ = _segment_at_index(part, float(self.x)) + x0, x1 = segment_bounds(part)[index] + prims: list[Prim] = [] + for sign in (1.0, -1.0): + for offset in (half, -half): + y = sign * (pcd_r + offset) + prims.append(Line((x0, y), (x1, y), "hidden")) + prims.append(Line((x0, sign * pcd_r), (x1, sign * pcd_r), "center")) + return tuple(prims) + if view == "side": + prims = [Circle((0.0, 0.0), pcd_r, "center")] + for cx, cy in self._centers(): + prims.append(Circle((cx, cy), half, "visible")) + prims.append(Line((cx - half - 2.0, cy), (cx + half + 2.0, cy), "center")) + prims.append(Line((cx, cy - half - 2.0), (cx, cy + half + 2.0), "center")) + return tuple(prims) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "side": + return () + pcd_r = float(self.pcd) / 2.0 + half = float(self.diameter) / 2.0 + first = self._centers()[0] + label = self.thread or f"⌀{float(self.diameter):g}" + return ( + DimIntent( + kind="diameter", + p1=(first[0] - half, first[1]), + p2=(first[0] + half, first[1]), + text_override=f"{int(self.count)}x {label}", + feature=self.id, + ), + DimIntent( + kind="diameter", + p1=(-pcd_r, 0.0), + p2=(pcd_r, 0.0), + text_override=f"⌀{float(self.pcd):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"a bolt circle has no {view} projection" + + +def _segment_at_index(part: RevolvedPart, x: float) -> tuple[int, float, float, Any]: + bounds = zip(segment_bounds(part), part.segments, strict=True) + for index, ((x0, x1), segment) in enumerate(bounds): + if x0 - _EPS <= x <= x1 + _EPS: + return index, x0, x1, segment + raise ValueError(f"x={x} is outside the part (0 .. {part_length(part)} mm).") + + +@_register +@dataclass(frozen=True, kw_only=True) +class AxialBore(_Base): + """A bore drilled in from one end. Expressed through the radial profile.""" + + KIND: ClassVar[str] = "axial_bore" + at: str = "start" + diameter: float + depth: float | None = None + + def _span(self, part) -> tuple[float, float]: + kind, _ = _parse_at(self.at, part, allow_step=False) + length = part_length(part) + depth = length if self.depth is None else float(self.depth) + return (0.0, depth) if kind == "start" else (length - depth, length) + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "AxialBore") + _parse_at(self.at, part, allow_step=False) + diameter = _number(self.diameter, "diameter") + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > part_length(part) + _EPS: + raise ValueError( + f"depth {depth} mm is longer than the part {part_length(part)} mm." + ) + x0, x1 = self._span(part) + for x in (x0 + _EPS, (x0 + x1) / 2.0, x1 - _EPS): + if diameter >= 2.0 * outer_radius_at(part, x): + raise ValueError( + f"diameter {diameter} mm is not smaller than the " + f"{2.0 * outer_radius_at(part, x):g} mm outside diameter at x={x:.3f}." + ) + for other in getattr(part, "features", ()): + if other is self or not isinstance(other, AxialBore): + continue + ox0, ox1 = other._span(part) + if min(x1, ox1) - max(x0, ox0) > _EPS: + raise ValueError( + f"bores {self.id!r} and {other.id!r} both remove material over " + f"x {max(x0, ox0):.3f}..{min(x1, ox1):.3f}. A bore that changes diameter " + "is two segments, not two features." + ) + + def radial_edit(self, part) -> tuple[dict, ...]: + x0, x1 = self._span(part) + return ({"kind": "inner", "x0": x0, "x1": x1, "radius": float(self.diameter) / 2.0},) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "AxialBore") + half = float(self.diameter) / 2.0 + kind, _ = _parse_at(self.at, part, allow_step=False) + x0, x1 = self._span(part) + if view == "front" and self.depth is not None: + bottom = x1 if kind == "start" else x0 + return (Line((bottom, -half), (bottom, half), "hidden"),) + if view == "side": + near = str(ctx.get("side", "right")).strip().lower() + open_here = (near == "left" and kind == "start") or (near == "right" and kind == "end") + return (Circle((0.0, 0.0), half, "visible" if open_here else "hidden"),) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "side": + return () + half = float(self.diameter) / 2.0 + return ( + DimIntent( + kind="diameter", + p1=(-half, 0.0), + p2=(half, 0.0), + text_override=f"⌀{float(self.diameter):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None # the bore always reaches the drawing through the radial profile + + +@_register +@dataclass(frozen=True, kw_only=True) +class GearTeeth(_Base): + """Involute spur/helical teeth on a segment, from the repository's own generator.""" + + KIND: ClassVar[str] = "gear_teeth" + segment: int + module: float + teeth: int + pressure_angle: float = 20.0 + helix_angle: float = 0.0 + hand: str = "RH" + + def _radii(self) -> tuple[float, float, float]: + module = float(self.module) + teeth = int(self.teeth) + pitch = module * teeth / 2.0 + return pitch + module, pitch, pitch - 1.25 * module + + def validate(self, part) -> None: + super().validate(part) + part = _require_revolved(part, "GearTeeth") + index = _segment_index(part, self.segment) + _number(self.module, "module") + angle = _number(self.pressure_angle, "pressure_angle") + if int(self.teeth) < 6: + raise ValueError( + f"teeth: the involute generator needs at least 6 teeth, got {self.teeth}." + ) + if not 10.0 <= angle <= 35.0: + raise ValueError(f"pressure_angle {angle}: expected between 10 and 35 degrees.") + if str(self.hand).strip().upper() not in ("RH", "LH"): + raise ValueError(f"hand: expected 'RH' or 'LH', got {self.hand!r}.") + tip, _, _ = self._radii() + outside = part.segments[index].d_outer + if abs(tip * 2.0 - outside) > 0.01: + raise ValueError( + f"m={float(self.module):g} z={int(self.teeth)} gives a tip diameter of " + f"{tip * 2.0:g} mm, but segment {index} is {outside:g} mm. Cut the segment to " + "the tip diameter, or name the module and tooth count that fit it." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_revolved(part, "GearTeeth") + index = _segment_index(part, self.segment) + _, pitch, root = self._radii() + if view == "front": + x0, x1 = segment_bounds(part)[index] + prims: list[Prim] = [ + Line((x0, root), (x1, root), "visible"), + Line((x0, -root), (x1, -root), "visible"), + Line((x0, pitch), (x1, pitch), "center"), + Line((x0, -pitch), (x1, -pitch), "center"), + ] + if abs(float(self.helix_angle)) > _EPS: + # ISO 2203: three thin lines at the helix angle mark the hand. + mid = (x0 + x1) / 2.0 + run = (x1 - x0) / 3.0 + rise = run * math.tan(math.radians(float(self.helix_angle))) + sign = 1.0 if str(self.hand).strip().upper() == "RH" else -1.0 + for offset in (-run, 0.0, run): + prims.append( + Line( + (mid + offset - run / 2.0, -pitch / 2.0), + (mid + offset + run / 2.0, -pitch / 2.0 + sign * rise), + "phantom", + ) + ) + return tuple(prims) + if view == "side": + outline = generate_full_gear_outline( + float(self.module), + int(self.teeth), + float(self.pressure_angle), + float(self.helix_angle), + str(self.hand).strip().upper(), + (0.0, 0.0), + ) + return ( + Poly(tuple(outline), True, "visible"), + Circle((0.0, 0.0), pitch, "center"), + ) + return () + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + tip, _, _ = self._radii() + if view != "side": + return () + return ( + DimIntent( + kind="diameter", + p1=(-tip, 0.0), + p2=(tip, 0.0), + text_override=(f"⌀{tip * 2.0:g} (m={float(self.module):g}, z={int(self.teeth)})"), + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side") else f"gear teeth have no {view} projection" + + +# -- prismatic features ------------------------------------------------------ +# +# View frames for a prismatic part: +# "front" : the outline's own XY +# "side" : u = part Y, v = z in [0, thickness] (looking along -X) +# "top" : u = part X, v = z in [0, thickness] (looking along -Y) + + +@_register +@dataclass(frozen=True, kw_only=True) +class Hole(_Base): + """A hole in a prismatic part, optionally counterbored, countersunk or tapped.""" + + KIND: ClassVar[str] = "hole" + x: float + y: float + diameter: float + depth: float | None = None + cbore_d: float | None = None + cbore_depth: float | None = None + csink_d: float | None = None + thread: str | None = None + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Hole") + _number(self.x, "x", positive=False) + _number(self.y, "y", positive=False) + diameter = _number(self.diameter, "diameter") + if not point_in_outline(part, float(self.x), float(self.y)): + raise ValueError( + f"({float(self.x):g}, {float(self.y):g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + if self.depth is not None: + depth = _number(self.depth, "depth") + if depth > part.thickness + _EPS: + raise ValueError(f"depth {depth} is deeper than the {part.thickness} mm thickness.") + for name, value in (("cbore_d", self.cbore_d), ("csink_d", self.csink_d)): + if value is not None and _number(value, name) <= diameter: + raise ValueError(f"{name} {value} must be larger than the hole {diameter}.") + if self.cbore_d is not None and self.cbore_depth is None: + raise ValueError("cbore_depth: a counterbore needs its depth.") + if self.thread: + parsed = parse_designation(self.thread) + if abs(parsed["d"] - diameter) > 0.01: + raise ValueError( + f"{parsed['designation']} has a major diameter of {parsed['d']:g} mm but " + f"diameter is {diameter:g} mm." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Hole") + x, y = float(self.x), float(self.y) + half = float(self.diameter) / 2.0 + if view == "front": + prims: list[Prim] = [] + if self.thread: + minor = MINOR_RATIO * float(self.diameter) / 2.0 + prims.append(Circle((x, y), minor, "visible")) + prims.append(Arc((x, y), half, 90.0, 0.0, "visible")) + else: + prims.append(Circle((x, y), half, "visible")) + for extra in (self.cbore_d, self.csink_d): + if extra is not None: + prims.append(Circle((x, y), float(extra) / 2.0, "visible")) + extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] + reach = max([half, *extras]) + 3.0 + prims.append(Line((x - reach, y), (x + reach, y), "center")) + prims.append(Line((x, y - reach), (x, y + reach), "center")) + return tuple(prims) + if view in ("side", "top"): + u = y if view == "side" else x + depth = part.thickness if self.depth is None else float(self.depth) + prims = [ + Line((u - half, 0.0), (u - half, depth), "hidden"), + Line((u + half, 0.0), (u + half, depth), "hidden"), + ] + if self.depth is not None: + prims.append(Line((u - half, depth), (u + half, depth), "hidden")) + if self.cbore_d is not None: + cb = float(self.cbore_d) / 2.0 + cd = float(self.cbore_depth) + prims.append(Line((u - cb, 0.0), (u - cb, cd), "hidden")) + prims.append(Line((u + cb, 0.0), (u + cb, cd), "hidden")) + prims.append(Line((u - cb, cd), (u + cb, cd), "hidden")) + return tuple(prims) + return () + + def removal_box(self, part): + x, y = float(self.x), float(self.y) + extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] + outer = max([float(self.diameter) / 2.0, *extras]) + return (x - outer, y - outer, x + outer, y + outer) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + x, y = float(self.x), float(self.y) + half = float(self.diameter) / 2.0 + label = self.thread or f"⌀{float(self.diameter):g}" + return ( + DimIntent( + kind="diameter", + p1=(x - half, y), + p2=(x + half, y), + text_override=label, + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a hole has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Slot(_Base): + """An obround slot: two parallel flanks closed by two semicircular ends.""" + + KIND: ClassVar[str] = "slot" + x: float + y: float + length: float + width: float + angle: float = 0.0 + + def _ends(self) -> tuple[tuple[float, float], tuple[float, float], float]: + half_run = (float(self.length) - float(self.width)) / 2.0 + theta = math.radians(float(self.angle)) + ux, uy = math.cos(theta), math.sin(theta) + x, y = float(self.x), float(self.y) + return ( + (x - half_run * ux, y - half_run * uy), + (x + half_run * ux, y + half_run * uy), + float(self.width) / 2.0, + ) + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Slot") + _number(self.x, "x", positive=False) + _number(self.y, "y", positive=False) + length = _number(self.length, "length") + width = _number(self.width, "width") + _number(self.angle, "angle", positive=False) + if length <= width: + raise ValueError( + f"length {length} must be greater than width {width}; a slot as wide as it is " + "long is a hole." + ) + for point in self._ends()[:2]: + if not point_in_outline(part, point[0], point[1]): + raise ValueError( + f"slot end ({point[0]:g}, {point[1]:g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Slot") + (c1, c2, half) = self._ends() + theta = math.radians(float(self.angle)) + nx, ny = -math.sin(theta), math.cos(theta) + if view == "front": + return ( + Line( + (c1[0] + half * nx, c1[1] + half * ny), + (c2[0] + half * nx, c2[1] + half * ny), + "visible", + ), + Line( + (c1[0] - half * nx, c1[1] - half * ny), + (c2[0] - half * nx, c2[1] - half * ny), + "visible", + ), + Arc( + c2, + half, + (float(self.angle) - 90.0) % 360.0, + (float(self.angle) + 90.0) % 360.0, + "visible", + ), + Arc( + c1, + half, + (float(self.angle) + 90.0) % 360.0, + (float(self.angle) + 270.0) % 360.0, + "visible", + ), + Line((c1[0], c1[1]), (c2[0], c2[1]), "center"), + ) + if view in ("side", "top"): + box = self.removal_box(part) + u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2]) + return ( + Line((u0, 0.0), (u0, part.thickness), "hidden"), + Line((u1, 0.0), (u1, part.thickness), "hidden"), + ) + return () + + def removal_box(self, part): + (c1, c2, half) = self._ends() + xs = (c1[0] - half, c1[0] + half, c2[0] - half, c2[0] + half) + ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half) + return (min(xs), min(ys), max(xs), max(ys)) + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + (c1, c2, half) = self._ends() + theta = math.radians(float(self.angle)) + nx, ny = -math.sin(theta), math.cos(theta) + return ( + DimIntent( + kind="aligned", + p1=(c1[0], c1[1]), + p2=(c2[0], c2[1]), + text_override=f"{float(self.length):g}", + feature=self.id, + ), + DimIntent( + kind="aligned", + p1=(c2[0] + half * nx, c2[1] + half * ny), + p2=(c2[0] - half * nx, c2[1] - half * ny), + text_override=f"{float(self.width):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a slot has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class Pocket(_Base): + """A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3).""" + + KIND: ClassVar[str] = "pocket" + outline: tuple[tuple[float, float], ...] + depth: float + no_section_hatch: bool = False + + def validate(self, part) -> None: + super().validate(part) + part = _require_prismatic(part, "Pocket") + depth = _number(self.depth, "depth") + if depth >= part.thickness - _EPS: + raise ValueError( + f"depth {depth} is not smaller than the {part.thickness} mm thickness; " + "a pocket that goes through is a Hole or a Slot." + ) + points = tuple((float(x), float(y)) for x, y in self.outline) + if len(points) < 3: + raise ValueError(f"outline: a pocket needs at least 3 vertices, got {len(points)}.") + from engineering.measure import is_self_intersecting + + if is_self_intersecting(points) is True: + raise ValueError("outline: the pocket outline crosses itself.") + for index, point in enumerate(points): + if not point_in_outline(part, point[0], point[1]): + raise ValueError( + f"outline[{index}] ({point[0]:g}, {point[1]:g}) is outside the part outline " + f"{outline_bbox(part)}." + ) + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + part = _require_prismatic(part, "Pocket") + points = tuple((float(x), float(y)) for x, y in self.outline) + if view == "front": + return (Poly(points, True, "visible"),) + if view in ("side", "top"): + box = self.removal_box(part) + u0, u1 = (box[1], box[3]) if view == "side" else (box[0], box[2]) + floor = part.thickness - float(self.depth) + return ( + Line((u0, part.thickness), (u0, floor), "hidden"), + Line((u0, floor), (u1, floor), "hidden"), + Line((u1, floor), (u1, part.thickness), "hidden"), + ) + return () + + def removal_box(self, part): + xs = [float(x) for x, _ in self.outline] + ys = [float(y) for _, y in self.outline] + return (min(xs), min(ys), max(xs), max(ys)) + + def omission(self, part, view: str) -> str | None: + return None if view in ("front", "side", "top") else f"a pocket has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CornerFillet(_Base): + """A rounded outline corner: two tangent points and the arc between them.""" + + KIND: ClassVar[str] = "corner_fillet" + vertex: int + radius: float + + def _frame(self, part): + points = _require_prismatic(part, "CornerFillet").outline + count = len(points) + index = int(self.vertex) + if not 0 <= index < count: + raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).") + v = points[index] + p = points[(index - 1) % count] + n = points[(index + 1) % count] + return v, p, n + + def validate(self, part) -> None: + super().validate(part) + radius = _number(self.radius, "radius") + v, p, n = self._frame(part) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p) + u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n) + cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + half = math.acos(cosine) / 2.0 + if half < 1e-6 or abs(cosine + 1.0) < 1e-9: + raise ValueError( + f"vertex {self.vertex} is a straight run, there is no corner to round." + ) + tangent = radius / math.tan(half) + if tangent > min(edge_p, edge_n) / 2.0 + _EPS: + raise ValueError( + f"radius {radius} needs {tangent:.3f} mm of edge, more than half the shorter " + f"adjacent edge ({min(edge_p, edge_n):.3f} mm)." + ) + + def outline_edit(self, part) -> dict: + v, p, n = self._frame(part) + radius = float(self.radius) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + u1 = ((p[0] - v[0]) / edge_p, (p[1] - v[1]) / edge_p) + u2 = ((n[0] - v[0]) / edge_n, (n[1] - v[1]) / edge_n) + cosine = max(-1.0, min(1.0, u1[0] * u2[0] + u1[1] * u2[1])) + half = math.acos(cosine) / 2.0 + tangent = radius / math.tan(half) + before = (v[0] + u1[0] * tangent, v[1] + u1[1] * tangent) + after = (v[0] + u2[0] * tangent, v[1] + u2[1] * tangent) + bisector = (u1[0] + u2[0], u1[1] + u2[1]) + norm = math.hypot(*bisector) + center = ( + v[0] + bisector[0] / norm * (radius / math.sin(half)), + v[1] + bisector[1] / norm * (radius / math.sin(half)), + ) + start = math.degrees(math.atan2(before[1] - center[1], before[0] - center[0])) % 360.0 + end = math.degrees(math.atan2(after[1] - center[1], after[0] - center[0])) % 360.0 + cross = u1[0] * u2[1] - u1[1] * u2[0] + if cross > 0: # the arc runs the other way round + start, end = end, start + return { + "vertex": int(self.vertex), + "before": before, + "after": after, + "arc": Arc(center, radius, start, end, "visible"), + } + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.outline_edit(part) + return ( + DimIntent( + kind="radius", + p1=edit["arc"].center, + p2=edit["before"], + text_override=f"R{float(self.radius):g}", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a corner radius has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class CornerChamfer(_Base): + """A bevelled outline corner: two points and the straight run between them.""" + + KIND: ClassVar[str] = "corner_chamfer" + vertex: int + size: float + + def _frame(self, part): + points = _require_prismatic(part, "CornerChamfer").outline + count = len(points) + index = int(self.vertex) + if not 0 <= index < count: + raise ValueError(f"vertex {index} is outside the outline (0-{count - 1}).") + return points[index], points[(index - 1) % count], points[(index + 1) % count] + + def validate(self, part) -> None: + super().validate(part) + size = _number(self.size, "size") + v, p, n = self._frame(part) + shortest = min(math.dist(v, p), math.dist(v, n)) + if size > shortest / 2.0 + _EPS: + raise ValueError( + f"size {size} is more than half the shorter adjacent edge ({shortest:.3f} mm)." + ) + + def outline_edit(self, part) -> dict: + v, p, n = self._frame(part) + size = float(self.size) + edge_p = math.dist(v, p) + edge_n = math.dist(v, n) + before = (v[0] + (p[0] - v[0]) / edge_p * size, v[1] + (p[1] - v[1]) / edge_p * size) + after = (v[0] + (n[0] - v[0]) / edge_n * size, v[1] + (n[1] - v[1]) / edge_n * size) + return {"vertex": int(self.vertex), "before": before, "after": after, "arc": None} + + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: + if view != "front": + return () + edit = self.outline_edit(part) + return ( + DimIntent( + kind="aligned", + p1=edit["before"], + p2=edit["after"], + text_override=f"{float(self.size):g}x45°", + feature=self.id, + ), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a corner chamfer has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class BendLine(_Base): + """A sheet-metal bend line, **representation only**. + + Spec section 2 puts flat patterns and bend allowance out of scope: no + developed length is computed here, because a wrong developed length is a + scrapped part. This feature draws the phantom line and its R/angle note and + claims nothing else. + """ + + KIND: ClassVar[str] = "bend_line" + p1: tuple[float, float] + p2: tuple[float, float] + angle: float + inside_radius: float + direction: str = "up" + + def validate(self, part) -> None: + super().validate(part) + _require_prismatic(part, "BendLine") + for name, point in (("p1", self.p1), ("p2", self.p2)): + if not isinstance(point, (tuple, list)) or len(point) != 2: + raise ValueError(f"{name}: expected [x, y], got {point!r}.") + _number(point[0], f"{name}.x", positive=False) + _number(point[1], f"{name}.y", positive=False) + if math.dist(tuple(self.p1), tuple(self.p2)) < _EPS: + raise ValueError("p1 and p2 are the same point; a bend line needs a direction.") + angle = _number(self.angle, "angle", positive=False) + if not 0.0 < abs(angle) <= 180.0: + raise ValueError(f"angle {angle}: a bend angle is between 0 and 180 degrees.") + _number(self.inside_radius, "inside_radius") + if str(self.direction).strip().lower() not in ("up", "down"): + raise ValueError(f"direction: expected 'up' or 'down', got {self.direction!r}.") + + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: + if view != "front": + return () + p1 = (float(self.p1[0]), float(self.p1[1])) + p2 = (float(self.p2[0]), float(self.p2[1])) + mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0) + rotation = math.degrees(math.atan2(p2[1] - p1[1], p2[0] - p1[0])) + note = ( + f"R{float(self.inside_radius):g} {float(self.angle):g}° " + f"{str(self.direction).strip().upper()}" + ) + return ( + Line(p1, p2, "phantom"), + Text((mid[0], mid[1] + 2.0), note, 3.5, rotation, "text"), + ) + + def omission(self, part, view: str) -> str | None: + return None if view == "front" else f"a bend line has no {view} projection" + + +@_register +@dataclass(frozen=True, kw_only=True) +class HolePattern(_Base): + """A linear, grid or polar array of one base hole. Expanded before validation.""" + + KIND: ClassVar[str] = "hole_pattern" + pattern: str + base: dict + count: int = 2 + spacing: float = 0.0 + count_y: int = 1 + spacing_y: float = 0.0 + pcd: float = 0.0 + start_angle: float = 0.0 + angle: float = 0.0 + + PATTERNS: ClassVar[tuple[str, ...]] = ("linear", "grid", "polar") + + def _base_fields(self) -> dict: + if not isinstance(self.base, dict): + raise ValueError( + f"base: expected a dict of Hole fields, got {type(self.base).__name__}." + ) + payload = {key: value for key, value in self.base.items() if key not in ("kind", "id")} + names = {field_.name for field_ in fields(Hole)} + unknown = sorted(set(payload) - names) + if unknown: + raise ValueError(f"base: unknown key(s) {unknown}; a Hole takes {sorted(names)}.") + for required in ("x", "y", "diameter"): + if required not in payload: + raise ValueError(f"base.{required}: a base hole needs x, y and diameter.") + return payload + + def expand(self) -> tuple[Hole, ...]: + kind = str(self.pattern).strip().lower() + if kind not in self.PATTERNS: + raise ValueError(f"pattern: expected one of {self.PATTERNS}, got {self.pattern!r}.") + base = self._base_fields() + x0, y0 = float(base["x"]), float(base["y"]) + count = int(self.count) + if count < 1: + raise ValueError(f"count: expected at least 1, got {count}.") + holes: list[Hole] = [] + if kind == "linear": + if float(self.spacing) <= _EPS: + raise ValueError("spacing: a linear pattern needs a spacing greater than zero.") + theta = math.radians(float(self.angle)) + for k in range(count): + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{k}", + "x": x0 + k * float(self.spacing) * math.cos(theta), + "y": y0 + k * float(self.spacing) * math.sin(theta), + } + ) + ) + elif kind == "grid": + rows = int(self.count_y) + if rows < 1: + raise ValueError(f"count_y: expected at least 1, got {rows}.") + if count > 1 and float(self.spacing) <= _EPS: + raise ValueError("spacing: a grid with more than one column needs a spacing.") + if rows > 1 and float(self.spacing_y) <= _EPS: + raise ValueError("spacing_y: a grid with more than one row needs a spacing_y.") + for j in range(rows): + for i in range(count): + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{i}_{j}", + "x": x0 + i * float(self.spacing), + "y": y0 + j * float(self.spacing_y), + } + ) + ) + else: + if float(self.pcd) <= _EPS: + raise ValueError("pcd: a polar pattern needs a pitch-circle diameter.") + if count < 2: + raise ValueError(f"count: a polar pattern needs at least 2 holes, got {count}.") + radius = float(self.pcd) / 2.0 + start = math.radians(float(self.start_angle)) + for k in range(count): + theta = start + 2.0 * math.pi * k / count + holes.append( + Hole( + **{ + **base, + "id": f"{self.id}_{k}", + "x": x0 + radius * math.cos(theta), + "y": y0 + radius * math.sin(theta), + } + ) + ) + return tuple(holes) + + def validate(self, part) -> None: # pragma: no cover - expanded before it reaches a part + super().validate(part) + for hole in self.expand(): + hole.validate(part) + + +FEATURE_KINDS: tuple[str, ...] = tuple(sorted(_REGISTRY)) diff --git a/engineering/mech/standards/__init__.py b/engineering/mech/standards/__init__.py index b550ada..5bf0b8c 100644 --- a/engineering/mech/standards/__init__.py +++ b/engineering/mech/standards/__init__.py @@ -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 " diff --git a/engineering/mech/standards/centres.py b/engineering/mech/standards/centres.py new file mode 100644 index 0000000..75d305b --- /dev/null +++ b/engineering/mech/standards/centres.py @@ -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)})." + ) diff --git a/engineering/mech/standards/orings.py b/engineering/mech/standards/orings.py new file mode 100644 index 0000000..db02161 --- /dev/null +++ b/engineering/mech/standards/orings.py @@ -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)})." + ) diff --git a/engineering/mech/standards/rings.py b/engineering/mech/standards/rings.py new file mode 100644 index 0000000..01201b0 --- /dev/null +++ b/engineering/mech/standards/rings.py @@ -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)}).") diff --git a/engineering/mech/standards/threads.py b/engineering/mech/standards/threads.py new file mode 100644 index 0000000..ff4612c --- /dev/null +++ b/engineering/mech/standards/threads.py @@ -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"),) diff --git a/engineering/mech/standards/undercuts.py b/engineering/mech/standards/undercuts.py new file mode 100644 index 0000000..19b0508 --- /dev/null +++ b/engineering/mech/standards/undercuts.py @@ -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)})." + ) diff --git a/tests/test_mech_features.py b/tests/test_mech_features.py new file mode 100644 index 0000000..7a00e73 --- /dev/null +++ b/tests/test_mech_features.py @@ -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 diff --git a/tests/test_mech_standards.py b/tests/test_mech_standards.py index 84ec877..d107e6a 100644 --- a/tests/test_mech_standards.py +++ b/tests/test_mech_standards.py @@ -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) diff --git a/tests/test_mech_standards_coverage.py b/tests/test_mech_standards_coverage.py new file mode 100644 index 0000000..f5e95f1 --- /dev/null +++ b/tests/test_mech_standards_coverage.py @@ -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}" diff --git a/tests/test_mech_threads.py b/tests/test_mech_threads.py new file mode 100644 index 0000000..e42c761 --- /dev/null +++ b/tests/test_mech_threads.py @@ -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)