diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index 157ce19..c8be734 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -6,6 +6,22 @@ and alphabetical — keep every side's names. from __future__ import annotations +from .part import ( + PART_KINDS, + PrismaticPart, + RevolvedPart, + Segment, + build_part, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + part_to_dict, + point_in_outline, + segment_bounds, + validate_part, +) from .primitives import ( ROLE_LAYER, ROLES, @@ -28,6 +44,7 @@ from .primitives import ( ) __all__ = [ + "PART_KINDS", "ROLES", "ROLE_LAYER", "Arc", @@ -37,13 +54,26 @@ __all__ = [ "Line", "Poly", "Prim", + "PrismaticPart", "Pt", + "RevolvedPart", "Role", + "Segment", "Text", "bbox", + "build_part", + "inner_radius_at", "layer_for", + "outer_radius_at", + "outline_bbox", + "part_length", + "part_max_diameter", + "part_to_dict", + "point_in_outline", "points_of", "rotate", "scale", + "segment_bounds", "translate", + "validate_part", ] diff --git a/engineering/mech/part.py b/engineering/mech/part.py new file mode 100644 index 0000000..dd74c1e --- /dev/null +++ b/engineering/mech/part.py @@ -0,0 +1,359 @@ +"""The mechanical part model: a profile plus a list of typed features. + +Pure: no backend, no async, no DXF. A part is either **revolved** (a stack of +segments along +X, the axis at y = 0, the left end at x = 0) or **prismatic** +(a closed outline in part-local XY plus a thickness along +Z). Every coordinate +here is part-local; `engineering/mech/draw.py` is the only module that maps +part-local to WCS. + +Everything is validated before anything is drawn (the repository's +validate-first rule), and every refusal names the offending path the way +`pid_from_spec` does: ``segments[2].d_outer: ...``. + +The feature surface is duck-typed on purpose, so this module does not import +`features.py` at module scope and the two are testable independently: + + feature.id -> str + feature.validate(part) -> None, ValueError naming what is wrong + feature.to_dict() -> dict accepted by feature_from_dict + feature.removal_box(part) -> (x0, y0, x1, y1) | None (optional) + feature.expand() -> tuple[Feature, ...] (optional) +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace +from typing import Any, NamedTuple + +from engineering.measure import is_self_intersecting +from engineering.mech.primitives import Pt +from engineering.mech.standards.materials import hatch_for + +_EPS = 1e-9 + + +class Segment(NamedTuple): + """One turned step of a revolved part, left to right along +X.""" + + length: float + d_outer: float + d_inner: float = 0.0 + taper_to: float | None = None + + +@dataclass(frozen=True) +class RevolvedPart: + name: str + segments: tuple[Segment, ...] + features: tuple[Any, ...] = () + material: str = "steel" + + +@dataclass(frozen=True) +class PrismaticPart: + name: str + outline: tuple[Pt, ...] + thickness: float + features: tuple[Any, ...] = () + material: str = "steel" + + +Part = RevolvedPart | PrismaticPart + +PART_KINDS: tuple[str, ...] = ("revolved", "prismatic") + +_SEGMENT_KEYS = frozenset({"length", "d_outer", "d_inner", "taper_to"}) + + +# -- number guards ----------------------------------------------------------- + + +def _finite(value: Any, path: str) -> 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.") + return number + + +def _positive(value: Any, path: str) -> float: + number = _finite(value, path) + if number <= _EPS: + raise ValueError(f"{path}: must be greater than zero, got {number}.") + return number + + +def _non_negative(value: Any, path: str) -> float: + number = _finite(value, path) + if number < -_EPS: + raise ValueError(f"{path}: must not be negative, got {number}.") + return max(number, 0.0) + + +# -- geometry helpers (Tasks 3-7 read the part through these) ---------------- + + +def segment_bounds(part: RevolvedPart) -> tuple[tuple[float, float], ...]: + """``((x0, x1), ...)`` per segment, left to right from x = 0.""" + bounds: list[tuple[float, float]] = [] + x = 0.0 + for segment in part.segments: + bounds.append((x, x + segment.length)) + x += segment.length + return tuple(bounds) + + +def part_length(part: Part) -> float: + if isinstance(part, RevolvedPart): + return float(sum(segment.length for segment in part.segments)) + x0, _, x1, _ = outline_bbox(part) + return x1 - x0 + + +def part_max_diameter(part: RevolvedPart) -> float: + diameters = [segment.d_outer for segment in part.segments] + diameters += [s.taper_to for s in part.segments if s.taper_to is not None] + return float(max(diameters)) + + +def _segment_at(part: RevolvedPart, x: float) -> tuple[int, float, float, Segment]: + bounds = zip(segment_bounds(part), part.segments, strict=True) # one bound per segment + 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).") + + +def outer_radius_at(part: RevolvedPart, x: float) -> float: + """Outer radius at an axial station, honouring a segment's taper.""" + _, x0, x1, segment = _segment_at(part, float(x)) + if segment.taper_to is None: + return segment.d_outer / 2.0 + span = x1 - x0 + t = 0.0 if span <= _EPS else (float(x) - x0) / span + return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0 + + +def inner_radius_at(part: RevolvedPart, x: float) -> float: + """Bore radius at an axial station; 0.0 where the part is solid.""" + _, _, _, segment = _segment_at(part, float(x)) + return segment.d_inner / 2.0 + + +def outline_bbox(part: PrismaticPart) -> tuple[float, float, float, float]: + xs = [p[0] for p in part.outline] + ys = [p[1] for p in part.outline] + return (min(xs), min(ys), max(xs), max(ys)) + + +def point_in_outline(part: PrismaticPart, x: float, y: float) -> bool: + """Even-odd ray cast against the closed outline; a point on an edge counts as in.""" + points = part.outline + count = len(points) + inside = False + for i in range(count): + ax, ay = points[i] + bx, by = points[(i + 1) % count] + cross = (bx - ax) * (y - ay) - (by - ay) * (x - ax) + on_span = min(ax, bx) - _EPS <= x <= max(ax, bx) + _EPS + on_span = on_span and min(ay, by) - _EPS <= y <= max(ay, by) + _EPS + if abs(cross) < 1e-9 and on_span: + return True + if (ay > y) != (by > y): + t = (y - ay) / (by - ay) + if x < ax + t * (bx - ax): + inside = not inside + return inside + + +# -- construction ------------------------------------------------------------ + + +def _segment_from(index: int, raw: Any) -> Segment: + if not isinstance(raw, dict): + raise ValueError(f"segments[{index}]: expected a dict, got {type(raw).__name__}.") + unknown = set(raw) - _SEGMENT_KEYS + if unknown: + raise ValueError( + f"segments[{index}]: unknown key(s) {sorted(unknown)}; " + f"a segment takes {sorted(_SEGMENT_KEYS)}." + ) + length = _positive(raw.get("length"), f"segments[{index}].length") + d_outer = _positive(raw.get("d_outer"), f"segments[{index}].d_outer") + d_inner = _non_negative(raw.get("d_inner") or 0.0, f"segments[{index}].d_inner") + if d_inner >= d_outer - _EPS: + raise ValueError( + f"segments[{index}].d_inner: {d_inner} mm is not smaller than d_outer " + f"{d_outer} mm. A bore that changes diameter is two segments." + ) + taper_to = raw.get("taper_to") + if taper_to is not None: + taper_to = _positive(taper_to, f"segments[{index}].taper_to") + if taper_to <= d_inner + _EPS: + raise ValueError( + f"segments[{index}].taper_to: {taper_to} mm is not larger than the bore " + f"{d_inner} mm." + ) + return Segment(length, d_outer, d_inner, taper_to) + + +def _outline_from(raw: Any) -> tuple[Pt, ...]: + if not isinstance(raw, (list, tuple)) or not raw: + raise ValueError("outline: expected a non-empty list of [x, y] points.") + points: list[Pt] = [] + for index, item in enumerate(raw): + if not isinstance(item, (list, tuple)) or len(item) != 2: + raise ValueError(f"outline[{index}]: expected [x, y], got {item!r}.") + points.append( + ( + _finite(item[0], f"outline[{index}].x"), + _finite(item[1], f"outline[{index}].y"), + ) + ) + if len(points) > 1 and math.dist(points[0], points[-1]) < _EPS: + points = points[:-1] + distinct: list[Pt] = [] + for point in points: + if not distinct or math.dist(distinct[-1], point) > _EPS: + distinct.append(point) + if len(distinct) < 3: + raise ValueError( + f"outline: a closed outline needs at least 3 distinct vertices, got {len(distinct)}." + ) + if is_self_intersecting(distinct) is True: + raise ValueError( + "outline: the closed outline crosses itself. A crossed loop has no single " + "enclosed area, so neither the view engine nor the section hatch can be right." + ) + return tuple(distinct) + + +def _features_from(raw: Any) -> tuple[Any, ...]: + if raw is None: + return () + if not isinstance(raw, (list, tuple)): + raise ValueError("features: expected a list of feature dicts.") + if not raw: + # No feature dicts to build, so `features.py` is not imported at all: + # a featureless part stays usable while Task 3 is still unwritten, and + # the import stays local either way (features.py imports this module). + return () + from engineering.mech.features import feature_from_dict # local: avoids an import cycle + + built: list[Any] = [] + seen: set[str] = set() + for index, item in enumerate(raw): + if not isinstance(item, dict): + raise ValueError(f"features[{index}]: expected a dict with a 'kind' key.") + try: + feature = feature_from_dict(item) + expand = getattr(feature, "expand", None) + expanded = tuple(expand()) if callable(expand) else (feature,) + except ValueError as exc: + raise ValueError(f"features[{index}]: {exc}") from exc + for one in expanded: + if one.id in seen: + raise ValueError(f"features[{index}]: duplicate feature id {one.id!r}.") + seen.add(one.id) + built.append(one) + return tuple(built) + + +def validate_part(part: Part) -> None: + """Every feature validates against the part, and no two remove the same material.""" + for index, feature in enumerate(part.features): + try: + feature.validate(part) + except ValueError as exc: + raise ValueError(f"features[{index}] ({feature.id}): {exc}") from exc + + boxes: list[tuple[str, tuple[float, float, float, float]]] = [] + for feature in part.features: + getter = getattr(feature, "removal_box", None) + box = getter(part) if callable(getter) else None + if box is not None: + x0, y0, x1, y1 = (float(v) for v in box) + boxes.append((feature.id, (x0, y0, x1, y1))) + + for a in range(len(boxes)): + id_a, (ax0, ay0, ax1, ay1) = boxes[a] + for b in range(a + 1, len(boxes)): + id_b, (bx0, by0, bx1, by1) = boxes[b] + ox0, ox1 = max(ax0, bx0), min(ax1, bx1) + oy0, oy1 = max(ay0, by0), min(ay1, by1) + if ox1 - ox0 > _EPS and oy1 - oy0 > _EPS: + raise ValueError( + f"features {id_a!r} and {id_b!r} remove the same material " + f"(overlap x {ox0:.3f}..{ox1:.3f}, y {oy0:.3f}..{oy1:.3f}). " + "Move one of them, or merge them into a single feature." + ) + + +def build_part(spec: dict) -> Part: + """Validate a part spec and return the typed model. ValueError names the path.""" + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict describing the part, got {type(spec).__name__}.") + kind = str(spec.get("kind", "")).strip().lower() + if kind not in PART_KINDS: + raise ValueError(f"kind: expected one of {PART_KINDS}, got {spec.get('kind')!r}.") + name = spec.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError("name: a non-empty part name is required.") + material = str(spec.get("material", "steel")) + try: + hatch_for(material) + except ValueError as exc: + raise ValueError(f"material: {exc}") from exc + + if kind == "revolved": + raw_segments = spec.get("segments") + if not isinstance(raw_segments, (list, tuple)) or not raw_segments: + raise ValueError("segments: a revolved part needs at least one segment.") + part: Part = RevolvedPart( + name=name.strip(), + segments=tuple(_segment_from(i, s) for i, s in enumerate(raw_segments)), + material=material, + ) + else: + part = PrismaticPart( + name=name.strip(), + outline=_outline_from(spec.get("outline")), + thickness=_positive(spec.get("thickness"), "thickness"), + material=material, + ) + + part = replace(part, features=_features_from(spec.get("features"))) + validate_part(part) + return part + + +def part_to_dict(part: Part) -> dict: + """The dict `build_part` accepts back, unchanged.""" + common = { + "name": part.name, + "material": part.material, + "features": [feature.to_dict() for feature in part.features], + } + if isinstance(part, RevolvedPart): + return { + "kind": "revolved", + **common, + "segments": [ + { + "length": s.length, + "d_outer": s.d_outer, + "d_inner": s.d_inner, + "taper_to": s.taper_to, + } + for s in part.segments + ], + } + return { + "kind": "prismatic", + **common, + "outline": [[x, y] for x, y in part.outline], + "thickness": part.thickness, + } diff --git a/tests/test_mech_part.py b/tests/test_mech_part.py new file mode 100644 index 0000000..59ee8a8 --- /dev/null +++ b/tests/test_mech_part.py @@ -0,0 +1,216 @@ +"""The part model: construction, round-trip and every refusal the spec names. + +Pure arithmetic - no backend, no DXF. The feature surface is exercised through +a local stub so this module's gate (placement on the part, two features +removing the same material) is pinned before `engineering/mech/features.py` +exists. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + Segment, + build_part, + inner_radius_at, + outer_radius_at, + outline_bbox, + part_length, + part_max_diameter, + part_to_dict, + point_in_outline, + segment_bounds, + validate_part, +) + +EPS = 1e-9 + +SHAFT = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [ + {"length": 20.0, "d_outer": 25.0}, + {"length": 40.0, "d_outer": 30.0, "d_inner": 10.0}, + {"length": 15.0, "d_outer": 20.0, "taper_to": 16.0}, + ], +} + +PLATE = { + "kind": "prismatic", + "name": "plate", + "material": "aluminium", + "thickness": 8.0, + "outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]], +} + + +@dataclass(frozen=True, kw_only=True) +class StubFeature: + """The duck-typed surface `build_part` uses, and nothing else.""" + + id: str + segment: int = 0 + box: tuple[float, float, float, float] | None = None + + def validate(self, part) -> None: + if not 0 <= self.segment < len(part.segments): + raise ValueError( + f"segment {self.segment} is outside the {len(part.segments)}-segment part " + f"(segments 0-{len(part.segments) - 1})." + ) + + def removal_box(self, part): + return self.box + + def to_dict(self) -> dict: + return {"kind": "stub", "id": self.id, "segment": self.segment} + + +def test_build_part_makes_a_revolved_part_with_typed_segments(): + part = build_part(SHAFT) + assert isinstance(part, RevolvedPart) + assert part.name == "shaft" + assert part.material == "steel" + assert part.segments[1] == Segment(40.0, 30.0, 10.0, None) + assert part.segments[2].taper_to == 16.0 + assert part.features == () + + +def test_build_part_makes_a_prismatic_part_and_drops_a_repeated_closing_vertex(): + closed = dict(PLATE, outline=PLATE["outline"] + [[0.0, 0.0]]) + part = build_part(closed) + assert isinstance(part, PrismaticPart) + assert len(part.outline) == 4 + assert part.thickness == 8.0 + assert outline_bbox(part) == (0.0, 0.0, 80.0, 50.0) + + +@pytest.mark.parametrize("spec", [SHAFT, PLATE]) +def test_part_to_dict_round_trips_through_build_part(spec): + part = build_part(spec) + assert build_part(part_to_dict(part)) == part + + +def test_geometry_helpers_report_the_real_numbers(): + part = build_part(SHAFT) + assert part_length(part) == 75.0 + assert part_max_diameter(part) == 30.0 + assert segment_bounds(part) == ((0.0, 20.0), (20.0, 60.0), (60.0, 75.0)) + assert outer_radius_at(part, 10.0) == 12.5 + assert inner_radius_at(part, 30.0) == 5.0 + assert inner_radius_at(part, 10.0) == 0.0 + # the taper runs 20 -> 16 mm over the last 15 mm, so its mid-point is 18 mm + assert outer_radius_at(part, 67.5) == pytest.approx(9.0, abs=EPS) + plate = build_part(PLATE) + assert part_length(plate) == 80.0 + assert point_in_outline(plate, 40.0, 25.0) is True + assert point_in_outline(plate, 90.0, 25.0) is False + + +@pytest.mark.parametrize("bad", [float("inf"), float("nan"), 0.0, -5.0]) +def test_a_non_finite_or_non_positive_number_is_refused_by_path(bad): + spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]]) + spec["segments"][2]["d_outer"] = bad + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[2].d_outer" in str(excinfo.value) + + +def test_a_bore_that_is_not_smaller_than_the_outside_is_refused(): + spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]]) + spec["segments"][1]["d_inner"] = 30.0 + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[1].d_inner" in str(excinfo.value) + assert "two segments" in str(excinfo.value) + + +def test_an_outline_with_fewer_than_three_distinct_vertices_is_refused(): + spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 0.0], [10.0, 0.0]]) + with pytest.raises(ValueError, match="at least 3 distinct vertices"): + build_part(spec) + + +def test_a_self_intersecting_outline_is_refused(): + spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 10.0]]) + with pytest.raises(ValueError, match="crosses itself"): + build_part(spec) + + +def test_an_unknown_material_is_refused(): + with pytest.raises(ValueError, match="material"): + build_part(dict(SHAFT, material="unobtainium")) + + +def test_a_feature_off_the_part_is_refused_by_its_own_name(): + part = RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=(StubFeature(id="keyway_1", segment=5),), + ) + with pytest.raises(ValueError) as excinfo: + validate_part(part) + message = str(excinfo.value) + assert "keyway_1" in message + assert "segment 5" in message + assert "segments 0-2" in message + + +def test_two_features_removing_the_same_material_are_refused_with_both_names(): + part = RevolvedPart( + name="shaft", + segments=(Segment(60.0, 30.0),), + features=( + StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)), + StubFeature(id="groove_b", box=(12.0, 13.0, 16.0, 15.0)), + ), + ) + with pytest.raises(ValueError) as excinfo: + validate_part(part) + message = str(excinfo.value) + assert "groove_a" in message and "groove_b" in message + assert "same material" in message + + +def test_features_that_only_touch_are_not_an_overlap(): + part = RevolvedPart( + name="shaft", + segments=(Segment(60.0, 30.0),), + features=( + StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)), + StubFeature(id="groove_b", box=(14.0, 12.0, 18.0, 15.0)), + ), + ) + validate_part(part) + + +def test_an_unknown_kind_is_refused(): + with pytest.raises(ValueError, match="kind"): + build_part(dict(SHAFT, kind="lofted")) + + +def test_an_unknown_segment_key_is_refused_by_name(): + spec = dict(SHAFT, segments=[{"length": 10.0, "d_outer": 20.0, "diameter": 20.0}]) + with pytest.raises(ValueError) as excinfo: + build_part(spec) + assert "segments[0]" in str(excinfo.value) + assert "diameter" in str(excinfo.value) + + +def test_a_duplicate_feature_id_is_refused(): + """The only test here that reaches `feature_from_dict` (Task 3).""" + spec = dict( + SHAFT, + features=[ + {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}, + {"kind": "chamfer", "id": "c1", "at": "end", "size": 2.0}, + ], + ) + with pytest.raises(ValueError, match="duplicate feature id"): + build_part(spec)