From 04d269318a9a4dcce7618980b23c7039eaead57e Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 18:52:39 +0300 Subject: [PATCH] feat(task-2): the mechanical part model - RevolvedPart, PrismaticPart, build_part engineering/mech/part.py is the model the whole track draws from: a revolved stack of segments along +X, or a closed prismatic outline plus a thickness, each carrying a list of typed features. Pure arithmetic - no backend, no async, no DXF - which is what lets the view engine be tested without a drawing. Every refusal fires before anything is drawn and names the offending path the way pid_from_spec does: a non-finite or non-positive number as segments[2].d_outer, a bore that is not smaller than its outside (a bore that changes diameter is two segments), an outline with fewer than three distinct vertices, an outline that crosses itself (engineering.measure.is_self_intersecting - the shoelace cancels crossed lobes rather than failing, so a bowtie would otherwise measure 0.0), an unknown material, an unknown segment key, a duplicate feature id. Two gates are the reason this is a model rather than a dict: a feature whose placement is off the part is refused by its own name and by the range it missed, and two features that would remove the same material are refused with both names and the overlapping box. Neither is clamped. part_to_dict round-trips through build_part, which is what lets mech_part_draw write the model onto the drawing as ACADMCP_MECH XDATA and mech_view_add read it back months later without the caller re-describing the part. Two fixes to the plan's own code and test list, both recorded in deviations: _features_from imported engineering.mech.features unconditionally, so a featureless part - every fixture in this module, and both round-trips - died on ModuleNotFoundError until Task 3 lands; the import now happens only when there is a feature dict to build. And the plan's test file omitted test_a_duplicate_feature_id_is_refused while its own gate deselects it by name, so the duplicate-id refusal the commit message claims went unpinned; the test is written here and Task 3 turns it green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 30 +++ engineering/mech/part.py | 359 +++++++++++++++++++++++++++++++++++ tests/test_mech_part.py | 216 +++++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 engineering/mech/part.py create mode 100644 tests/test_mech_part.py 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)