From 04d269318a9a4dcce7618980b23c7039eaead57e Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 18:52:39 +0300 Subject: [PATCH 01/11] 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) From ac6c4075ba7e358dba1984dc7924d2ac88bf90e3 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:12:00 +0300 Subject: [PATCH 02/11] 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) From 457c53644f950902e280b8108239ca3b667314bd Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:48:00 +0300 Subject: [PATCH 03/11] fix(task-3): keyway end-view arc, the machined face, and the chamfer's segment bound Three defects in the feature catalogue, all faithfully transcribed from the plan text and all wrong in shipped code. 1. Keyway.prims(view="side") emitted the arc the keyway REMOVES. `Arc` is CCW start -> end, and the emitted (right wall -> left wall) is the ~31 degree cap over the slot opening, not the ~329 degrees of shaft that survives it. Measured live on AutoCAD 2026 (Documents.Add scratch doc, closed in a finally): ModelSpace.AddArc(center, radius, start_rad, end_rad) -- angles in RADIANS, CCW start -> end -- gives ArcLength 8.098 mm for the emitted 74.534 -> 105.466 deg and 86.150 mm for the reversed 105.466 -> 74.534 deg, against a 94.248 mm full circle at r = 15; StartPoint/EndPoint of the emitted arc are exactly the notch walls at x = +/-4. Swapped, so the arc now runs left wall -> right wall the long way round, and the side view's bbox goes from (-4, 11, 4, 15) to (-15, -15, 15, 14.457). 2. Hole and Pocket cut the plate from OPPOSITE faces in the same view frame: Hole ran z = 0 -> depth, Pocket z = thickness -> thickness - depth, and neither takes a face parameter. A plate carrying both showed the pocket milled from one side and the blind hole drilled from the other, silently -- omission() returns None for both, so nothing reported it. The frame comment now names the machined face (v = thickness, the face the front view looks at), and Hole drills from it: the blind floor, the counterbore step and the through bore all hang down from there, as Pocket already did. 3. Chamfer.validate bounded the axial leg against the whole part, never against the segment the chamfer sits on. A shallow lead-in passed validation and was drawn straight through a shoulder at a radius that segment does not have (size 30 at 10 deg on a 15 mm end segment: radial 5.29 < 10 so the radial guard was happy, yet `before` landed at x = 45, two segments away). New helper _anchor_segment names the segment a corner actually cuts into -- at a step, the adjacent one carrying the larger radius -- and the leg is bounded by its length, refused by name with the step it would have crossed. Tests: the keyway test asserted only primitive counts and two x coordinates, so it passed with the arc reversed; it now pins the sweep, the endpoints and the bbox. The hole test exercised only a through hole, which is symmetric; added a blind-hole-and-pocket test that pins both to the same face, and a counterbore test. Added three chamfer tests (two refusals, one that must still be accepted). Each fix was mutation-checked: reverting it fails its test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/features.py | 83 ++++++++++++++++++------ tests/test_mech_features.py | 121 ++++++++++++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 21 deletions(-) diff --git a/engineering/mech/features.py b/engineering/mech/features.py index 34cc21d..56e5e55 100644 --- a/engineering/mech/features.py +++ b/engineering/mech/features.py @@ -202,6 +202,23 @@ def _anchor(feature, part: RevolvedPart) -> tuple[float, float, int]: return (x, left, -1) if left >= right else (x, right, +1) +def _anchor_segment(feature, part: RevolvedPart) -> int: + """Index of the segment a chamfer at ``feature.at`` actually cuts into. + + ``_anchor`` gives the corner and the direction into the material; the leg + runs that way, so at a step it belongs to whichever of the two adjacent + segments carries the larger radius. + """ + kind, index = _parse_at(feature.at, part) + if kind == "start": + return 0 + if kind == "end": + return len(part.segments) - 1 + left = _radius_right_end(part, index) + right = _radius_left_end(part, index + 1) + return index if left >= right else index + 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) @@ -342,15 +359,22 @@ class Chamfer(_Base): 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) + x, radius, direction = _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).") + index = _anchor_segment(self, part) + segment_length = part.segments[index].length + if axial > segment_length + _EPS: + far = x + direction * segment_length + raise ValueError( + f"size {self.size} at {self.at!r} runs {axial:g} mm along the axis, past " + f"segment {index}'s {segment_length:g} mm: the chamfer would cross the step " + f"at x={far:g} and be anchored at a radius that segment does not have." + ) del x def corner_edit(self, part) -> dict: @@ -549,10 +573,14 @@ class Keyway(_Base): 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 + right = math.degrees(math.atan2(top, half)) % 360.0 + left = math.degrees(math.atan2(top, -half)) % 360.0 + # `Arc` is CCW from start to end, so the shaft that SURVIVES the slot + # runs from the left notch wall the long way round to the right one. + # Emitting (right -> left) would draw the ~31 degree cap over the + # opening instead of the ~329 degrees of material. return ( - Arc((0.0, 0.0), radius, start, end, "visible"), + Arc((0.0, 0.0), radius, left, right, "visible"), Line((-half, top), (-half, floor), "visible"), Line((-half, floor), (half, floor), "visible"), Line((half, floor), (half, top), "visible"), @@ -1417,12 +1445,24 @@ class GearTeeth(_Base): # "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) +# +# THE MACHINED FACE IS v = thickness. The front view looks at the outline from +# +Z, so the face it shows -- the face a feature placed by its (x, y) is cut +# from -- is the top of the side and top views, and every blind feature hangs +# down from there toward v = 0. Hole, Pocket and anything added later must +# agree on this: a drawing whose pocket is milled from one face and whose blind +# hole is drilled from the other is silently wrong, and no validator can tell. @_register @dataclass(frozen=True, kw_only=True) class Hole(_Base): - """A hole in a prismatic part, optionally counterbored, countersunk or tapped.""" + """A hole in a prismatic part, optionally counterbored, countersunk or tapped. + + Drilled from the machined face (``v = thickness``, the one the front view + looks at), so a blind hole hangs down from there and a counterbore steps + down from the same face -- the convention ``Pocket`` follows too. + """ KIND: ClassVar[str] = "hole" x: float @@ -1484,19 +1524,21 @@ class Hole(_Base): return tuple(prims) if view in ("side", "top"): u = y if view == "side" else x + face = part.thickness depth = part.thickness if self.depth is None else float(self.depth) + floor = face - depth prims = [ - Line((u - half, 0.0), (u - half, depth), "hidden"), - Line((u + half, 0.0), (u + half, depth), "hidden"), + Line((u - half, face), (u - half, floor), "hidden"), + Line((u + half, face), (u + half, floor), "hidden"), ] if self.depth is not None: - prims.append(Line((u - half, depth), (u + half, depth), "hidden")) + prims.append(Line((u - half, floor), (u + half, floor), "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")) + cf = face - float(self.cbore_depth) + prims.append(Line((u - cb, face), (u - cb, cf), "hidden")) + prims.append(Line((u + cb, face), (u + cb, cf), "hidden")) + prims.append(Line((u - cb, cf), (u + cb, cf), "hidden")) return tuple(prims) return () @@ -1647,7 +1689,11 @@ class Slot(_Base): @_register @dataclass(frozen=True, kw_only=True) class Pocket(_Base): - """A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3).""" + """A milled pocket. ``no_section_hatch`` marks a rib or web (ISO 128-3). + + Milled from the machined face (``v = thickness``), the same face ``Hole`` + drills from. + """ KIND: ClassVar[str] = "pocket" outline: tuple[tuple[float, float], ...] @@ -1685,11 +1731,12 @@ class Pocket(_Base): 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) + face = part.thickness + floor = face - float(self.depth) return ( - Line((u0, part.thickness), (u0, floor), "hidden"), + Line((u0, face), (u0, floor), "hidden"), Line((u0, floor), (u1, floor), "hidden"), - Line((u1, floor), (u1, part.thickness), "hidden"), + Line((u1, floor), (u1, face), "hidden"), ) return () diff --git a/tests/test_mech_features.py b/tests/test_mech_features.py index 7a00e73..b529d3f 100644 --- a/tests/test_mech_features.py +++ b/tests/test_mech_features.py @@ -41,7 +41,7 @@ from engineering.mech.features import ( mirror_about_x, ) from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part -from engineering.mech.primitives import Arc, Circle, Line, Poly, Text +from engineering.mech.primitives import Arc, Circle, Line, Poly, Text, bbox CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"} @@ -167,6 +167,43 @@ def test_a_chamfer_bigger_than_the_step_is_refused_by_name(): feature.validate(shaft(feature)) +def test_a_chamfer_whose_axial_leg_outruns_its_segment_is_refused_by_name(): + """The radial guard only bites near 45 deg; a shallow lead-in slips past it. + + The end segment of `shaft()` is 15 mm long at r = 10. A 30 mm chamfer at + 10 deg takes only 5.29 mm off the radius, so `radial >= radius` is happy, + and the old part-length bound (75 mm) was happy too - yet `before` landed + at x = 45, two segments away, anchored at a radius that segment does not + have. + """ + feature = Chamfer(id="c1", at="end", size=30.0, angle=10.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + message = str(excinfo.value) + assert "30" in message and "15" in message and "segment 2" in message + + +def test_a_shallow_chamfer_that_fits_inside_its_segment_is_still_accepted(): + """The new bound refuses only what leaves the segment; 14 < 15 mm stays.""" + feature = Chamfer(id="c1", at="end", size=14.0, angle=10.0) + part = shaft(feature) + feature.validate(part) + edit = feature.corner_edit(part) + assert edit["before"][0] == pytest.approx(61.0) + assert edit["before"][0] >= 60.0 # inside segment 2, which spans x 60..75 + + +def test_a_chamfer_at_a_step_is_bounded_by_the_segment_it_cuts_into(): + """`shaft()` steps UP at x = 20, so the chamfer cuts into segment 1 (40 mm). + + A 45 mm leg outruns that segment even though the part is 75 mm long. + """ + feature = Chamfer(id="c1", at="step:0", size=45.0, angle=5.0) + with pytest.raises(ValueError) as excinfo: + feature.validate(shaft(feature)) + assert "segment 1" in str(excinfo.value) and "40" in str(excinfo.value) + + 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) @@ -216,6 +253,16 @@ def test_a_keyway_takes_its_width_and_depth_from_din_6885(): def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch(): + """The arc is the shaft that SURVIVES the slot, not the cap over its mouth. + + `Arc` is CCW start -> end, so the sweep has to be the long way round: it + leaves the left notch wall, goes over the bottom of the shaft and arrives + at the right one. Emitting the two angles the other way round draws a short + chord-like cap straight across the keyway opening - geometrically a legal + arc, which is why only the sweep and the endpoints catch it. Measured on + AutoCAD 2026: the 30.93 deg arc is 8.098 mm long, the 329.07 deg one is + 86.150 mm, and the full r=15 circle is 94.248 mm. + """ feature = Keyway(id="kw", segment=1, length=25.0) part = shaft(feature) prims = feature.prims(part, "side", CTX) @@ -226,6 +273,22 @@ def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch(): assert lines[1].p1[0] == pytest.approx(-width / 2.0) assert lines[1].p2[0] == pytest.approx(width / 2.0) + arc = arcs[0] + radius = 15.0 + half = width / 2.0 + top = math.sqrt(radius * radius - half * half) + # start at the LEFT wall, end at the RIGHT one: everything but the opening. + assert arc.radius == pytest.approx(radius) + assert arc.start_deg == pytest.approx(math.degrees(math.atan2(top, -half))) + assert arc.end_deg == pytest.approx(math.degrees(math.atan2(top, half))) + sweep = (arc.end_deg - arc.start_deg) % 360.0 + assert sweep == pytest.approx(329.068, abs=1e-3) + assert math.radians(sweep) * radius == pytest.approx(86.150, abs=1e-3) + # and the end view therefore spans the whole shaft, not just the notch: + # full width and full depth, topped by the notch walls because the slot has + # taken the crown of the circle away. + assert bbox(prims) == pytest.approx((-radius, -radius, radius, top)) + def test_a_keyway_longer_than_its_segment_is_refused_by_name(): feature = Keyway(id="kw", segment=1, length=50.0) @@ -468,9 +531,61 @@ def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view() 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) + # a through hole runs the whole thickness, drawn from the machined face down + assert side[0].p1 == (20.0, 8.0) and side[0].p2 == (20.0, 0.0) top = feature.prims(part, "top", CTX) - assert top[0].p1 == (15.0, 0.0) + assert top[0].p1 == (15.0, 8.0) + + +def test_a_blind_hole_and_a_pocket_are_cut_from_the_same_face(): + """Both enter at v = thickness - the face the front view looks at. + + Nothing downstream can catch a disagreement here: two blind features cut + from opposite faces of the same plate are each a legal drawing on their + own, `omission()` returns None for both, and the validator sees only + hidden lines inside the material. So it has to be pinned at the source. + """ + hole = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, depth=5.0) + pocket = Pocket( + id="p1", + outline=((40.0, 10.0), (70.0, 10.0), (70.0, 40.0), (40.0, 40.0)), + depth=5.0, + ) + part = plate(hole, pocket) + hole.validate(part) + pocket.validate(part) + for view in ("side", "top"): + entry = {round(p.p1[1], 9) for p in hole.prims(part, view, CTX)} | { + round(p.p2[1], 9) for p in hole.prims(part, view, CTX) + } + pocket_entry = {round(p.p1[1], 9) for p in pocket.prims(part, view, CTX)} | { + round(p.p2[1], 9) for p in pocket.prims(part, view, CTX) + } + # the same two v values: the face at 8.0 and a floor 5 mm below it + assert entry == {8.0, 3.0} + assert pocket_entry == {8.0, 3.0} + (left, right, floor) = hole.prims(part, "side", CTX) + assert left.p1 == (22.0, 8.0) and left.p2 == (22.0, 3.0) + assert right.p1 == (28.0, 8.0) and right.p2 == (28.0, 3.0) + assert floor.p1 == (22.0, 3.0) and floor.p2 == (28.0, 3.0) + + +def test_a_counterbore_is_stepped_down_from_the_machined_face_too(): + feature = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, cbore_d=12.0, cbore_depth=3.0) + part = plate(feature) + feature.validate(part) + # the side view runs u = part Y, so the hole sits at u = 25 and the + # counterbore walls at 25 +/- 6. + side = feature.prims(part, "side", CTX) + cbore = [p for p in side if {19.0, 31.0} & {p.p1[0], p.p2[0]}] + assert len(cbore) == 3 + assert {round(p.p1[1], 9) for p in cbore} | {round(p.p2[1], 9) for p in cbore} == {8.0, 5.0} + # and the bore itself still runs the full thickness from that same face + bore = [p for p in side if {22.0, 28.0} & {p.p1[0], p.p2[0]}] + assert [(p.p1, p.p2) for p in bore] == [ + ((22.0, 8.0), (22.0, 0.0)), + ((28.0, 8.0), (28.0, 0.0)), + ] def test_a_hole_outside_the_outline_is_refused_by_name(): From 48eb057273d1819654c48dd823b377243f6d13d1 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:56:04 +0300 Subject: [PATCH 04/11] feat(task-4): the orthographic view engine - silhouette, hidden lines, layout engineering/mech/views.py projects a part model onto a view plane and returns primitive descriptions, so a test asserts geometry rather than a screenshot. The one calculation everything comes out of for a revolved part is the radial profile: the part cut into bands of constant bore and constant (or linearly tapering) outside, with every feature's radial_edit applied. The front view is that profile walked as a path and mirrored about the axis; the section cut face of the next task is the same profile walked as a closed loop, which is what stops the outline and the hatched area ever disagreeing. Chamfers and fillets do not draw over the silhouette - they substitute their corner in it, so the overshoot the critique focus looks for cannot be produced by construction, and a corner edit that finds no corner is reported rather than dropped. A band's radii are read off the segment the band's MIDPOINT lies in and the taper is evaluated at the band's own two ends. Sampling a hair inside the band to dodge the segment-boundary ambiguity - the way the plan's draft did - writes the taper slope times that inset into every coordinate the silhouette is built from: a 30 -> 10 mm cone over 20 mm then reports 14.99999 for a radius that is exactly 15, and nothing downstream can tell that it is wrong. The end view applies a real hidden-line rule: walking the bands from the viewing end, a diameter larger than everything nearer than it is drawn hidden, a bore behind material is hidden, and the near end's own circles are visible. Looking from the left reverses it. A revolved part's top view is refused, because it repeats the front. Prismatic parts get the outline in true shape with corner fillets and chamfers substituted in, and side/top thickness rectangles - the case where a hand-drawing model reliably forgets the hidden line per through feature, which here comes out of the feature itself. The honesty rule of spec section 6 is enforced in one place: a feature that emits no primitives and edits neither the radial profile nor a corner is listed in the view's omitted tuple with its own reason. Nothing is silently dropped. layout_origin places views in first-angle positions by default (the view from the right goes to the left, the view from above goes below) and flips both for third angle. It takes one additive keyword beyond the pinned signature, child, because placing a view to the left depends on that view's own width; without it the child is assumed the parent's size and every pinned call still works. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 28 ++- engineering/mech/views.py | 448 +++++++++++++++++++++++++++++++++++ tests/test_mech_views.py | 299 +++++++++++++++++++++++ 3 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 engineering/mech/views.py create mode 100644 tests/test_mech_views.py diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index 4c71d20..9f6e4e1 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -67,14 +67,22 @@ from .primitives import ( scale, translate, ) +from .views import ( + PROJECTIONS, + VIEW_KINDS, + Band, + View, + build_view, + layout_origin, + outline_prims, + radial_profile, + silhouette_prims, +) __all__ = [ - "FEATURE_KINDS", - "PART_KINDS", - "ROLES", - "ROLE_LAYER", "Arc", "AxialBore", + "Band", "BendLine", "CentreHole", "Chamfer", @@ -83,6 +91,7 @@ __all__ = [ "CornerFillet", "CrossHole", "DimIntent", + "FEATURE_KINDS", "Feature", "Fillet", "GearTeeth", @@ -93,11 +102,15 @@ __all__ = [ "Keyway", "Line", "ORingGroove", + "PART_KINDS", + "PROJECTIONS", "Pocket", "Poly", "Prim", "PrismaticPart", "Pt", + "ROLES", + "ROLE_LAYER", "RetainingGroove", "RevolvedPart", "Role", @@ -106,22 +119,29 @@ __all__ = [ "Text", "Thread", "Undercut", + "VIEW_KINDS", + "View", "bbox", "build_part", + "build_view", "feature_from_dict", "inner_radius_at", "layer_for", + "layout_origin", "mirror_about_x", "outer_radius_at", "outline_bbox", + "outline_prims", "part_length", "part_max_diameter", "part_to_dict", "point_in_outline", "points_of", + "radial_profile", "rotate", "scale", "segment_bounds", + "silhouette_prims", "translate", "validate_part", ] diff --git a/engineering/mech/views.py b/engineering/mech/views.py new file mode 100644 index 0000000..7a3133d --- /dev/null +++ b/engineering/mech/views.py @@ -0,0 +1,448 @@ +"""The view engine: a part model projected onto a plane, as primitive descriptions. + +Pure: no backend, no async, no DXF. A ``View`` carries the primitives, the +dimension intents its features asked for, the features it could **not** project +(the honesty rule of spec section 6 - nothing is silently dropped), and its own +bounding box, so the next view can be laid out against it. + +The one calculation everything comes out of for a revolved part is the **radial +profile**: the part cut into bands of constant (or linearly tapering) outer +radius and constant bore radius, with every feature's ``radial_edit`` applied. +The silhouette is that profile walked as a path; the section cut face (Task 5) +is the same profile walked as a closed loop. One calculation, so the outline +and the hatched area can never disagree. + +Line roles follow ISO 128 and are mapped to layers by +``engineering/mech/primitives.py``: the silhouette is ``visible``, a bore or a +feature behind material is ``hidden``, the axis and centre marks are +``center``, a bend line is ``phantom``. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, NamedTuple + +from engineering.mech.features import mirror_about_x +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + outline_bbox, + part_length, + part_max_diameter, + segment_bounds, +) +from engineering.mech.primitives import Circle, DimIntent, Line, Prim, Pt, bbox + +_EPS = 1e-9 +_SNAP = 6 # decimals used to match a corner vertex to a corner edit + +VIEW_KINDS: tuple[str, ...] = ("front", "side", "top") +PROJECTIONS: tuple[str, ...] = ("first", "third") +SIDES: tuple[str, ...] = ("right", "left") + +#: How far the axis and the centre lines run past the material, as a fraction +#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun). +AXIS_OVERRUN = 0.10 + + +@dataclass(frozen=True) +class View: + kind: str + prims: tuple[Prim, ...] + dims: tuple[DimIntent, ...] + omitted: tuple[dict, ...] + bbox: tuple[float, float, float, float] + label: str | None = None + scale: float = 1.0 + + +class Band(NamedTuple): + """One axial slice of a revolved part with a constant bore and a linear outside.""" + + x0: float + x1: float + r_in: float + r_out0: float + r_out1: float + + +# -- the radial profile ------------------------------------------------------ + + +def _band_radii(part: RevolvedPart, a: float, b: float) -> tuple[float, float, float]: + """``(r_out0, r_out1, r_in)`` of one band, read off the segment it lies in. + + The segment is resolved from the band's **midpoint** - a band never spans a + segment boundary, because every boundary is one of the cuts - and a taper is + then evaluated at the band's own two ends. Sampling a hair inside the band + instead (the obvious way to dodge the boundary ambiguity) writes the taper + slope times that inset into every coordinate the silhouette is built from: + a 30 -> 10 mm cone over 20 mm reports 14.99999 for a radius that is exactly + 15, and nothing downstream can tell that it is wrong. + """ + mid = (a + b) / 2.0 + found: tuple[float, float, Any] | None = None + for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True): + if x0 - _EPS <= mid <= x1 + _EPS: + found = (x0, x1, segment) + break + if found is None: # pragma: no cover - the cuts always include every segment bound + raise ValueError(f"radial_profile: x={mid} is outside the part.") + x0, x1, segment = found + r_in = segment.d_inner / 2.0 + if segment.taper_to is None: + radius = segment.d_outer / 2.0 + return radius, radius, r_in + span = x1 - x0 + + def taper_at(x: float) -> float: + t = 0.0 if span <= _EPS else (x - x0) / span + return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0 + + return taper_at(a), taper_at(b), r_in + + +def radial_profile(part: RevolvedPart) -> tuple[Band, ...]: + """The part as bands, with every feature's ``radial_edit`` applied.""" + length = part_length(part) + cuts: set[float] = {0.0, length} + for x0, x1 in segment_bounds(part): + cuts.add(x0) + cuts.add(x1) + + edits: list[dict] = [] + for feature in part.features: + for edit in feature.radial_edit(part): + x0 = max(0.0, min(length, float(edit["x0"]))) + x1 = max(0.0, min(length, float(edit["x1"]))) + if x1 - x0 <= _EPS: + continue + edits.append( + {"kind": edit["kind"], "x0": x0, "x1": x1, "radius": float(edit["radius"])} + ) + cuts.add(x0) + cuts.add(x1) + + xs = sorted(cuts) + bands: list[Band] = [] + for a, b in zip(xs, xs[1:], strict=False): + if b - a <= _EPS: + continue + r_out0, r_out1, r_in = _band_radii(part, a, b) + for edit in edits: + if edit["x0"] - _EPS <= a and b <= edit["x1"] + _EPS: + if edit["kind"] == "outer": + r_out0 = min(r_out0, edit["radius"]) + r_out1 = min(r_out1, edit["radius"]) + else: + r_in = max(r_in, edit["radius"]) + bands.append(Band(a, b, r_in, r_out0, r_out1)) + return tuple(bands) + + +def _outer_path(bands: tuple[Band, ...]) -> list[Pt]: + """The +Y half-outline, from the left face at the bore, over the outside, down + to the right face at the bore. The end faces are *part of the path*, so a + chamfer or fillet at an end corner substitutes into them instead of being + drawn across them - which is what stops the overshoot the `untrimmed_corner` + critique focus looks for.""" + points: list[Pt] = [(bands[0].x0, bands[0].r_in), (bands[0].x0, bands[0].r_out0)] + for band in bands: + if math.dist(points[-1], (band.x0, band.r_out0)) > _EPS: + points.append((band.x0, band.r_out0)) + points.append((band.x1, band.r_out1)) + points.append((bands[-1].x1, bands[-1].r_in)) + return points + + +def _bore_path(bands: tuple[Band, ...]) -> list[list[Pt]]: + """One path per contiguous run of bored bands; a blind end is capped.""" + runs: list[list[Pt]] = [] + current: list[Pt] = [] + for band in bands: + if band.r_in <= _EPS: + if current: + runs.append(current) + current = [] + continue + if not current: + current = [(band.x0, band.r_in)] + elif math.dist(current[-1], (band.x0, band.r_in)) > _EPS: + current.append((band.x0, band.r_in)) + current.append((band.x1, band.r_in)) + if current: + runs.append(current) + return runs + + +def _key(point: Pt) -> tuple[float, float]: + return (round(float(point[0]), _SNAP), round(float(point[1]), _SNAP)) + + +def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = False): + """Walk a vertex path, substituting corner edits, and emit Lines and Arcs. + + Returns ``(prims, used)`` - ``used`` is the set of edit ids that actually + found their corner, so the caller can report the ones that did not. + """ + by_corner: dict[tuple[float, float], dict] = {} + for edit in edits: + by_corner[_key(edit["corner"])] = edit + + sequence: list[tuple[str, Any]] = [] + used: set[str] = set() + for point in points: + edit = by_corner.get(_key(point)) + if edit is None: + sequence.append(("pt", (float(point[0]), float(point[1])))) + continue + used.add(edit["owner"]) + sequence.append(("pt", edit["before"])) + if edit["arc"] is not None: + sequence.append(("arc", edit["arc"])) + sequence.append(("pt", edit["after"])) + + if closed and sequence: + sequence.append(sequence[0]) + + prims: list[Prim] = [] + previous: Pt | None = None + for tag, item in sequence: + if tag == "pt": + if previous is not None and math.dist(previous, item) > _EPS: + prims.append(Line(previous, item, role)) + previous = item + else: + prims.append(item) + previous = None + return tuple(prims), used + + +def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" + bands = radial_profile(part) + edits: list[dict] = [] + for feature in part.features: + edit = feature.corner_edit(part) + if edit is not None: + edits.append({**edit, "owner": feature.id}) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "hidden") + prims.extend(bore) + prims.extend(mirror_about_x(bore)) + + unplaced = tuple( + { + "feature": edit["owner"], + "reason": ( + "its silhouette corner was changed by another feature, so the " + "chamfer/fillet could not be placed" + ), + } + for edit in edits + if edit["owner"] not in used + ) + return tuple(prims), unplaced + + +def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The closed outline with every corner fillet/chamfer substituted in.""" + edits: list[dict] = [] + for feature in part.features: + edit = feature.outline_edit(part) + if edit is None: + continue + corner = part.outline[int(edit["vertex"]) % len(part.outline)] + edits.append({**edit, "corner": corner, "owner": feature.id}) + + prims, used = _emit_path(list(part.outline), edits, "visible", closed=True) + unplaced = tuple( + {"feature": edit["owner"], "reason": "its outline vertex was already replaced"} + for edit in edits + if edit["owner"] not in used + ) + return prims, unplaced + + +# -- view construction ------------------------------------------------------- + + +def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]: + length = part_length(part) + over = max(length, part_max_diameter(part)) * AXIS_OVERRUN + return (Line((-over, 0.0), (length + over, 0.0), "center"),) + + +def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]: + bands = radial_profile(part) + order = list(reversed(bands)) if side == "right" else list(bands) + + prims: list[Prim] = [] + seen_out = 0.0 + for index, band in enumerate(order): + radius = max(band.r_out0, band.r_out1) + if radius > seen_out + 1e-6: + prims.append(Circle((0.0, 0.0), radius, "visible" if index == 0 else "hidden")) + seen_out = radius + seen_in = 0.0 + for index, band in enumerate(order): + if band.r_in > seen_in + 1e-6: + prims.append(Circle((0.0, 0.0), band.r_in, "visible" if index == 0 else "hidden")) + seen_in = band.r_in + + over = seen_out * (1.0 + AXIS_OVERRUN) + prims.append(Line((-over, 0.0), (over, 0.0), "center")) + prims.append(Line((0.0, -over), (0.0, over), "center")) + return tuple(prims) + + +def _rectangle( + u0: float, u1: float, v0: float, v1: float, role: str = "visible" +) -> tuple[Prim, ...]: + return ( + Line((u0, v0), (u1, v0), role), + Line((u1, v0), (u1, v1), role), + Line((u1, v1), (u0, v1), role), + Line((u0, v1), (u0, v0), role), + ) + + +def _feature_layer(part, kind: str, ctx: dict) -> tuple[list[Prim], list[DimIntent], list[dict]]: + prims: list[Prim] = [] + dims: list[DimIntent] = [] + omitted: list[dict] = [] + for feature in part.features: + own = feature.prims(part, kind, ctx) + if own and kind == "front" and getattr(feature, "axisymmetric", False): + own = tuple(own) + mirror_about_x(own) + prims.extend(own) + dims.extend(feature.dims(part, kind, ctx)) + if own: + continue + if feature.radial_edit(part) or feature.corner_edit(part) or feature.outline_edit(part): + continue + reason = feature.omission(part, kind) + if reason: + omitted.append({"feature": feature.id, "reason": reason}) + return prims, dims, omitted + + +def build_view( + part, + kind: str, + *, + side: str = "right", + plane: dict | None = None, + style: str = "full", + scale: float = 1.0, + projection: str = "first", + detail: dict | None = None, +) -> View: + """Project a part onto one view plane and return its primitive description.""" + name = str(kind or "").strip().lower() + if name not in VIEW_KINDS: + raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.") + where = str(side or "right").strip().lower() + if where not in SIDES: + raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.") + del plane, style, detail # the orthographic kinds take no plane or detail + + ctx = {"side": where, "scale": float(scale), "projection": str(projection)} + prims: list[Prim] = [] + omitted: list[dict] = [] + + if isinstance(part, RevolvedPart): + if name == "top": + raise ValueError( + "build_view: a revolved part's top view is identical to its front view; " + "use kind='front', or kind='section' for a cut." + ) + if name == "front": + body, unplaced = silhouette_prims(part) + prims.extend(body) + prims.extend(_axis_prims(part)) + omitted.extend(unplaced) + else: + prims.extend(_end_circles(part, where)) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if name == "front": + body, unplaced = outline_prims(part) + prims.extend(body) + omitted.extend(unplaced) + elif name == "side": + prims.extend(_rectangle(y0, y1, 0.0, part.thickness)) + else: + prims.extend(_rectangle(x0, x1, 0.0, part.thickness)) + else: # pragma: no cover - build_part only makes these two + raise ValueError(f"build_view: {type(part).__name__} is not a part model.") + + feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx) + prims.extend(feature_prims) + omitted.extend(feature_omitted) + + return View( + kind=name, + prims=tuple(prims), + dims=tuple(dims), + omitted=tuple(omitted), + bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0), + label=None, + scale=float(scale), + ) + + +def layout_origin( + parent: View, + kind: str, + *, + side: str = "right", + projection: str = "first", + gap: float = 20.0, + child: View | None = None, +) -> Pt: + """Where a child view's bounding box lower-left corner goes, relative to its parent. + + First angle (ISO 128-30, the default here): the view obtained by looking + from the right is placed on the **left**, and the view from above is placed + **below**. Third angle flips both. With no ``child`` the child is assumed + the same size as the parent, which keeps the views aligned even before the + child has been built. + """ + projection_name = str(projection or "").strip().lower() + if projection_name not in PROJECTIONS: + raise ValueError(f"layout_origin: projection {projection!r} is not one of {PROJECTIONS}.") + px0, py0, px1, py1 = parent.bbox + if child is None: + width, height = px1 - px0, py1 - py0 + else: + cx0, cy0, cx1, cy1 = child.bbox + width, height = cx1 - cx0, cy1 - cy0 + mid_x = (px0 + px1) / 2.0 + mid_y = (py0 + py1) / 2.0 + first = projection_name == "first" + name = str(kind or "").strip().lower() + gap = float(gap) + + if name in ("side", "section"): + looking_from_right = str(side or "right").strip().lower() == "right" + to_the_left = looking_from_right == first + if to_the_left: + return (px0 - gap - width, mid_y - height / 2.0) + return (px1 + gap, mid_y - height / 2.0) + if name == "top": + if first: + return (mid_x - width / 2.0, py0 - gap - height) + return (mid_x - width / 2.0, py1 + gap) + if name == "detail": + return (px1 + gap, mid_y - height / 2.0) + raise ValueError( + f"layout_origin: kind {kind!r} has no projection position; expected " + "'side', 'top', 'section' or 'detail'." + ) diff --git a/tests/test_mech_views.py b/tests/test_mech_views.py new file mode 100644 index 0000000..0951218 --- /dev/null +++ b/tests/test_mech_views.py @@ -0,0 +1,299 @@ +"""The orthographic view engine: silhouette, hidden lines, centre lines, layout. + +Everything is asserted as emitted primitive tuples - counts, roles, coordinates +to 1e-9. The radial profile is the single calculation both the silhouette and +(from Task 5) the section cut face come out of, so it is pinned first. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.features import ( + AxialBore, + Chamfer, + CornerFillet, + CrossHole, + Fillet, + Hole, + Keyway, + RetainingGroove, +) +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import Arc, Circle, Line +from engineering.mech.views import ( + PROJECTIONS, + VIEW_KINDS, + Band, + View, + build_view, + layout_origin, + radial_profile, +) + +EPS = 1e-9 + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.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 roles(view: View, role: str): + return [p for p in view.prims if getattr(p, "role", None) == role] + + +def test_the_engine_declares_three_orthographic_kinds_and_two_projections(): + assert VIEW_KINDS == ("front", "side", "top") + assert PROJECTIONS == ("first", "third") + + +# -- the radial profile ------------------------------------------------------ + + +def test_a_plain_shaft_has_one_band_per_segment(): + bands = radial_profile(shaft()) + assert bands == ( + Band(0.0, 20.0, 0.0, 12.5, 12.5), + Band(20.0, 60.0, 0.0, 15.0, 15.0), + Band(60.0, 75.0, 0.0, 10.0, 10.0), + ) + + +def test_a_taper_is_carried_as_two_end_radii(): + part = RevolvedPart(name="cone", segments=(Segment(20.0, 30.0, 0.0, 10.0),)) + (band,) = radial_profile(part) + assert band.r_out0 == pytest.approx(15.0, abs=1e-6) + assert band.r_out1 == pytest.approx(5.0, abs=1e-6) + + +def test_a_ring_groove_cuts_a_band_out_of_the_outer_profile(): + groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + bands = radial_profile(shaft(groove)) + # the cut band is the one INSIDE the 30 mm segment whose outside is pulled + # back; segments 0 (d25) and 2 (d20) are simply thinner and are not cuts. + cut = [b for b in bands if 20.0 <= b.x0 and b.x1 <= 60.0 and b.r_out0 < 15.0 - EPS] + assert len(cut) == 1 + assert cut[0].x0 == pytest.approx(29.2) + assert cut[0].x1 == pytest.approx(30.8) + assert cut[0].r_out0 == pytest.approx(14.3) + + +def test_an_axial_bore_raises_the_inner_profile_over_its_depth(): + bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0) + bands = radial_profile(shaft(bore)) + bored = [b for b in bands if b.r_in > 0.0] + assert [(b.x0, b.x1) for b in bored] == [(0.0, 20.0), (20.0, 30.0)] + assert {b.r_in for b in bored} == {4.0} + + +# -- the revolved front view ------------------------------------------------- + + +def test_a_plain_shaft_front_view_is_a_mirrored_silhouette_on_an_axis(): + view = build_view(shaft(), "front") + assert view.kind == "front" + axis = roles(view, "center") + assert len(axis) == 1 + assert axis[0].p1[1] == 0.0 and axis[0].p2[1] == 0.0 + assert axis[0].p1[0] < 0.0 and axis[0].p2[0] > 75.0 + visible = roles(view, "visible") + # the +Y half-outline is 7 lines (left face, 3 cylinders, 2 steps, right face) + # and it is mirrored about the axis + assert len(visible) == 14 + ys = sorted({round(line.p1[1], 6) for line in visible}) + assert ys == [-15.0, -12.5, -10.0, 0.0, 10.0, 12.5, 15.0] + assert view.bbox == (pytest.approx(-7.5), -15.0, pytest.approx(82.5), 15.0) + + +def test_a_bore_reaches_the_front_view_as_hidden_lines(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "front") + hidden = roles(view, "hidden") + assert len(hidden) == 2 + assert {round(line.p1[1], 6) for line in hidden} == {10.0, -10.0} + assert hidden[0].p1[0] == 0.0 and hidden[0].p2[0] == 30.0 + + +def test_a_chamfer_replaces_the_silhouette_corner_instead_of_being_drawn_over_it(): + chamfer = Chamfer(id="c1", at="start", size=2.0) + view = build_view(shaft(chamfer), "front") + upper = [line for line in roles(view, "visible") if line.p1[1] > 0 or line.p2[1] > 0] + starts = {(round(line.p1[0], 6), round(line.p1[1], 6)) for line in upper} + assert (0.0, 10.5) in starts + assert (0.0, 12.5) not in starts + assert any(line.p1 == (0.0, 10.5) and line.p2 == (2.0, 12.5) for line in upper) + + +def test_a_fillet_puts_a_tangent_arc_into_the_silhouette(): + fillet = Fillet(id="f1", at="step:0", radius=2.0) + view = build_view(shaft(fillet), "front") + arcs = [p for p in view.prims if isinstance(p, Arc)] + assert len(arcs) == 2 # the fillet and its mirror + upper = next(a for a in arcs if a.center[1] > 0) + assert upper.center == (18.0, 14.5) + assert (upper.radius, upper.start_deg, upper.end_deg) == (2.0, 270.0, 360.0) + + +def test_an_axisymmetric_feature_is_mirrored_and_a_keyway_is_not(): + groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6) + keyway = Keyway(id="kw", segment=1, length=20.0, offset=5.0) + view = build_view(shaft(groove, keyway), "front") + groove_lines = [ + line for line in view.prims if isinstance(line, Line) and abs(abs(line.p1[1]) - 14.3) < 1e-6 + ] + assert {line.p1[1] > 0 for line in groove_lines} == {True, False} + floor = 15.0 - 4.0 # DIN 6885 depth_shaft for a 30 mm shaft + keyway_lines = [ + line for line in view.prims if isinstance(line, Line) and abs(line.p1[1] - floor) < 1e-6 + ] + assert keyway_lines and all(line.p1[1] > 0 for line in keyway_lines) + + +# -- the revolved end view --------------------------------------------------- + + +def test_the_end_view_marks_a_diameter_behind_a_larger_one_as_hidden(): + view = build_view(shaft(), "side", side="right") + circles = [p for p in view.prims if isinstance(p, Circle)] + radii = {(round(c.radius, 6), c.role) for c in circles} + assert (10.0, "visible") in radii # the near end + assert (15.0, "hidden") in radii # the middle step, behind it + assert (12.5, "hidden") not in radii # smaller than 15, never seen + assert len([p for p in view.prims if getattr(p, "role", None) == "center"]) == 2 + + +def test_looking_from_the_left_reverses_which_circle_is_visible(): + view = build_view(shaft(), "side", side="left") + circles = [p for p in view.prims if isinstance(p, Circle)] + radii = {(round(c.radius, 6), c.role) for c in circles} + assert (12.5, "visible") in radii + assert (15.0, "hidden") in radii + + +def test_a_revolved_top_view_is_refused_because_it_repeats_the_front(): + with pytest.raises(ValueError, match="identical to its front view"): + build_view(shaft(), "top") + + +# -- the prismatic views ----------------------------------------------------- + + +def test_a_prismatic_front_view_is_the_closed_outline(): + view = build_view(plate(), "front") + lines = roles(view, "visible") + assert len(lines) == 4 + assert view.bbox == (0.0, 0.0, 80.0, 50.0) + + +def test_a_corner_fillet_replaces_an_outline_vertex_with_an_arc(): + view = build_view(plate(CornerFillet(id="cf", vertex=1, radius=10.0)), "front") + arcs = [p for p in view.prims if isinstance(p, Arc)] + # the substituted arc carries role "visible" too, so the straight edges are + # counted by type: 4 outline edges remain, two of them shortened. + lines = [p for p in roles(view, "visible") if isinstance(p, Line)] + assert len(arcs) == 1 + assert arcs[0].center == (pytest.approx(70.0), pytest.approx(10.0)) + assert len(lines) == 4 + assert not any(abs(line.p1[0] - 80.0) < EPS and abs(line.p1[1]) < EPS for line in lines) + + +def test_a_prismatic_side_view_is_the_thickness_rectangle_with_a_hidden_line_per_hole(): + hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + view = build_view(plate(hole), "side") + visible = roles(view, "visible") + hidden = roles(view, "hidden") + assert len(visible) == 4 + assert view.bbox == (0.0, 0.0, 50.0, 8.0) + assert len(hidden) == 2 + assert {round(line.p1[0], 6) for line in hidden} == {20.0, 30.0} + + +def test_a_prismatic_top_view_projects_the_other_axis(): + hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + view = build_view(plate(hole), "top") + assert view.bbox == (0.0, 0.0, 80.0, 8.0) + hidden = roles(view, "hidden") + assert {round(line.p1[0], 6) for line in hidden} == {15.0, 25.0} + + +# -- the honesty rule -------------------------------------------------------- + + +def test_a_feature_that_projects_into_the_view_is_not_reported_as_omitted(): + side = build_view(shaft(Chamfer(id="c1", at="start", size=2.0)), "side") + assert side.omitted == () + front = build_view(shaft(CrossHole(id="xh", x=30.0, diameter=6.0)), "front") + assert front.omitted == () + bore = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)), "front") + assert bore.omitted == () + + +def test_a_bend_line_is_omitted_from_a_side_view_with_a_reason(): + from engineering.mech.features import BendLine + + bend = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0) + view = build_view(plate(bend), "side") + assert len(view.omitted) == 1 + assert view.omitted[0]["feature"] == "b1" + assert "side" in view.omitted[0]["reason"] + + +def test_the_view_collects_the_dimension_intents_of_its_own_features(): + keyway = Keyway(id="kw", segment=1, length=20.0) + view = build_view(shaft(keyway), "front") + assert [intent.feature for intent in view.dims] == ["kw"] + + +def test_an_unknown_view_kind_is_refused_by_name(): + with pytest.raises(ValueError, match="front"): + build_view(shaft(), "isometric") + + +# -- projection layout ------------------------------------------------------- + + +def test_first_angle_puts_the_right_hand_view_on_the_left_and_the_top_below(): + front = build_view(shaft(), "front") + end = build_view(shaft(), "side") + left = layout_origin(front, "side", side="right", projection="first", child=end) + width = end.bbox[2] - end.bbox[0] + height = end.bbox[3] - end.bbox[1] + assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width) + assert left[1] == pytest.approx((front.bbox[1] + front.bbox[3]) / 2.0 - height / 2.0) + below = layout_origin(front, "top", projection="first", child=end) + assert below[1] == pytest.approx(front.bbox[1] - 20.0 - height) + + +def test_third_angle_flips_both_placements(): + front = build_view(shaft(), "front") + end = build_view(shaft(), "side") + right = layout_origin(front, "side", side="right", projection="third", child=end) + assert right[0] == pytest.approx(front.bbox[2] + 20.0) + above = layout_origin(front, "top", projection="third", child=end) + assert above[1] == pytest.approx(front.bbox[3] + 20.0) + + +def test_without_a_child_the_layout_assumes_the_parent_size(): + front = build_view(shaft(), "front") + width = front.bbox[2] - front.bbox[0] + left = layout_origin(front, "side", side="right", projection="first") + assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width) + + +def test_an_unknown_projection_is_refused(): + front = build_view(shaft(), "front") + with pytest.raises(ValueError, match="first"): + layout_origin(front, "side", projection="second") From b2f858212f53e67878e8c0c92825932c7877b220 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 20:38:57 +0300 Subject: [PATCH 05/11] fix(task-4): the end-view occlusion rule, the double-drawn groove, the left view Three defects in the orthographic view engine, all one missing idea: the engine had no visibility rule and no single owner for the silhouette. 1. `_end_circles` inverted the hidden-line rule. Walking the bands from the viewing end it drew a circle only when its radius EXCEEDED everything nearer, and roled every such circle `hidden` unless it was the first band - so the one edge that CANNOT be occluded (a step face larger than the material in front of it) shipped dashed on HIDDEN, and the circle that really is covered was not drawn at all. `_front_max` is now the rule: an edge of radius r at station x is continuous when r is larger than every outer radius between x and the observer, dashed when it is not, and it is always drawn. Bores follow the mirror image - you see down a bore only while it stays at least as wide. Each distinct radius is emitted once, because two coincident circles are a duplicate entity. The same rule now roles a feature's axis-centred end-view circle, whose axial station comes from `removal_box`; a feature that reports none keeps its own verdict rather than having one guessed. Ground truth by ray cast along the axis of the same solid of revolution (segments 20x25, 40x30, 15x20; observer at +X, probe just inside each edge): r=10 at x=75 -> first surface x=75 -> VISIBLE (was visible, kept) r=15 at x=60 -> first surface x=60 -> VISIBLE (was HIDDEN, fixed) r=12.5 at x=20 -> first surface x=60 -> HIDDEN (was not drawn, fixed) 2. Every axisymmetric groove was drawn twice - once as the notch `radial_profile` cuts into the silhouette, once by the feature's own `prims()`, which `_feature_layer` appended unconditionally. Six exactly coincident LINEs per grooved shaft, which is precisely what `critique.py::_check_duplicate_entities` matches, so premium rule 7 could never be met on a part carrying a retaining or O-ring groove. `_profile_owned` now drops a feature primitive that the body already emitted, and drops a revolved front view's `visible` silhouette line from any feature that edits the radial profile: the spec's rule is that grooves cut a notch in the silhouette, and the notch belongs to the one calculation. Suppression is "the profile already drew this", not "this feature edits the profile", so a blind bore keeps the bottom line the profile cannot express, and an internal thread keeps its ISO 6410 limit line. A groove straddling a step was worse than duplicated: the feature reads one surface radius for a span that has two, so it drew a groove floor through solid material and left the +Y path with four odd-degree nodes. It now has two - its two ends on the axis. 3. `build_view` validated `side` and put it in ctx, but the prismatic branch ignored it: `side="left"` returned the right-hand view, with every feature on the wrong side of the sheet and a symmetric outline rectangle to hide it. An observer at -X sees screen-right = -Y, so the feature layer is now mirrored about the outline's mid-Y. The top view is always seen from above and does not move with `side`; that is now said in the docstring and pinned by a test. Tests: the two that pinned the inverted rule are rewritten around the ray cast, and one of them contradicted its own name. Seven tests added, and the prismatic side-view test's hole moved off the y=25 mirror axis, where it could not tell a left view from a right one. Nine of them fail against the previous `views.py` and pass against this one. Live AutoCAD was unavailable for a SOLPROF cross-check: the running AutoCAD 2026 (pid 40068) reports Responding=False and `GetActiveObject("AutoCAD.Application")` never returned inside 100 s, so no scratch document was created and none is left open. No new ActiveX member is called by this change - views.py is pure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/views.py | 238 +++++++++++++++++++++++++++++++++++--- tests/test_mech_views.py | 178 ++++++++++++++++++++++++++-- 2 files changed, 390 insertions(+), 26 deletions(-) diff --git a/engineering/mech/views.py b/engineering/mech/views.py index 7a3133d..8a7bb59 100644 --- a/engineering/mech/views.py +++ b/engineering/mech/views.py @@ -33,10 +33,24 @@ from engineering.mech.part import ( part_max_diameter, segment_bounds, ) -from engineering.mech.primitives import Circle, DimIntent, Line, Prim, Pt, bbox +from engineering.mech.primitives import ( + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Prim, + Pt, + Text, + bbox, +) _EPS = 1e-9 _SNAP = 6 # decimals used to match a corner vertex to a corner edit +#: Radii closer together than this are the same circle, in millimetres. Looser +#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices. +_TOL = 1e-6 VIEW_KINDS: tuple[str, ...] = ("front", "side", "top") PROJECTIONS: tuple[str, ...] = ("first", "third") @@ -279,24 +293,81 @@ def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]: return (Line((-over, 0.0), (length + over, 0.0), "center"),) +def _band_order(bands: tuple[Band, ...], side: str) -> list[Band]: + """The bands from the end the observer stands at, nearest first.""" + return list(reversed(bands)) if side == "right" else list(bands) + + +def _band_radii_near_first(band: Band, side: str) -> tuple[float, float]: + """A band's two outer radii, the one nearer the observer first. + + They differ only on a taper: ``r_out0`` sits at ``x0`` and ``r_out1`` at + ``x1``, so which of them faces the observer depends on the end they are + looking from. + """ + return (band.r_out1, band.r_out0) if side == "right" else (band.r_out0, band.r_out1) + + +def _front_max(bands: tuple[Band, ...], side: str, x: float) -> float: + """The largest outer radius of the material lying **between** ``x`` and the observer. + + This is the whole hidden-line rule for a view along the axis of a solid of + revolution: a circular edge of radius ``r`` at station ``x`` is drawn + continuous when ``r`` is larger than everything in front of it, because then + nothing can cover it, and dashed when it is not, because the material in + front does cover it. A step face whose circle is *larger* than the material + ahead of it is the one edge that cannot be occluded. + """ + best = 0.0 + for band in bands: + nearer = band.x0 >= x - _TOL if side == "right" else band.x1 <= x + _TOL + if nearer: + best = max(best, band.r_out0, band.r_out1) + return best + + def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]: + """The concentric circles the profile crosses at one end, roled by occlusion. + + Outer edges: walking from the observer, a radius larger than everything + already passed is ``visible``; a smaller one is behind that material and is + ``hidden`` - it is still drawn, dashed, because it is a real edge of the + part. Bore edges follow the mirror-image rule: you can see down a bore only + while it stays at least as wide, so a bore radius no larger than the + narrowest bore in front of it is ``visible`` and anything wider (or anything + behind solid material) is ``hidden``. + + Each distinct radius is emitted once: two coincident circles would be a + duplicate entity, which is what ``drawing_critique`` reports. + """ bands = radial_profile(part) - order = list(reversed(bands)) if side == "right" else list(bands) + order = _band_order(bands, side) prims: list[Prim] = [] - seen_out = 0.0 - for index, band in enumerate(order): - radius = max(band.r_out0, band.r_out1) - if radius > seen_out + 1e-6: - prims.append(Circle((0.0, 0.0), radius, "visible" if index == 0 else "hidden")) - seen_out = radius - seen_in = 0.0 - for index, band in enumerate(order): - if band.r_in > seen_in + 1e-6: - prims.append(Circle((0.0, 0.0), band.r_in, "visible" if index == 0 else "hidden")) - seen_in = band.r_in + emitted: set[float] = set() - over = seen_out * (1.0 + AXIS_OVERRUN) + def emit(radius: float, role: str) -> None: + key = round(float(radius), _SNAP) + if key <= _TOL or key in emitted: + return + emitted.add(key) + prims.append(Circle((0.0, 0.0), float(radius), role)) + + front_max = 0.0 + for band in order: + for radius in _band_radii_near_first(band, side): + emit(radius, "visible" if radius > front_max + _TOL else "hidden") + front_max = max(front_max, radius) + + open_r = math.inf + for band in order: + if band.r_in <= _TOL: + open_r = 0.0 # solid across the whole section: nothing behind is seen + continue + emit(band.r_in, "visible" if band.r_in <= open_r + _TOL else "hidden") + open_r = min(open_r, band.r_in) + + over = front_max * (1.0 + AXIS_OVERRUN) prims.append(Line((-over, 0.0), (over, 0.0), "center")) prims.append(Line((0.0, -over), (0.0, over), "center")) return tuple(prims) @@ -313,15 +384,131 @@ def _rectangle( ) -def _feature_layer(part, kind: str, ctx: dict) -> tuple[list[Prim], list[DimIntent], list[dict]]: +def _mirror_u(prims, axis: float) -> tuple[Prim, ...]: + """Reflect primitives about the vertical line ``u = axis`` (screen left <-> right). + + ``Text`` moves with the drawing but keeps its rotation: a mirrored label + would read backwards, and no drawing has ever wanted that. + """ + + def flip(point: Pt) -> Pt: + return (2.0 * axis - float(point[0]), float(point[1])) + + out: list[Prim] = [] + for prim in prims: + if isinstance(prim, Line): + out.append(Line(flip(prim.p1), flip(prim.p2), prim.role)) + elif isinstance(prim, Arc): + # reflection reverses the sweep, so the CCW arc runs from the + # reflected end angle to the reflected start angle + out.append( + Arc( + flip(prim.center), + prim.radius, + (180.0 - prim.end_deg) % 360.0, + (180.0 - prim.start_deg) % 360.0, + prim.role, + ) + ) + elif isinstance(prim, Circle): + out.append(Circle(flip(prim.center), prim.radius, prim.role)) + elif isinstance(prim, Poly): + out.append(Poly(tuple(flip(pt) for pt in prim.points), prim.closed, prim.role)) + elif isinstance(prim, HatchArea): + out.append( + HatchArea( + tuple(tuple(flip(pt) for pt in loop) for loop in prim.loops), prim.material + ) + ) + elif isinstance(prim, Text): + out.append(prim._replace(at=flip(prim.at))) + else: # pragma: no cover - Prim is a closed union + raise TypeError(f"_mirror_u: not a primitive: {prim!r}") + return tuple(out) + + +def _same_geometry(a: Prim, b: Prim) -> bool: + """True when two primitives would be drawn on top of each other, role aside. + + Exactly what ``engineering/critique.py::_check_duplicate_entities`` matches, + so anything this returns True for is a warning on the finished drawing. + """ + if isinstance(a, Line) and isinstance(b, Line): + forward = math.dist(a.p1, b.p1) <= _TOL and math.dist(a.p2, b.p2) <= _TOL + reverse = math.dist(a.p1, b.p2) <= _TOL and math.dist(a.p2, b.p1) <= _TOL + return forward or reverse + if isinstance(a, Circle) and isinstance(b, Circle): + return math.dist(a.center, b.center) <= _TOL and abs(a.radius - b.radius) <= _TOL + if isinstance(a, Arc) and isinstance(b, Arc): + return ( + math.dist(a.center, b.center) <= _TOL + and abs(a.radius - b.radius) <= _TOL + and abs((a.start_deg - b.start_deg) % 360.0) <= _TOL + and abs((a.end_deg - b.end_deg) % 360.0) <= _TOL + ) + return False + + +def _profile_owned(feature, part, prim: Prim, kind: str, body: list[Prim]) -> bool: + """True when the radial profile has already drawn this primitive. + + Two separate reasons, and both are the same principle - the profile is the + one calculation the silhouette comes out of, so a feature never draws it a + second time: + + * the primitive is coincident with something the body already emitted; or + * the feature edits the radial profile and this is a **silhouette** line of + a revolved front view. ``radial_profile`` already cut the notch, and the + feature's own idea of where the surface is can even contradict it - a + groove straddling a step reads one surface radius for a span that has two. + """ + if any(_same_geometry(prim, other) for other in body): + return True + if kind == "front" and getattr(prim, "role", None) == "visible": + return bool(feature.radial_edit(part)) + return False + + +def _end_view_role(feature, part, prim: Prim, bands: tuple[Band, ...], side: str) -> Prim: + """Re-role a feature's axis-centred end-view circle by the same occlusion rule. + + The feature knows its diameter, not what is standing in front of it. Its + axial station comes from ``removal_box``; a feature that does not report one + keeps its own verdict rather than having one guessed for it. + """ + if not isinstance(prim, Circle) or math.dist(prim.center, (0.0, 0.0)) > _TOL: + return prim + box = feature.removal_box(part) + if box is None: + return prim + x_near = float(box[2]) if side == "right" else float(box[0]) + covered = _front_max(bands, side, x_near) + return prim._replace(role="visible" if prim.radius > covered + _TOL else "hidden") + + +def _feature_layer( + part, + kind: str, + ctx: dict, + *, + body: list[Prim] | None = None, + bands: tuple[Band, ...] | None = None, +) -> tuple[list[Prim], list[DimIntent], list[dict]]: + drawn: list[Prim] = list(body or ()) prims: list[Prim] = [] dims: list[DimIntent] = [] omitted: list[dict] = [] + end_view = bands is not None and kind == "side" + side = str(ctx.get("side", "right")).strip().lower() for feature in part.features: - own = feature.prims(part, kind, ctx) + own = tuple(feature.prims(part, kind, ctx)) + if end_view: + own = tuple(_end_view_role(feature, part, prim, bands, side) for prim in own) + own = tuple(prim for prim in own if not _profile_owned(feature, part, prim, kind, drawn)) if own and kind == "front" and getattr(feature, "axisymmetric", False): own = tuple(own) + mirror_about_x(own) prims.extend(own) + drawn.extend(own) dims.extend(feature.dims(part, kind, ctx)) if own: continue @@ -344,7 +531,12 @@ def build_view( projection: str = "first", detail: dict | None = None, ) -> View: - """Project a part onto one view plane and return its primitive description.""" + """Project a part onto one view plane and return its primitive description. + + ``side`` is the end the observer stands at for an end view, and it decides + both what is in front of what and which way round the view reads. The + ``top`` view is always seen from above, so ``side`` does not apply to it. + """ name = str(kind or "").strip().lower() if name not in VIEW_KINDS: raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.") @@ -356,6 +548,8 @@ def build_view( ctx = {"side": where, "scale": float(scale), "projection": str(projection)} prims: list[Prim] = [] omitted: list[dict] = [] + bands: tuple[Band, ...] | None = None + mirror_u: float | None = None if isinstance(part, RevolvedPart): if name == "top": @@ -369,6 +563,7 @@ def build_view( prims.extend(_axis_prims(part)) omitted.extend(unplaced) else: + bands = radial_profile(part) prims.extend(_end_circles(part, where)) elif isinstance(part, PrismaticPart): x0, y0, x1, y1 = outline_bbox(part) @@ -378,12 +573,19 @@ def build_view( omitted.extend(unplaced) elif name == "side": prims.extend(_rectangle(y0, y1, 0.0, part.thickness)) + # An observer standing at -X sees screen-right = -Y, so the left + # view is the right view mirrored. The thickness rectangle spans + # y0..y1 either way and is its own mirror; the features are not. + if where == "left": + mirror_u = (y0 + y1) / 2.0 else: prims.extend(_rectangle(x0, x1, 0.0, part.thickness)) else: # pragma: no cover - build_part only makes these two raise ValueError(f"build_view: {type(part).__name__} is not a part model.") - feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx) + feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx, body=prims, bands=bands) + if mirror_u is not None: + feature_prims = list(_mirror_u(feature_prims, mirror_u)) prims.extend(feature_prims) omitted.extend(feature_omitted) diff --git a/tests/test_mech_views.py b/tests/test_mech_views.py index 0951218..f12f49f 100644 --- a/tests/test_mech_views.py +++ b/tests/test_mech_views.py @@ -7,6 +7,8 @@ to 1e-9. The radial profile is the single calculation both the silhouette and from __future__ import annotations +from collections import Counter + import pytest from engineering.mech.features import ( @@ -17,6 +19,7 @@ from engineering.mech.features import ( Fillet, Hole, Keyway, + ORingGroove, RetainingGroove, ) from engineering.mech.part import PrismaticPart, RevolvedPart, Segment @@ -55,6 +58,17 @@ def roles(view: View, role: str): return [p for p in view.prims if getattr(p, "role", None) == role] +def coincident(view: View) -> dict: + """Geometries drawn more than once - exactly what ``_check_duplicate_entities`` flags.""" + counts: Counter = Counter() + for prim in view.prims: + if isinstance(prim, Line): + counts[tuple(sorted((prim.p1, prim.p2)))] += 1 + elif isinstance(prim, Circle): + counts[(prim.center, round(prim.radius, 9))] += 1 + return {key: n for key, n in counts.items() if n > 1} + + def test_the_engine_declares_three_orthographic_kinds_and_two_projections(): assert VIEW_KINDS == ("front", "side", "top") assert PROJECTIONS == ("first", "third") @@ -162,25 +176,141 @@ def test_an_axisymmetric_feature_is_mirrored_and_a_keyway_is_not(): assert keyway_lines and all(line.p1[1] > 0 for line in keyway_lines) +@pytest.mark.parametrize( + "groove", + [ + RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6), + ORingGroove(id="or", x=40.0, cord=3.0, b=4.1, h=2.3), + ], + ids=["retaining", "oring"], +) +def test_a_groove_is_the_notch_in_the_silhouette_and_is_not_drawn_a_second_time(groove): + """The spec's rule is `grooves cut a notch in the silhouette`. The notch is + the radial profile's; the feature does not redraw it. Two coincident LINEs + are what ``drawing_critique(focus=None)`` reports as a duplicate, and + premium rule 7 needs that report empty before ``drawing_finalize``.""" + view = build_view(shaft(groove), "front") + assert coincident(view) == {} + # the notch is still there, mirrored, once per side + floor = {round(abs(line.p1[1]), 6) for line in view.prims if isinstance(line, Line)} + assert (14.3 in floor) or (12.7 in floor) + assert view.omitted == () + + +def test_an_axial_bore_end_circle_is_drawn_once(): + """``_end_circles`` reads the bore off the same radial profile the feature + edited, so the feature's own circle would be the second copy of it.""" + view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "side") + assert coincident(view) == {} + assert len([c for c in view.prims if isinstance(c, Circle) and abs(c.radius - 4.0) < EPS]) == 1 + + +def test_a_blind_bore_keeps_the_bottom_line_the_profile_cannot_draw(): + """Suppression is `the profile already drew this`, not `this feature edits + the profile` - the blind bottom is not on the radial profile at all.""" + view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "front") + bottoms = [ + line + for line in roles(view, "hidden") + if isinstance(line, Line) and abs(line.p1[0] - 30.0) < EPS and abs(line.p2[0] - 30.0) < EPS + ] + assert len(bottoms) == 1 + assert {round(bottoms[0].p1[1], 6), round(bottoms[0].p2[1], 6)} == {-4.0, 4.0} + + +def test_a_groove_straddling_a_step_leaves_one_closed_silhouette(): + """The feature's own prims read a single surface radius for a span that has + two, so drawing them as well as the profile's notch put a groove floor + through solid material and left the +Y path with four odd-degree nodes. + An open path has exactly two: its two ends on the axis.""" + part = shaft(RetainingGroove(id="rg", x=20.0, d=30.0, m=1.6, d2=28.6)) + upper = [ + line + for line in roles(build_view(part, "front"), "visible") + if isinstance(line, Line) and line.p1[1] >= 0.0 and line.p2[1] >= 0.0 + ] + degree: Counter = Counter() + for line in upper: + degree[(round(line.p1[0], 6), round(line.p1[1], 6))] += 1 + degree[(round(line.p2[0], 6), round(line.p2[1], 6))] += 1 + assert sorted(node for node, n in degree.items() if n % 2) == [(0.0, 0.0), (75.0, 0.0)] + # nothing is drawn at the groove floor radius outside the band the profile cut + assert not [ + line + for line in upper + if abs(line.p1[1] - 14.3) < EPS and abs(line.p2[1] - 14.3) < EPS and line.p1[0] < 20.0 + ] + + # -- the revolved end view --------------------------------------------------- -def test_the_end_view_marks_a_diameter_behind_a_larger_one_as_hidden(): +def test_the_end_view_hides_the_smaller_diameter_and_draws_the_step_face_continuous(): + """Ground truth by ray cast along the axis of the same solid of revolution + (viewer at +X, probe just inside each edge): + + edge r=10 at x=75 -> first surface at x=75 -> VISIBLE + edge r=15 at x=60 -> first surface at x=60 -> VISIBLE + edge r=12.5 at x=20 -> first surface at x=60 -> HIDDEN + + The annular step face at x=60 faces the observer and nothing at projected + radius 15-eps stands in front of it, so the 30 mm circle is continuous. The + 25 mm circle is the one that is genuinely covered, and it is still *drawn* - + dashed, not dropped. + """ view = build_view(shaft(), "side", side="right") circles = [p for p in view.prims if isinstance(p, Circle)] radii = {(round(c.radius, 6), c.role) for c in circles} - assert (10.0, "visible") in radii # the near end - assert (15.0, "hidden") in radii # the middle step, behind it - assert (12.5, "hidden") not in radii # smaller than 15, never seen + assert radii == {(10.0, "visible"), (15.0, "visible"), (12.5, "hidden")} assert len([p for p in view.prims if getattr(p, "role", None) == "center"]) == 2 def test_looking_from_the_left_reverses_which_circle_is_visible(): + """From -X the near end is the 25 mm face; the 20 mm end is behind the 30 mm body.""" view = build_view(shaft(), "side", side="left") circles = [p for p in view.prims if isinstance(p, Circle)] radii = {(round(c.radius, 6), c.role) for c in circles} - assert (12.5, "visible") in radii - assert (15.0, "hidden") in radii + assert radii == {(12.5, "visible"), (15.0, "visible"), (10.0, "hidden")} + + +def test_a_feature_circle_at_the_far_end_is_hidden_behind_the_larger_body(): + """A chamfer at x=0 seen from +X sits behind the 30 mm band, so its circle + is dashed; seen from -X nothing is in front of it and it is continuous.""" + part = shaft(Chamfer(id="c1", at="start", size=2.0)) + right = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="right").prims + if isinstance(c, Circle) + } + left = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="left").prims + if isinstance(c, Circle) + } + assert (10.5, "hidden") in right + assert (10.5, "visible") in left + + +def test_a_bore_that_widens_behind_a_narrower_one_is_hidden(): + """Looking into the narrow end you see the small bore; the wider bore behind + it is covered by the wall of the narrow one. From the other end, both are + seen down the widening hole.""" + part = RevolvedPart( + name="stepped-bore", + segments=(Segment(20.0, 40.0, 10.0), Segment(20.0, 40.0, 20.0)), + ) + right = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="right").prims + if isinstance(c, Circle) + } + left = { + (round(c.radius, 6), c.role) + for c in build_view(part, "side", side="left").prims + if isinstance(c, Circle) + } + assert (10.0, "visible") in right and (5.0, "visible") in right + assert (5.0, "visible") in left and (10.0, "hidden") in left def test_a_revolved_top_view_is_refused_because_it_repeats_the_front(): @@ -211,14 +341,46 @@ def test_a_corner_fillet_replaces_an_outline_vertex_with_an_arc(): def test_a_prismatic_side_view_is_the_thickness_rectangle_with_a_hidden_line_per_hole(): - hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0) + # y=10, deliberately OFF the y=25 mirror axis of the 0..50 plate: a hole on + # the axis cannot tell a right-hand view from a left-hand one. + hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0) view = build_view(plate(hole), "side") visible = roles(view, "visible") hidden = roles(view, "hidden") assert len(visible) == 4 assert view.bbox == (0.0, 0.0, 50.0, 8.0) assert len(hidden) == 2 - assert {round(line.p1[0], 6) for line in hidden} == {20.0, 30.0} + assert {round(line.p1[0], 6) for line in hidden} == {5.0, 15.0} + + +def test_a_prismatic_left_side_view_is_the_right_view_mirrored(): + """An observer at -X sees screen-right = -Y, so the hole at y=10 in a plate + spanning y 0..50 lands at u = 50-15 .. 50-5. Accepting ``side='left'`` and + returning the right-hand view puts every feature on the wrong side of the + sheet with nothing in the drawing to reveal it.""" + hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0) + part = plate(hole) + right = { + round(line.p1[0], 6) for line in roles(build_view(part, "side", side="right"), "hidden") + } + left = {round(line.p1[0], 6) for line in roles(build_view(part, "side", side="left"), "hidden")} + assert right == {5.0, 15.0} + assert left == {35.0, 45.0} + # the thickness rectangle is its own mirror, so only the features move + assert build_view(part, "side", side="left").bbox == (0.0, 0.0, 50.0, 8.0) + + +def test_a_prismatic_top_view_is_always_seen_from_above(): + """``side`` names the end an *end* view is taken from; the top view is not + one, so it must not move with it.""" + part = plate(Hole(id="h1", x=20.0, y=10.0, diameter=10.0)) + from_right = { + round(line.p1[0], 6) for line in roles(build_view(part, "top", side="right"), "hidden") + } + from_left = { + round(line.p1[0], 6) for line in roles(build_view(part, "top", side="left"), "hidden") + } + assert from_right == from_left == {15.0, 25.0} def test_a_prismatic_top_view_projects_the_other_axis(): From 72bd17d02414b756f4cc2606af526d87027b3186 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 21:40:29 +0300 Subject: [PATCH 06/11] feat(task-5): sections, cut faces, material hatch and detail views engineering/mech/views.py grows the two remaining view kinds. A section's cut faces come out of the same radial profile the silhouette does, so the hatched area and the drawn outline are one calculation and cannot disagree: a solid shaft's half-section loop measures exactly the sum of its segment rectangles, and a bore closes the loop on itself rather than being hatched over. ISO 6410's section convention falls out of the model rather than being special- cased: an internal thread's radial_edit already raises the bore to the major diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the feature - a pocket marked no_section_hatch is drawn and left unhatched, while the rest of the cut face is hatched with the part's ISO 128-50 material. Four styles, each either implemented or refused by name with the alternative rather than approximated: full and half on a longitudinal plane through a revolved axis; full and revolved on a transverse plane (the annulus, flattened at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than hidden); full and offset on a prismatic part, where through features split the cut face and pockets reduce it to the remaining thickness. A plane that misses the part, or that grazes an outline vertex so its crossings cannot be paired, is refused instead of producing a guessed cut face. Details scale a circular region of a parent view, emit the ISO 128-34 circle and letter for the parent and the "A (5:1)" label for the detail itself, and report every feature that falls outside the circle in omitted - the honesty rule again. Three test assertions were corrected to the measured truth rather than the engine bent to them, each verified by running the code: * the ISO 6410 test asked for the whole cut-face loop's minimum to be the M20 major (r=10). It is not, and must not be: over the 5 mm that is drilled but not threaded the loop closes on the real 16 mm drill (r=8). The loop is ((0,10),(0,25),(25,25),(30,25),(30,8),(30,8),(25,8),(25,10),(0,10)). The test now pins both spans, which is the convention it meant to assert. * the transverse-annulus areas were pinned at rel=2e-4. An inscribed regular 180-gon is exactly 1 - (n/2pi) sin(2pi/n) = 2.0307e-4 under the circle, so 2e-4 rejects the very flattening CIRCLE_SEGMENTS declares. The tolerance is now 2.1e-4 and the test computes the deficit and pins it, so the number is derived rather than guessed. CIRCLE_SEGMENTS' docstring now states both figures: 0.015% radial (the 2 degree chord's sagitta) and 0.0203% of area. * tests/test_mech_views.py pinned VIEW_KINDS == ("front","side","top"), which this task deliberately grows. It now pins the three orthographic kinds as the first three and the full five-kind tuple. _is_longitudinal takes only the plane; the plan's signature also took the part and never read it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 16 ++ engineering/mech/views.py | 443 ++++++++++++++++++++++++++++++++++- tests/test_mech_sections.py | 316 +++++++++++++++++++++++++ tests/test_mech_views.py | 5 +- 4 files changed, 777 insertions(+), 3 deletions(-) create mode 100644 tests/test_mech_sections.py diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index 9f6e4e1..b318f68 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -68,14 +68,22 @@ from .primitives import ( translate, ) from .views import ( + CIRCLE_SEGMENTS, PROJECTIONS, + SECTION_PLANE, + SECTION_STYLES, VIEW_KINDS, Band, View, build_view, + cut_loops, + detail_marker_prims, + detail_view, layout_origin, + normalise_plane, outline_prims, radial_profile, + section_view, silhouette_prims, ) @@ -84,6 +92,7 @@ __all__ = [ "AxialBore", "Band", "BendLine", + "CIRCLE_SEGMENTS", "CentreHole", "Chamfer", "Circle", @@ -114,6 +123,8 @@ __all__ = [ "RetainingGroove", "RevolvedPart", "Role", + "SECTION_PLANE", + "SECTION_STYLES", "Segment", "Slot", "Text", @@ -124,11 +135,15 @@ __all__ = [ "bbox", "build_part", "build_view", + "cut_loops", + "detail_marker_prims", + "detail_view", "feature_from_dict", "inner_radius_at", "layer_for", "layout_origin", "mirror_about_x", + "normalise_plane", "outer_radius_at", "outline_bbox", "outline_prims", @@ -140,6 +155,7 @@ __all__ = [ "radial_profile", "rotate", "scale", + "section_view", "segment_bounds", "silhouette_prims", "translate", diff --git a/engineering/mech/views.py b/engineering/mech/views.py index 8a7bb59..313e469 100644 --- a/engineering/mech/views.py +++ b/engineering/mech/views.py @@ -24,10 +24,13 @@ import math from dataclasses import dataclass from typing import Any, NamedTuple +from engineering.mech.features import Pocket as _PocketType from engineering.mech.features import mirror_about_x from engineering.mech.part import ( PrismaticPart, RevolvedPart, + inner_radius_at, + outer_radius_at, outline_bbox, part_length, part_max_diameter, @@ -44,7 +47,9 @@ from engineering.mech.primitives import ( Pt, Text, bbox, + translate, ) +from engineering.mech.primitives import scale as scale_prims _EPS = 1e-9 _SNAP = 6 # decimals used to match a corner vertex to a corner edit @@ -52,7 +57,7 @@ _SNAP = 6 # decimals used to match a corner vertex to a corner edit #: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices. _TOL = 1e-6 -VIEW_KINDS: tuple[str, ...] = ("front", "side", "top") +VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail") PROJECTIONS: tuple[str, ...] = ("first", "third") SIDES: tuple[str, ...] = ("right", "left") @@ -60,6 +65,26 @@ SIDES: tuple[str, ...] = ("right", "left") #: of the part's largest dimension (ISO 128-23 asks for a short, even overrun). AXIS_OVERRUN = 0.10 +#: The cutting-plane record `section_line` (Task 11, group A) produces and this +#: engine consumes. `via` is optional and only meaningful for an offset plane. +SECTION_PLANE = { + "p1": (0.0, 0.0), + "p2": (0.0, 0.0), + "label": "A", + "direction": (0.0, -1.0), + "style": "full", +} + +SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved") + +#: A circular cut-face boundary has to be handed to the hatch as points. 180 +#: segments is a 2 degree chord: the boundary is never further than +#: r * (1 - cos(1 deg)) = 0.015% of the radius from the true circle, and the +#: area of the inscribed 180-gon is 0.0203% under the circle's. Both figures are +#: stated rather than hidden, and the same loop is what the hatch and any area +#: check read, so they cannot disagree with each other. +CIRCLE_SEGMENTS = 180 + @dataclass(frozen=True) class View: @@ -543,7 +568,10 @@ def build_view( where = str(side or "right").strip().lower() if where not in SIDES: raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.") - del plane, style, detail # the orthographic kinds take no plane or detail + if name == "section": + return section_view(part, plane, style=style, scale=scale, side=where) + if name == "detail": + return detail_view(part, detail or {}, projection=projection, side=where) ctx = {"side": where, "scale": float(scale), "projection": str(projection)} prims: list[Prim] = [] @@ -648,3 +676,414 @@ def layout_origin( f"layout_origin: kind {kind!r} has no projection position; expected " "'side', 'top', 'section' or 'detail'." ) + + +# -- sections ---------------------------------------------------------------- + + +def normalise_plane(plane: dict | None) -> dict: + """Fill the `SECTION_PLANE` shape and refuse a plane that has no direction.""" + if plane is None: + raise ValueError( + "section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, " + "or take one from section_line()." + ) + if not isinstance(plane, dict): + raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.") + unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"}) + if unknown: + raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.") + p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"])) + p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"])) + if math.dist(p1, p2) < 1e-6: + raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).") + style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower() + if style not in SECTION_STYLES: + raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.") + via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ())) + return { + "p1": p1, + "p2": p2, + "label": str(plane.get("label", SECTION_PLANE["label"])), + "direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])), + "style": style, + "via": via, + } + + +def _inner_path(bands: tuple[Band, ...]) -> list[Pt]: + points: list[Pt] = [(bands[0].x0, bands[0].r_in)] + for band in bands: + if math.dist(points[-1], (band.x0, band.r_in)) > _EPS: + points.append((band.x0, band.r_in)) + points.append((band.x1, band.r_in)) + return points + + +def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]: + steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS) + return tuple( + ( + radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS), + radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS), + ) + for k in steps + ) + + +def _is_longitudinal(plane: dict) -> bool: + """True when the cutting plane runs along the revolved axis (y = 0 at both ends).""" + return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6 + + +def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]): + out: list[tuple[float, float]] = [] + low, high = cut + for a, b in intervals: + if high <= a + _EPS or low >= b - _EPS: + out.append((a, b)) + continue + if low > a + _EPS: + out.append((a, low)) + if high < b - _EPS: + out.append((high, b)) + return [(a, b) for a, b in out if b - a > 1e-9] + + +def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]: + """Parameters along `unit` from `p1` where the line is inside the outline. + + Crossings are paired in order, which is exact for a simple polygon whose + edges the line crosses transversally. A line that grazes a vertex produces + an odd crossing count and is refused by the caller rather than paired wrong. + """ + ts: list[float] = [] + count = len(outline) + for index in range(count): + ax, ay = outline[index] + bx, by = outline[(index + 1) % count] + ex, ey = bx - ax, by - ay + denominator = ex * unit[1] - ey * unit[0] + if abs(denominator) < 1e-12: + continue + s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator + if -1e-9 <= s <= 1.0 + 1e-9: + px, py = ax + s * ex, ay + s * ey + ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1]) + ts = sorted({round(t, 9) for t in ts}) + if len(ts) % 2 != 0: + raise ValueError( + "section: the cutting plane grazes an outline vertex, so its crossings cannot be " + "paired. Move the plane off the vertex rather than accept a guessed cut face." + ) + return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)] + + +def _box_span(box, p1: Pt, unit: Pt, normal: Pt): + xs = (box[0], box[2]) + ys = (box[1], box[3]) + corners = [(x, y) for x in xs for y in ys] + distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners] + if min(distances) > 1e-9 or max(distances) < -1e-9: + return None + spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners] + return (min(spans), max(spans)) + + +def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]: + """The cutting legs of an offset plane. + + ISO 128-40: the offset itself is not drawn, so with ``via`` present the + odd-numbered legs are the transfer jogs and contribute nothing to the cut + face. + """ + points = [plane["p1"], *plane["via"], plane["p2"]] + legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)] + if plane["via"]: + legs = [leg for index, leg in enumerate(legs) if index % 2 == 0] + return legs + + +def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): + """``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane.""" + legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])] + thickness = float(part.thickness) + rectangles: list[tuple[float, float, float, float, bool]] = [] + origin = 0.0 + for start, end in legs: + length = math.dist(start, end) + if length < 1e-9: + continue + unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length) + normal = (-unit[1], unit[0]) + intervals = _chords(part.outline, start, unit) + intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9] + intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals] + intervals = [(a, b) for a, b in intervals if b - a > 1e-9] + + pockets: list[tuple[tuple[float, float], float, bool]] = [] + for feature in part.features: + box = feature.removal_box(part) + if box is None: + continue + span = _box_span(box, start, unit, normal) + if span is None: + continue + span = (max(span[0], 0.0), min(span[1], length)) + if span[1] - span[0] <= 1e-9: + continue + if isinstance(feature, _PocketType): + pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch)) + else: + intervals = _subtract(intervals, span) + + for pocket_span, floor, unhatched in pockets: + intervals = _subtract(intervals, pocket_span) + rectangles.append( + (origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched) + ) + for a, b in intervals: + rectangles.append((origin + a, origin + b, 0.0, thickness, True)) + origin += length + if rectangles: + # the section view has its own frame: u = 0 at the first cut. + shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles) + rectangles = [ + (u0 - shift, u1 - shift, v0, v1, hatched) for u0, u1, v0, v1, hatched in rectangles + ] + return rectangles + + +def cut_loops(part, plane: dict, *, style: str = "full"): + """The closed cut-face loops, each flagged with whether it is hatched.""" + plane = normalise_plane(plane) + name = str(style or "full").strip().lower() + if name not in SECTION_STYLES: + raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.") + + if isinstance(part, RevolvedPart): + bands = radial_profile(part) + if _is_longitudinal(plane): + if name == "offset": + raise ValueError( + "section: an offset plane has no meaning along a revolved part's axis; " + "use style='full' (or a transverse plane for a cross section)." + ) + upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands))) + loops = [(upper, True)] + if name != "half": + loops.append((tuple((x, -y) for x, y in upper), True)) + return tuple(loops) + if name == "half": + raise ValueError( + "section: a half section is defined on a longitudinal plane through the axis; " + "a transverse plane takes style='full' or 'revolved'." + ) + station = plane["p1"][0] + r_out = outer_radius_at(part, station) + r_in = inner_radius_at(part, station) + loops = [(_circle_loop(r_out), True)] + if r_in > _EPS: + loops.append((_circle_loop(r_in, reverse=True), True)) + return tuple(loops) + + if name in ("half", "revolved"): + raise ValueError( + f"section: a {name} section is defined on a revolved part; a prismatic part takes " + "style='full' or 'offset'." + ) + rectangles = _prismatic_cut(part, plane, name) + if not rectangles: + raise ValueError( + f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}." + ) + return tuple( + (((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched) + for u0, u1, v0, v1, hatched in rectangles + ) + + +def section_view( + part, plane: dict, *, style: str = "full", scale: float = 1.0, side: str = "right" +) -> View: + """A cut view: the cut faces as hatch areas, plus the geometry behind them.""" + from engineering.mech.standards.materials import hatch_for + + plane = normalise_plane(plane) + name = str(style or plane["style"]).strip().lower() + hatch_for(part.material) # refuse an unknown material before anything is built + ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane} + + loops = cut_loops(part, plane, style=name) + prims: list[Prim] = [] + omitted: list[dict] = [] + dims: list[DimIntent] = [] + + if isinstance(part, RevolvedPart) and _is_longitudinal(plane): + body, unplaced = silhouette_prims(part) + prims.extend(body) + prims.extend(_axis_prims(part)) + omitted.extend(unplaced) + for feature in part.features: + own = feature.cut_prims(part, plane, ctx) + if own and getattr(feature, "axisymmetric", False): + own = tuple(own) + mirror_about_x(own) + prims.extend(own) + dims.extend(feature.dims(part, "front", ctx)) + elif isinstance(part, RevolvedPart): + role = "phantom" if name == "revolved" else "visible" + station = plane["p1"][0] + r_out = outer_radius_at(part, station) + r_in = inner_radius_at(part, station) + prims.append(Circle((0.0, 0.0), r_out, role)) + if r_in > _EPS: + prims.append(Circle((0.0, 0.0), r_in, role)) + over = r_out * (1.0 + AXIS_OVERRUN) + prims.append(Line((-over, 0.0), (over, 0.0), "center")) + prims.append(Line((0.0, -over), (0.0, over), "center")) + else: + for points, _hatched in loops: + xs = [p[0] for p in points] + ys = [p[1] for p in points] + prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys))) + + hatched = [points for points, flag in loops if flag] + if isinstance(part, RevolvedPart) and not _is_longitudinal(plane): + # a transverse cut face is one region with the bore as its island + if hatched: + prims.append(HatchArea(tuple(hatched), part.material)) + else: + for points in hatched: + prims.append(HatchArea((points,), part.material)) + + label = f"{plane['label']}-{plane['label']}" + return View( + kind="section", + prims=tuple(prims), + dims=tuple(dims), + omitted=tuple(omitted), + bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0), + label=label, + scale=float(scale), + ) + + +# -- detail views ------------------------------------------------------------ + + +def _prim_bbox(prim) -> tuple[float, float, float, float]: + return bbox([prim]) + + +def _touches(prim, center: Pt, radius: float) -> bool: + x0, y0, x1, y1 = _prim_bbox(prim) + nearest_x = min(max(center[0], x0), x1) + nearest_y = min(max(center[1], y0), y1) + return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9 + + +def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float): + """The part of the segment inside the circle, or None.""" + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + fx, fy = p1[0] - center[0], p1[1] - center[1] + a = dx * dx + dy * dy + if a < 1e-18: + return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None + b = 2.0 * (fx * dx + fy * dy) + c = fx * fx + fy * fy - radius * radius + discriminant = b * b - 4.0 * a * c + if discriminant <= 0.0: + return None + root = math.sqrt(discriminant) + t0 = max(0.0, (-b - root) / (2.0 * a)) + t1 = min(1.0, (-b + root) / (2.0 * a)) + if t1 - t0 <= 1e-9: + return None + return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy)) + + +def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]: + """Straight geometry is trimmed to the detail circle; curved geometry is kept + whole when it touches it. + + Trimming an arc would mean solving its angular range against the circle, and + a detail that silently re-cuts an arc is worse than one that carries a + slightly long arc into the enlargement - so the rule is stated rather than + approximated, and a detail is meant to be drawn inside its circle. + """ + if isinstance(prim, Line): + clipped = _clip_segment(prim.p1, prim.p2, center, radius) + return (Line(clipped[0], clipped[1], prim.role),) if clipped else () + if isinstance(prim, Poly): + points = list(prim.points) + if prim.closed and points: + points.append(points[0]) + out: list[Prim] = [] + for index in range(len(points) - 1): + clipped = _clip_segment(points[index], points[index + 1], center, radius) + if clipped: + out.append(Line(clipped[0], clipped[1], prim.role)) + return tuple(out) + return (prim,) if _touches(prim, center, radius) else () + + +def detail_marker_prims(detail: dict) -> tuple[Prim, ...]: + """ISO 128-34 detail circle and its letter, for the **parent** view.""" + center = tuple(float(v) for v in detail["center"]) + radius = float(detail["radius"]) + label = str(detail.get("label", "A")) + return ( + Circle(center, radius, "visible"), + Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"), + ) + + +def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View: + """A circular region of a parent view, enlarged, with the ISO 128-34 labels. + + Straight geometry is trimmed to the circle and curved geometry that touches + it is kept whole (see :func:`_clip_to_circle`); every feature whose own + primitives all fall outside is reported in ``omitted``. + """ + if not isinstance(detail, dict): + raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.") + if "radius" not in detail: + raise ValueError("detail.radius: a detail needs the radius of its circle.") + center = tuple(float(v) for v in detail.get("center", (0.0, 0.0))) + radius = float(detail["radius"]) + if radius <= _EPS: + raise ValueError(f"detail.radius: must be greater than zero, got {radius}.") + factor = float(detail.get("scale", 5.0)) + if factor <= _EPS: + raise ValueError(f"detail.scale: must be greater than zero, got {factor}.") + label = str(detail.get("label", "A")) + of = str(detail.get("of", "front")).strip().lower() + + parent = build_view(part, of, side=side, projection=projection) + kept: list[Prim] = [] + for prim in parent.prims: + kept.extend(_clip_to_circle(prim, center, radius)) + kept = tuple(kept) + if not kept: + raise ValueError( + f"detail: the circle at {center} r={radius} contains no geometry of the {of} view." + ) + + moved = translate(kept, -center[0], -center[1]) + enlarged = scale_prims(moved, factor, (0.0, 0.0)) + + omitted = list(parent.omitted) + for feature in part.features: + own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection}) + if own and not any(_touches(prim, center, radius) for prim in own): + omitted.append({"feature": feature.id, "reason": f"outside the detail circle {label}"}) + + return View( + kind="detail", + prims=tuple(enlarged), + dims=(), + omitted=tuple(omitted), + bbox=bbox(enlarged), + label=f"{label} ({factor:g}:1)", + scale=factor, + ) diff --git a/tests/test_mech_sections.py b/tests/test_mech_sections.py new file mode 100644 index 0000000..9023ddd --- /dev/null +++ b/tests/test_mech_sections.py @@ -0,0 +1,316 @@ +"""Sections, cut faces, material hatch and detail views. + +The cut face and the silhouette come out of the same radial profile, so the +tests assert that the hatched loop closes on the real geometry - including the +ISO 6410 rule that an internal thread's hatch runs to the major diameter, and +the ISO 128-3 rule that a rib cut longitudinally is not hatched. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.mech.features import AxialBore, Hole, Pocket, Thread +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import Circle, HatchArea, Text +from engineering.mech.views import ( + CIRCLE_SEGMENTS, + SECTION_PLANE, + SECTION_STYLES, + VIEW_KINDS, + build_view, + cut_loops, + detail_marker_prims, + normalise_plane, + radial_profile, +) + +EPS = 1e-9 + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.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 axis_plane(**kw) -> dict: + return normalise_plane({"p1": (0.0, 0.0), "p2": (100.0, 0.0), **kw}) + + +def polygon_area(points) -> float: + total = 0.0 + count = len(points) + for i in range(count): + x1, y1 = points[i] + x2, y2 = points[(i + 1) % count] + total += x1 * y2 - x2 * y1 + return abs(total) / 2.0 + + +def test_the_engine_now_declares_five_kinds_and_four_section_styles(): + assert VIEW_KINDS == ("front", "side", "top", "section", "detail") + assert SECTION_STYLES == ("full", "half", "offset", "revolved") + assert set(SECTION_PLANE) == {"p1", "p2", "label", "direction", "style"} + + +def test_normalise_plane_fills_the_pinned_shape_and_refuses_a_zero_length_plane(): + plane = normalise_plane({"p1": (0.0, 0.0), "p2": (10.0, 0.0)}) + assert plane["label"] == "A" + assert plane["direction"] == (0.0, -1.0) + assert plane["style"] == "full" + with pytest.raises(ValueError, match="zero length"): + normalise_plane({"p1": (1.0, 1.0), "p2": (1.0, 1.0)}) + + +# -- revolved longitudinal sections ------------------------------------------ + + +def test_a_solid_shaft_full_section_hatches_both_halves_to_the_axis(): + loops = cut_loops(shaft(), axis_plane(), style="full") + assert len(loops) == 2 + assert all(hatched for _, hatched in loops) + upper, lower = (points for points, _ in loops) + assert max(y for _, y in upper) == 15.0 + assert min(y for _, y in upper) == 0.0 + assert min(y for _, y in lower) == -15.0 + # the cut face is the real cross-section: 20x12.5 + 40x15 + 15x10 per half + assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0) + + +def test_a_bore_is_not_hatched_because_the_loop_closes_on_it(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full") + assert polygon_area(upper) == pytest.approx(30.0 * (20.0 - 10.0)) + assert min(y for _, y in upper) == 10.0 + assert max(y for _, y in lower) == -10.0 + + +def test_a_half_section_hatches_one_half_only(): + loops = cut_loops(shaft(), axis_plane(style="half"), style="half") + assert len(loops) == 1 + assert min(y for _, y in loops[0][0]) == 0.0 + + +def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter(): + thread = Thread(id="th", segment=0, designation="M20", length=25.0, internal=True) + # drilled to the ISO 6410 minor (0.8 x 20 = 16 mm); the hatch must stop at + # the 20 mm major, not at the drilled 16. + part = RevolvedPart(name="boss", segments=(Segment(30.0, 50.0, 16.0),), features=(thread,)) + bands = radial_profile(part) + threaded = [b for b in bands if b.x1 <= 25.0 + EPS] + assert {round(b.r_in, 6) for b in threaded} == {10.0} + unthreaded = [b for b in bands if b.x0 >= 25.0 - EPS] + assert {round(b.r_in, 6) for b in unthreaded} == {8.0} + (upper, _), _ = cut_loops(part, axis_plane(), style="full") + # The cut face closes on the major over the threaded span - and on the real + # drilled bore over the 5 mm that is drilled but not threaded. Asserting the + # minimum over the *whole* loop would be asserting that the drill does not + # exist beyond the thread, which is a different (and wrong) drawing. + assert min(y for x, y in upper if x < 25.0 - EPS) == pytest.approx(10.0) + assert min(y for x, y in upper if x > 25.0 + EPS) == pytest.approx(8.0) + + +def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material(): + view = build_view(shaft(), "section", plane=axis_plane(), style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 2 + assert {h.material for h in hatches} == {"steel"} + assert view.label == "A-A" + + +def test_an_unknown_material_is_refused_before_a_section_is_built(): + part = RevolvedPart(name="x", segments=(Segment(10.0, 20.0),), material="unobtainium") + with pytest.raises(ValueError, match="unobtainium"): + build_view(part, "section", plane=axis_plane()) + + +def test_an_offset_plane_on_a_revolved_part_is_refused_with_the_alternative(): + with pytest.raises(ValueError) as excinfo: + build_view(shaft(), "section", plane=axis_plane(style="offset"), style="offset") + assert "style='full'" in str(excinfo.value) + + +# -- revolved transverse (cross) sections ------------------------------------ + + +def test_a_transverse_section_hatches_the_annulus_at_that_station(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0), "label": "B"}) + view = build_view(part, "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 1 + outer, island = hatches[0].loops + assert len(outer) == CIRCLE_SEGMENTS and len(island) == CIRCLE_SEGMENTS + # An inscribed regular CIRCLE_SEGMENTS-gon is exactly + # 1 - (n / 2pi) sin(2pi / n) = 2.0307e-4 under the circle, so the tolerance + # is the flattening the constant declares and nothing looser. + flattening = 1.0 - (CIRCLE_SEGMENTS / (2.0 * math.pi)) * math.sin( + 2.0 * math.pi / CIRCLE_SEGMENTS + ) + assert flattening == pytest.approx(2.0307e-4, rel=1e-3) + assert polygon_area(outer) == pytest.approx(math.pi * 20.0**2, rel=2.1e-4) + assert polygon_area(island) == pytest.approx(math.pi * 10.0**2, rel=2.1e-4) + assert view.label == "B-B" + circles = [p for p in view.prims if isinstance(p, Circle)] + assert {round(c.radius, 6) for c in circles} == {20.0, 10.0} + + +def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) + view = build_view(part, "section", plane=plane, style="revolved") + circles = [p for p in view.prims if isinstance(p, Circle)] + assert {c.role for c in circles} == {"phantom"} + + +def test_a_half_transverse_section_is_refused_with_its_reason(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) + with pytest.raises(ValueError, match="half section"): + build_view(part, "section", plane=plane, style="half") + + +# -- prismatic sections ------------------------------------------------------ + + +def test_a_prismatic_full_section_is_the_chord_times_the_thickness(): + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0), "label": "C"}) + view = build_view(plate(), "section", plane=plane, style="full") + (hatch,) = [p for p in view.prims if isinstance(p, HatchArea)] + assert polygon_area(hatch.loops[0]) == pytest.approx(80.0 * 8.0) + assert view.bbox == (0.0, 0.0, 80.0, 8.0) + + +def test_a_through_hole_splits_the_cut_face_in_two(): + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + assert len(hatches) == 2 + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx((80.0 - 10.0) * 8.0) + + +def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness(): + pocket = Pocket( + id="p1", + outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)), + depth=3.0, + ) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(pocket), "section", plane=plane, style="full") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx(40.0 * 8.0 + 40.0 * 5.0) + + +def test_iso128_3_a_rib_cut_longitudinally_is_drawn_but_not_hatched(): + rib = Pocket( + id="rib", + outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)), + depth=3.0, + no_section_hatch=True, + ) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + loops = cut_loops(plate(rib), plane, style="full") + hatched = [points for points, flag in loops if flag] + unhatched = [points for points, flag in loops if not flag] + assert len(unhatched) == 1 + assert polygon_area(unhatched[0]) == pytest.approx(40.0 * 5.0) + assert sum(polygon_area(p) for p in hatched) == pytest.approx(40.0 * 8.0) + view = build_view(plate(rib), "section", plane=plane, style="full") + assert len([p for p in view.prims if isinstance(p, HatchArea)]) == 2 + + +def test_an_offset_prismatic_section_accumulates_along_the_dog_leg(): + plane = normalise_plane( + { + "p1": (-10.0, 15.0), + "p2": (100.0, 35.0), + "via": [[30.0, 15.0], [30.0, 35.0]], + "style": "offset", + } + ) + view = build_view(plate(), "section", plane=plane, style="offset") + hatches = [p for p in view.prims if isinstance(p, HatchArea)] + total = sum(polygon_area(h.loops[0]) for h in hatches) + assert total == pytest.approx(80.0 * 8.0) + + +def test_a_revolved_style_section_of_a_prismatic_part_is_refused(): + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + with pytest.raises(ValueError, match="revolved section"): + build_view(plate(), "section", plane=plane, style="revolved") + + +def test_a_plane_that_misses_the_part_is_refused_rather_than_hatched_empty(): + plane = normalise_plane({"p1": (-10.0, 200.0), "p2": (100.0, 200.0)}) + with pytest.raises(ValueError, match="does not cut"): + build_view(plate(), "section", plane=plane, style="full") + + +# -- detail views ------------------------------------------------------------ + + +def test_a_detail_scales_the_region_and_labels_both_places(): + detail = {"center": (20.0, 12.5), "radius": 8.0, "scale": 5.0, "label": "D"} + view = build_view(shaft(), "detail", detail=detail) + assert view.kind == "detail" + assert view.scale == 5.0 + assert view.label == "D (5:1)" + width = view.bbox[2] - view.bbox[0] + # straight geometry is trimmed to the circle, so the enlargement can never + # be wider than the circle itself at the detail scale + assert width <= 2.0 * 8.0 * 5.0 + 1e-6 + assert width > 8.0 * 5.0 + marker = detail_marker_prims(detail) + circle = next(p for p in marker if isinstance(p, Circle)) + text = next(p for p in marker if isinstance(p, Text)) + assert circle.center == (20.0, 12.5) and circle.radius == 8.0 + assert text.text == "D" + + +def test_a_detail_reports_the_features_that_fall_outside_its_circle(): + from engineering.mech.features import RetainingGroove + + groove = RetainingGroove(id="rg", x=70.0, d=20.0, m=1.6, d2=18.6) + detail = {"center": (5.0, 12.5), "radius": 6.0, "scale": 5.0, "label": "D"} + view = build_view(shaft(groove), "detail", detail=detail) + assert any(entry["feature"] == "rg" for entry in view.omitted) + assert "detail circle D" in next( + entry["reason"] for entry in view.omitted if entry["feature"] == "rg" + ) + + +def test_a_detail_without_a_radius_or_with_a_bad_scale_is_refused(): + with pytest.raises(ValueError, match="radius"): + build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "scale": 5.0}) + with pytest.raises(ValueError, match="scale"): + build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "radius": 8.0, "scale": 0.0}) + + +def test_a_section_without_a_plane_is_refused_by_name(): + with pytest.raises(ValueError, match="plane"): + build_view(shaft(), "section") + + +def test_an_axial_bore_shows_its_flat_bottom_in_the_section(): + bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0) + view = build_view(shaft(bore), "section", plane=axis_plane(), style="full") + (hatch_upper, _hatch_lower) = [p for p in view.prims if isinstance(p, HatchArea)] + assert min(y for _, y in hatch_upper.loops[0]) == 0.0 + assert 4.0 in {round(y, 6) for _, y in hatch_upper.loops[0]} diff --git a/tests/test_mech_views.py b/tests/test_mech_views.py index f12f49f..168e19b 100644 --- a/tests/test_mech_views.py +++ b/tests/test_mech_views.py @@ -70,7 +70,10 @@ def coincident(view: View) -> dict: def test_the_engine_declares_three_orthographic_kinds_and_two_projections(): - assert VIEW_KINDS == ("front", "side", "top") + # The three orthographic kinds come first; Task 5 appended the two cut kinds + # (tests/test_mech_sections.py pins the full tuple from the other side). + assert VIEW_KINDS[:3] == ("front", "side", "top") + assert VIEW_KINDS == ("front", "side", "top", "section", "detail") assert PROJECTIONS == ("first", "third") From 978c1075ad414c0921fe0f2fdd21b0f049577fc7 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 22:16:47 +0300 Subject: [PATCH 07/11] fix(task-5): cut faces on the real chord, cross sections that see features, a section with no dashed cut edge Three defects, all of them the same mistake in three places: the cut face was built from something other than the geometry it claims to show. 1. A prismatic cut face removed a feature by its BOUNDING BOX. `Hole.removal_box` is a square, so the gap punched in the cut face was the full diameter wherever the plane passed. On the plan's 80x50x8 plate with a Ho10 hole at (40, 25): a plane at y=29.9 grazes the hole (true chord 1.990) and the section still showed an 8 mm gap through solid metal - 560.0 mm2 hatched against a true 624.08, a 64 mm2 error in the hatched area AND a wrong drawn outline. `Slot` had the same obround-as-box. Features now answer `cut_spans(part, p1, unit)` with their own exact chord: `_circle_chord` for a hole, the union of two circles and a rotated rectangle for a slot, the polygon's own crossings for a pocket, and an exact slab clip of `removal_box` as the default. `_box_span` (the corner projection, which is the box's shadow and not a chord at all) is gone. 2. A transverse revolved section ignored every feature and the honesty rule never fired. A cross section through a DIN 6885-1 keyway came out as a plain hatched disc with `omitted == ()` - the commonest mechanical cross section there is, hatched straight over the 12x5 keyway. The face is now built by `_transverse_face`: its radii come from `radial_profile` (so a groove, an undercut, a bore or an ISO 6410 internal thread is in the face already, which the raw segment diameters could not report), a non-axisymmetric feature is asked for its own `cross_section` notch record, and one the plane really passes through with no such record is listed in `omitted` with its reason rather than hatched over. `Keyway._notch` is the one calculation the end view and the cut face both come out of. 3. A section drew its own cut boundary as a HIDDEN (dashed) line, because the longitudinal branch reused `silhouette_prims` and the silhouette draws the bore dashed - correct for an un-cut view, wrong for a section, where that line IS the hatch loop's boundary. ISO 128-50 bounds a surface lying in the cutting plane with a continuous wide line and ISO 128-3 leaves hidden detail out of a sectional view; via ROLE_LAYER['hidden'] this would also have put the cut boundary on the HIDDEN layer in Task 7. New `section_profile_prims` emits the cut outline all-continuous, and `half=True` keeps the dashed bore on the -Y half only, which is the half a half section does not cut. And the claim the last commit made without earning it: the hatched area and the drawn outline are now literally one calculation. The longitudinal loop is the *emitted* outline flattened (`_flatten_path`), so a chamfer or fillet that substitutes a silhouette corner is cut out of the hatch exactly where it is drawn - a 2 mm 45 deg chamfer takes its 2 mm2 off the 1000 mm2 half face instead of being hatched over. Eleven new tests, every one of them red on the parent commit: the hole chord at 0/4/4.9 mm off centre, a tangent plane leaving the face whole, the 45 deg slot, the keyway cross section against its own analytic area, the feature a cross section cannot project, the feature the plane misses, the groove read off the profile, the continuous cut boundary, the half section's dashed half, and the chamfered cut face. No new ActiveX member is called: all three fixes are pure geometry in the backend-free `engineering/mech` package. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 2 + engineering/mech/features.py | 231 ++++++++++++++++++++++++-- engineering/mech/views.py | 305 ++++++++++++++++++++++++++++------- tests/test_mech_sections.py | 161 +++++++++++++++++- 4 files changed, 626 insertions(+), 73 deletions(-) diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index b318f68..ffb6f43 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -83,6 +83,7 @@ from .views import ( normalise_plane, outline_prims, radial_profile, + section_profile_prims, section_view, silhouette_prims, ) @@ -155,6 +156,7 @@ __all__ = [ "radial_profile", "rotate", "scale", + "section_profile_prims", "section_view", "segment_bounds", "silhouette_prims", diff --git a/engineering/mech/features.py b/engineering/mech/features.py index 56e5e55..a9b8873 100644 --- a/engineering/mech/features.py +++ b/engineering/mech/features.py @@ -121,6 +121,111 @@ def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]: return tuple(out) +# -- cutting-line chords ----------------------------------------------------- +# +# A section plane meets a feature along a *chord*, not along the feature's +# bounding box. The difference is not cosmetic: a plane 4.9 mm off the centre +# of a 10 mm hole cuts a 1.990 mm chord, and the box would punch the whole +# 10 mm out of the cut face - a wrong hatched area and a wrong drawn outline at +# the same time. Every function here returns parameters measured along ``unit`` +# from ``p1``, on the infinite line; the caller clips them to its own leg. + + +def _box_around(center, radius: float) -> tuple[float, float, float, float]: + """The axis-aligned box of a circle - what ``removal_box`` reports, and + exactly why a cut face may not be built from it (see ``cut_spans``).""" + x, y = float(center[0]), float(center[1]) + return (x - radius, y - radius, x + radius, y + radius) + + +def _merge_spans(spans) -> tuple[tuple[float, float], ...]: + """Sorted, non-overlapping spans. A union, so a shape built from several + pieces (a slot is two circles and a rectangle) reports one interval.""" + items = sorted((float(a), float(b)) for a, b in spans if float(b) - float(a) > _EPS) + out: list[list[float]] = [] + for a, b in items: + if out and a <= out[-1][1] + _EPS: + out[-1][1] = max(out[-1][1], b) + else: + out.append([a, b]) + return tuple((a, b) for a, b in out) + + +def _box_chord(box, p1, unit) -> tuple[tuple[float, float], ...]: + """The exact chord of an axis-aligned rectangle (a slab clip, not a corner + projection: projecting the four corners onto the line reports the box's + whole shadow, which is only the chord when the line is parallel to a side).""" + if box is None: + return () + low, high = -math.inf, math.inf + for axis in (0, 1): + direction = float(unit[axis]) + lo, hi = float(box[axis]), float(box[axis + 2]) + origin = float(p1[axis]) + if abs(direction) < 1e-12: + if origin < lo - _EPS or origin > hi + _EPS: + return () + continue + a, b = (lo - origin) / direction, (hi - origin) / direction + if a > b: + a, b = b, a + low, high = max(low, a), min(high, b) + return ((low, high),) if high - low > _EPS else () + + +def _circle_chord(center, radius, p1, unit) -> tuple[tuple[float, float], ...]: + """``2*sqrt(r**2 - d**2)`` about the foot of the perpendicular - exact, and + empty when the line only grazes the circle.""" + dx, dy = float(center[0]) - float(p1[0]), float(center[1]) - float(p1[1]) + along = dx * unit[0] + dy * unit[1] + off = -dx * unit[1] + dy * unit[0] + inside = float(radius) * float(radius) - off * off + if inside <= _EPS: + return () + half = math.sqrt(inside) + return ((along - half, along + half),) + + +def _rotated_box_chord(center, half_u, half_v, angle_deg, p1, unit): + """The exact chord of a rectangle rotated about its own centre: the line is + taken into the rectangle's frame and slab-clipped there.""" + theta = math.radians(float(angle_deg)) + cos, sin = math.cos(theta), math.sin(theta) + dx, dy = float(p1[0]) - float(center[0]), float(p1[1]) - float(center[1]) + origin = (dx * cos + dy * sin, -dx * sin + dy * cos) + direction = (unit[0] * cos + unit[1] * sin, -unit[0] * sin + unit[1] * cos) + return _box_chord((-half_u, -half_v, half_u, half_v), origin, direction) + + +def _polygon_chord(points, p1, unit, *, what: str = "the removed outline"): + """The intervals of the line that lie inside a simple polygon. + + Crossings are paired in order, exact for a polygon the line crosses + transversally. A line through a vertex gives an odd count and is refused + rather than paired wrong - the same rule the outline itself follows. + """ + ts: list[float] = [] + count = len(points) + for index in range(count): + ax, ay = points[index] + bx, by = points[(index + 1) % count] + ex, ey = bx - ax, by - ay + denominator = ex * unit[1] - ey * unit[0] + if abs(denominator) < 1e-12: + continue + s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator + if -1e-9 <= s <= 1.0 + 1e-9: + px, py = ax + s * ex, ay + s * ey + ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1]) + ts = sorted({round(t, 9) for t in ts}) + if len(ts) % 2 != 0: + raise ValueError( + f"section: the cutting plane grazes a vertex of {what}, so its crossings cannot " + "be paired. Move the plane off the vertex rather than accept a guessed cut face." + ) + return tuple((ts[i], ts[i + 1]) for i in range(0, len(ts), 2)) + + def _number(value: Any, path: str, *, positive: bool = True) -> float: try: number = float(value) @@ -289,6 +394,38 @@ class _Base: def removal_box(self, part) -> tuple[float, float, float, float] | None: return None + def cut_spans(self, part, p1, unit) -> tuple[tuple[float, float], ...]: + """Where a cutting line lies inside the material this feature removes. + + Parameters along ``unit`` measured from ``p1``, in the front view's own + plane. The default is the exact chord of ``removal_box``, which is the + truth for a feature whose removal really is a rectangle. A feature + whose removal is a circle or an obround **must** override this: the + chord of a hole shrinks to nothing as the plane leaves its centre, + while its bounding box stays the full diameter wide, so a box-based cut + face shows an 8 mm gap through solid material where the plane merely + grazes the hole. + """ + return _box_chord(self.removal_box(part), p1, unit) + + def cross_section(self, part, station: float) -> dict | None: + """This feature's shape in a **transverse** cut face at ``station``. + + ``{"start_deg", "end_deg", "path", "radius"}`` - the outer boundary of + the cut face keeps its arc from ``start_deg`` counter-clockwise to + ``end_deg`` (the material that survives the feature) and is closed by + ``path``, a polyline running from the point at ``start_deg`` to the + point at ``end_deg``, which the engine walks backwards to close the + loop. ``radius`` is the outside radius the record was built on, so the + engine can refuse to graft it onto a face of another size rather than + draw a notch that does not reach the surface. + + ``None`` means the feature has no transverse projection here. If the + plane really passes through it, the honesty rule reports it instead of + hatching over it. + """ + return None + def radial_edit(self, part) -> tuple[dict, ...]: return () @@ -559,6 +696,27 @@ class Keyway(_Base): f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm." ) + def _notch(self, part) -> dict: + """The notch the slot cuts out of the round section, once. + + The end view draws it and a transverse cut face closes on it, so both + come out of this one calculation and cannot disagree. ``Arc`` is CCW + from start to end, so the shaft that SURVIVES the slot runs from the + left notch wall the long way round to the right one; emitting + (right -> left) would draw the ~31 degree cap over the opening instead + of the ~329 degrees of material. + """ + _xa, _xb, radius, width, depth = self._span(part) + half = width / 2.0 + top = math.sqrt(max(radius * radius - half * half, 0.0)) + floor = radius - depth + return { + "radius": radius, + "start_deg": math.degrees(math.atan2(top, -half)) % 360.0, + "end_deg": math.degrees(math.atan2(top, half)) % 360.0, + "path": ((-half, top), (-half, floor), (half, floor), (half, top)), + } + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: part = _require_revolved(part, "Keyway") xa, xb, radius, width, depth = self._span(part) @@ -570,23 +728,22 @@ class Keyway(_Base): 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 - right = math.degrees(math.atan2(top, half)) % 360.0 - left = math.degrees(math.atan2(top, -half)) % 360.0 - # `Arc` is CCW from start to end, so the shaft that SURVIVES the slot - # runs from the left notch wall the long way round to the right one. - # Emitting (right -> left) would draw the ~31 degree cap over the - # opening instead of the ~329 degrees of material. + notch = self._notch(part) + path = notch["path"] return ( - Arc((0.0, 0.0), radius, left, right, "visible"), - Line((-half, top), (-half, floor), "visible"), - Line((-half, floor), (half, floor), "visible"), - Line((half, floor), (half, top), "visible"), + Arc((0.0, 0.0), radius, notch["start_deg"], notch["end_deg"], "visible"), + *(Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)), ) return () + def cross_section(self, part, station: float) -> dict | None: + """The notch, when the transverse plane falls inside the slot's length.""" + part = _require_revolved(part, "Keyway") + xa, xb, _radius, _width, _depth = self._span(part) + if not xa - _EPS <= float(station) <= xb + _EPS: + return None + return self._notch(part) + def removal_box(self, part): xa, xb, radius, _, depth = self._span(part) return (xa, radius - depth, xb, radius) @@ -1543,10 +1700,26 @@ class Hole(_Base): return () def removal_box(self, part): - x, y = float(self.x), float(self.y) + return _box_around(self._centre(), self._outer_radius()) + + def _centre(self) -> tuple[float, float]: + return (float(self.x), float(self.y)) + + def _outer_radius(self) -> float: + """The widest circle the plane can meet: the drill, or a counterbore or + countersink that is wider than it.""" 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) + return max([float(self.diameter) / 2.0, *extras]) + + def cut_spans(self, part, p1, unit): + """The true chord of the drilled circle, not the square that bounds it. + + On an 80x50x8 plate with a 10 mm hole at (40, 25): a plane at y=29.9 + grazes the hole and cuts a 1.990 mm chord, leaving 624.08 mm2 of cut + face. The bounding box reports 10 mm at every offset and hatches + 560 mm2 - a 64 mm2 error, with an 8 mm gap drawn through solid metal. + """ + return _circle_chord(self._centre(), self._outer_radius(), p1, unit) def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: if view != "front": @@ -1659,6 +1832,22 @@ class Slot(_Base): ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half) return (min(xs), min(ys), max(xs), max(ys)) + def cut_spans(self, part, p1, unit): + """The true chord of the obround: the union of its two end circles and + the rectangle between them. The bounding box of an angled slot is far + wider than the slot itself, so the box would erase material the slot + never touches.""" + (c1, c2, half) = self._ends() + middle = ((c1[0] + c2[0]) / 2.0, (c1[1] + c2[1]) / 2.0) + run = math.dist(c1, c2) + return _merge_spans( + [ + *_circle_chord(c1, half, p1, unit), + *_circle_chord(c2, half, p1, unit), + *_rotated_box_chord(middle, run / 2.0, half, float(self.angle), p1, unit), + ] + ) + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: if view != "front": return () @@ -1740,6 +1929,16 @@ class Pocket(_Base): ) return () + def cut_spans(self, part, p1, unit): + """The chord of the pocket's own outline, not of the box around it: a + pocket is only rectangular when its outline is.""" + return _polygon_chord( + tuple((float(x), float(y)) for x, y in self.outline), + p1, + unit, + what=f"pocket {self.id!r}", + ) + def removal_box(self, part): xs = [float(x) for x, _ in self.outline] ys = [float(y) for _, y in self.outline] diff --git a/engineering/mech/views.py b/engineering/mech/views.py index 313e469..2666078 100644 --- a/engineering/mech/views.py +++ b/engineering/mech/views.py @@ -29,8 +29,6 @@ from engineering.mech.features import mirror_about_x from engineering.mech.part import ( PrismaticPart, RevolvedPart, - inner_radius_at, - outer_radius_at, outline_bbox, part_length, part_max_diameter, @@ -259,24 +257,18 @@ def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = return tuple(prims), used -def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: - """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" - bands = radial_profile(part) +def _corner_edits(part: RevolvedPart) -> list[dict]: + """Every feature's silhouette corner substitution, tagged with its owner.""" edits: list[dict] = [] for feature in part.features: edit = feature.corner_edit(part) if edit is not None: edits.append({**edit, "owner": feature.id}) + return edits - upper, used = _emit_path(_outer_path(bands), edits, "visible") - prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) - for run in _bore_path(bands): - bore, _ = _emit_path(run, [], "hidden") - prims.extend(bore) - prims.extend(mirror_about_x(bore)) - - unplaced = tuple( +def _unplaced(edits: list[dict], used: set[str]) -> tuple[dict, ...]: + return tuple( { "feature": edit["owner"], "reason": ( @@ -287,7 +279,54 @@ def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, for edit in edits if edit["owner"] not in used ) - return tuple(prims), unplaced + + +def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" + bands = radial_profile(part) + edits = _corner_edits(part) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "hidden") + prims.extend(bore) + prims.extend(mirror_about_x(bore)) + + return tuple(prims), _unplaced(edits, used) + + +def section_profile_prims( + part: RevolvedPart, *, half: bool = False +) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The longitudinal **cut** outline of a revolved part. + + Every edge of this outline lies in the cutting plane, so every edge is a + continuous line: ISO 128-50 bounds a surface lying in the cutting plane + with a continuous wide line, and ISO 128-3 leaves hidden detail out of a + sectional view altogether. The bore wall is the case that matters - the + un-cut silhouette draws it dashed because material hides it, but in a + section it *is* the cut face's own boundary, and drawing the hatch loop and + a dashed line on the same coordinates is the one thing a section must not + do. ``half=True`` keeps the dashed bore on the -Y half, which is the half a + half section does not cut. + """ + bands = radial_profile(part) + edits = _corner_edits(part) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "visible") + prims.extend(bore) + mirrored = mirror_about_x(bore) + if half: + mirrored = tuple(prim._replace(role="hidden") for prim in mirrored) + prims.extend(mirrored) + + return tuple(prims), _unplaced(edits, used) def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: @@ -720,6 +759,54 @@ def _inner_path(bands: tuple[Band, ...]) -> list[Pt]: return points +def _arc_loop_points( + radius: float, start_deg: float, end_deg: float, *, center: Pt = (0.0, 0.0) +) -> tuple[Pt, ...]: + """An arc as points, at the same 2 degree chord ``CIRCLE_SEGMENTS`` states. + + Endpoints are exact, so a loop built from an arc plus a polyline closes on + the polyline's own corners. + """ + sweep = (float(end_deg) - float(start_deg)) % 360.0 + if sweep <= _EPS: + sweep = 360.0 + count = max(1, math.ceil(sweep / (360.0 / CIRCLE_SEGMENTS) - 1e-9)) + out: list[Pt] = [] + for step in range(count + 1): + angle = math.radians(float(start_deg) + sweep * step / count) + out.append((center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle))) + return tuple(out) + + +def _flatten_path(prims) -> tuple[Pt, ...]: + """A contiguous run of ``Line``/``Arc`` primitives as a point path. + + This is what makes the drawn outline and the hatched loop **one** + calculation rather than two that agree by inspection: the loop is the + emitted outline, flattened, so a chamfer or a fillet that substitutes a + silhouette corner substitutes it in the cut face too. + """ + points: list[Pt] = [] + + def push(point: Pt) -> None: + pt = (float(point[0]), float(point[1])) + if not points or math.dist(points[-1], pt) > _EPS: + points.append(pt) + + for prim in prims: + if isinstance(prim, Line): + push(prim.p1) + push(prim.p2) + elif isinstance(prim, Arc): + for point in _arc_loop_points( + prim.radius, prim.start_deg, prim.end_deg, center=prim.center + ): + push(point) + else: # pragma: no cover - only an outline path is ever flattened + raise TypeError(f"_flatten_path: {type(prim).__name__} is not a path primitive.") + return tuple(points) + + def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]: steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS) return tuple( @@ -779,17 +866,6 @@ def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]: return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)] -def _box_span(box, p1: Pt, unit: Pt, normal: Pt): - xs = (box[0], box[2]) - ys = (box[1], box[3]) - corners = [(x, y) for x in xs for y in ys] - distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners] - if min(distances) > 1e-9 or max(distances) < -1e-9: - return None - spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners] - return (min(spans), max(spans)) - - def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]: """The cutting legs of an offset plane. @@ -815,7 +891,6 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): if length < 1e-9: continue unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length) - normal = (-unit[1], unit[0]) intervals = _chords(part.outline, start, unit) intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9] intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals] @@ -823,19 +898,20 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): pockets: list[tuple[tuple[float, float], float, bool]] = [] for feature in part.features: - box = feature.removal_box(part) - if box is None: - continue - span = _box_span(box, start, unit, normal) - if span is None: - continue - span = (max(span[0], 0.0), min(span[1], length)) - if span[1] - span[0] <= 1e-9: - continue - if isinstance(feature, _PocketType): - pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch)) - else: - intervals = _subtract(intervals, span) + # the feature's own chord, never the box around it: a box punches + # the full width of a hole or an angled slot into the cut face + # wherever the plane passes, which is a wrong hatched area and a + # wrong drawn outline at the same time. + for span in feature.cut_spans(part, start, unit): + span = (max(span[0], 0.0), min(span[1], length)) + if span[1] - span[0] <= 1e-9: + continue + if isinstance(feature, _PocketType): + pockets.append( + (span, thickness - float(feature.depth), feature.no_section_hatch) + ) + else: + intervals = _subtract(intervals, span) for pocket_span, floor, unhatched in pockets: intervals = _subtract(intervals, pocket_span) @@ -854,6 +930,131 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): return rectangles +def _band_at(bands: tuple[Band, ...], station: float) -> Band | None: + for band in bands: + if band.x0 - _TOL <= station <= band.x1 + _TOL: + return band + return None + + +def _represented_by_the_profile(feature, part, station: float) -> bool: + """True when the radial profile already carries this feature at ``station``. + + A groove, an undercut, an axial bore or an ISO 6410 internal thread edits + the profile, and the transverse cut face is read off that profile - so the + feature is in the face already and must not be reported missing from it. + """ + for edit in feature.radial_edit(part): + if float(edit["x0"]) - _TOL <= station <= float(edit["x1"]) + _TOL: + return True + return False + + +def _transverse_face(part: RevolvedPart, station: float) -> dict: + """One transverse cut face: its boundary primitives, its loops, its omissions. + + The radii come from ``radial_profile``, the same one calculation the + silhouette and the longitudinal cut face come out of, so a groove or an + internal thread at that station is in the face without being asked for. + A feature that is *not* axisymmetric is asked for its own ``cross_section`` + notch; one that the plane really passes through and that has no such record + is reported rather than hatched over - a keyed shaft whose cross section + came out as a plain hatched disc is exactly the drawing the honesty rule + exists to prevent. + """ + bands = radial_profile(part) + band = _band_at(bands, station) + if band is None: + raise ValueError( + f"section: the plane at x={station:g} is outside {part.name!r} " + f"(0 .. {part_length(part):g} mm)." + ) + span = band.x1 - band.x0 + fraction = 0.0 if span <= _EPS else (station - band.x0) / span + r_out = band.r_out0 + fraction * (band.r_out1 - band.r_out0) + r_in = band.r_in + + notch: dict | None = None + omitted: list[dict] = [] + for feature in part.features: + record = feature.cross_section(part, station) + if record is not None: + if notch is not None: + omitted.append( + { + "feature": feature.id, + "reason": ( + "the cut face already carries another feature's notch; this " + "engine cuts one notch per transverse face" + ), + } + ) + continue + measured = float(record["radius"]) + if abs(measured - r_out) > _TOL: + omitted.append( + { + "feature": feature.id, + "reason": ( + f"its notch was measured on a {2.0 * measured:g} mm surface but " + f"the cut face at x={station:g} is {2.0 * r_out:g} mm, so the " + "notch would not reach it" + ), + } + ) + continue + notch = record + continue + if _represented_by_the_profile(feature, part, station): + continue + box = feature.removal_box(part) + if box is not None and float(box[0]) - _TOL <= station <= float(box[2]) + _TOL: + omitted.append( + { + "feature": feature.id, + "reason": ( + f"the cutting plane at x={station:g} passes through it, but a " + f"{type(feature).__name__} has no transverse cut-face projection" + ), + } + ) + else: + omitted.append( + { + "feature": feature.id, + "reason": f"it is not on the cutting plane at x={station:g}", + } + ) + + if notch is None: + loops: list[tuple[tuple[Pt, ...], bool]] = [(_circle_loop(r_out), True)] + prims: list[Prim] = [Circle((0.0, 0.0), r_out, "visible")] + else: + path = tuple((float(x), float(y)) for x, y in notch["path"]) + start, end = float(notch["start_deg"]), float(notch["end_deg"]) + # the material arc plus the notch walls: one boundary, and the loop is + # that same boundary sampled, so the hatch cannot cover the notch. The + # arc ends where the path ends, so the path is walked backwards to + # close the loop, and both of its endpoints are already on the arc. + closing = tuple(reversed(path))[1:-1] + loops = [(_arc_loop_points(r_out, start, end) + closing, True)] + prims = [Arc((0.0, 0.0), r_out, start, end, "visible")] + prims.extend( + Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1) + ) + if r_in > _EPS: + loops.append((_circle_loop(r_in, reverse=True), True)) + prims.append(Circle((0.0, 0.0), r_in, "visible")) + + return { + "r_out": r_out, + "r_in": r_in, + "loops": tuple(loops), + "prims": tuple(prims), + "omitted": tuple(omitted), + } + + def cut_loops(part, plane: dict, *, style: str = "full"): """The closed cut-face loops, each flagged with whether it is hatched.""" plane = normalise_plane(plane) @@ -869,7 +1070,10 @@ def cut_loops(part, plane: dict, *, style: str = "full"): "section: an offset plane has no meaning along a revolved part's axis; " "use style='full' (or a transverse plane for a cross section)." ) - upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands))) + # the loop is the *emitted* outline flattened, so a chamfer or a + # fillet is cut out of the hatched area exactly where it is drawn + outline, _used = _emit_path(_outer_path(bands), _corner_edits(part), "visible") + upper = _flatten_path(outline) + tuple(reversed(_inner_path(bands))) loops = [(upper, True)] if name != "half": loops.append((tuple((x, -y) for x, y in upper), True)) @@ -879,13 +1083,7 @@ def cut_loops(part, plane: dict, *, style: str = "full"): "section: a half section is defined on a longitudinal plane through the axis; " "a transverse plane takes style='full' or 'revolved'." ) - station = plane["p1"][0] - r_out = outer_radius_at(part, station) - r_in = inner_radius_at(part, station) - loops = [(_circle_loop(r_out), True)] - if r_in > _EPS: - loops.append((_circle_loop(r_in, reverse=True), True)) - return tuple(loops) + return _transverse_face(part, plane["p1"][0])["loops"] if name in ("half", "revolved"): raise ValueError( @@ -920,7 +1118,7 @@ def section_view( dims: list[DimIntent] = [] if isinstance(part, RevolvedPart) and _is_longitudinal(plane): - body, unplaced = silhouette_prims(part) + body, unplaced = section_profile_prims(part, half=name == "half") prims.extend(body) prims.extend(_axis_prims(part)) omitted.extend(unplaced) @@ -932,13 +1130,10 @@ def section_view( dims.extend(feature.dims(part, "front", ctx)) elif isinstance(part, RevolvedPart): role = "phantom" if name == "revolved" else "visible" - station = plane["p1"][0] - r_out = outer_radius_at(part, station) - r_in = inner_radius_at(part, station) - prims.append(Circle((0.0, 0.0), r_out, role)) - if r_in > _EPS: - prims.append(Circle((0.0, 0.0), r_in, role)) - over = r_out * (1.0 + AXIS_OVERRUN) + face = _transverse_face(part, plane["p1"][0]) + prims.extend(prim._replace(role=role) for prim in face["prims"]) + omitted.extend(face["omitted"]) + over = face["r_out"] * (1.0 + AXIS_OVERRUN) prims.append(Line((-over, 0.0), (over, 0.0), "center")) prims.append(Line((0.0, -over), (0.0, over), "center")) else: diff --git a/tests/test_mech_sections.py b/tests/test_mech_sections.py index 9023ddd..7c40863 100644 --- a/tests/test_mech_sections.py +++ b/tests/test_mech_sections.py @@ -12,9 +12,19 @@ import math import pytest -from engineering.mech.features import AxialBore, Hole, Pocket, Thread +from engineering.mech.features import ( + AxialBore, + Chamfer, + CrossHole, + Hole, + Keyway, + Pocket, + RetainingGroove, + Slot, + Thread, +) from engineering.mech.part import PrismaticPart, RevolvedPart, Segment -from engineering.mech.primitives import Circle, HatchArea, Text +from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Text from engineering.mech.views import ( CIRCLE_SEGMENTS, SECTION_PLANE, @@ -124,6 +134,55 @@ def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter(): assert min(y for x, y in upper if x > 25.0 + EPS) == pytest.approx(8.0) +def test_a_section_draws_its_own_cut_boundary_continuous_and_never_dashed(): + """ISO 128-50: a surface lying in the cutting plane is bounded by a + continuous wide line, and ISO 128-3 leaves hidden detail out of a sectional + view. The bore wall is the case that catches a section built out of the + un-cut silhouette: there the material hides the bore, so the silhouette + draws it dashed - but y = +-10 is the hatch loop's own boundary, and a + dashed line drawn on the hatch boundary means the outline and the hatched + area disagree at identical coordinates. + """ + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "section", plane=axis_plane(), style="full") + assert "hidden" not in {getattr(prim, "role", None) for prim in view.prims} + bore = [ + line + for line in view.prims + if isinstance(line, Line) + and abs(abs(line.p1[1]) - 10.0) < EPS + and abs(abs(line.p2[1]) - 10.0) < EPS + ] + assert len(bore) == 2 + assert {line.role for line in bore} == {"visible"} + (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full") + assert min(y for _, y in upper) == pytest.approx(10.0) + assert max(y for _, y in lower) == pytest.approx(-10.0) + + +def test_a_half_section_keeps_its_dashed_bore_only_on_the_half_it_does_not_cut(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "section", plane=axis_plane(style="half"), style="half") + hidden = [prim for prim in view.prims if getattr(prim, "role", None) == "hidden"] + assert hidden, "the un-sectioned half still shows its bore, dashed" + assert all(line.p1[1] < 0.0 and line.p2[1] < 0.0 for line in hidden) + assert all(abs(abs(line.p1[1]) - 10.0) < EPS for line in hidden) + # the cut half carries the hatch, and nothing dashed on it + ((upper, _),) = cut_loops(part, axis_plane(style="half"), style="half") + assert min(y for _, y in upper) == pytest.approx(10.0) + + +def test_a_chamfer_is_cut_out_of_the_hatched_area_exactly_where_it_is_drawn(): + """The loop is the emitted outline flattened, so a corner substitution + reaches the cut face too: a 2 mm 45 deg chamfer takes a 2 mm2 triangle off + the 1000 mm2 half face, and a loop built from the raw profile would hatch + over the chamfer it just drew.""" + (upper, _), _lower = cut_loops( + shaft(Chamfer(id="c1", at="start", size=2.0)), axis_plane(), style="full" + ) + assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0 - 2.0) + + def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material(): view = build_view(shaft(), "section", plane=axis_plane(), style="full") hatches = [p for p in view.prims if isinstance(p, HatchArea)] @@ -177,6 +236,66 @@ def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom(): assert {c.role for c in circles} == {"phantom"} +def keyed_shaft(*features) -> RevolvedPart: + """A plain 40 mm shaft; DIN 6885-1 gives its keyway 12 mm x 5 mm deep.""" + return RevolvedPart(name="keyed", segments=(Segment(60.0, 40.0),), features=tuple(features)) + + +def test_a_cross_section_through_a_keyway_cuts_the_notch_out_of_the_disc(): + """The commonest mechanical cross section there is. The plane at x=20 is + squarely inside the slot, so the face is a notched disc - not the plain + hatched circle a face built from the segment diameter alone produces.""" + keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0) + plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)}) + view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full") + assert view.omitted == () + arcs = [prim for prim in view.prims if isinstance(prim, Arc)] + assert len(arcs) == 1 and arcs[0].radius == pytest.approx(20.0) + assert not [prim for prim in view.prims if isinstance(prim, Circle)] + + radius, half, floor = 20.0, 6.0, 15.0 + top = math.sqrt(radius * radius - half * half) + # the disc, less the notch rectangle, less the cap the slot mouth removes + cap = radius * radius * math.acos(top / radius) - top * half + expected = math.pi * radius * radius - 2.0 * half * (top - floor) - cap + (hatch,) = [prim for prim in view.prims if isinstance(prim, HatchArea)] + assert polygon_area(hatch.loops[0]) == pytest.approx(expected, rel=2.1e-4) + # and it really is a notch: the whole disc is 56 mm2 larger + assert polygon_area(hatch.loops[0]) < math.pi * radius * radius - 50.0 + # the arc the face is bounded by is the arc the end view draws + assert arcs[0].start_deg == pytest.approx(math.degrees(math.atan2(top, -half)) % 360.0) + assert arcs[0].end_deg == pytest.approx(math.degrees(math.atan2(top, half)) % 360.0) + + +def test_a_cross_section_reports_the_feature_it_cannot_project(): + """The honesty rule, on the one view where silence is indistinguishable + from a correct drawing: the face is hatched either way.""" + cross = CrossHole(id="xh", x=20.0, diameter=6.0) + plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)}) + view = build_view(keyed_shaft(cross), "section", plane=plane, style="full") + assert [entry["feature"] for entry in view.omitted] == ["xh"] + assert "transverse" in view.omitted[0]["reason"] + + +def test_a_feature_the_transverse_plane_misses_is_reported_as_off_the_plane(): + keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0) + plane = normalise_plane({"p1": (50.0, -40.0), "p2": (50.0, 40.0)}) + view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full") + assert [entry["feature"] for entry in view.omitted] == ["kw"] + assert "not on the cutting plane" in view.omitted[0]["reason"] + + +def test_a_transverse_face_is_read_off_the_radial_profile_not_the_segment(): + """A groove is in the profile, so it is in the cross section - at its own + reduced diameter, and without being reported missing.""" + groove = RetainingGroove(id="rg", x=30.0, d=40.0, m=1.85, d2=37.5) + plane = normalise_plane({"p1": (30.0, -40.0), "p2": (30.0, 40.0)}) + view = build_view(keyed_shaft(groove), "section", plane=plane, style="full") + assert view.omitted == () + circles = [prim for prim in view.prims if isinstance(prim, Circle)] + assert {round(circle.radius, 6) for circle in circles} == {37.5 / 2.0} + + def test_a_half_transverse_section_is_refused_with_its_reason(): part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) @@ -205,6 +324,44 @@ def test_a_through_hole_splits_the_cut_face_in_two(): assert total == pytest.approx((80.0 - 10.0) * 8.0) +@pytest.mark.parametrize("offset", [0.0, 4.0, 4.9]) +def test_a_hole_is_cut_on_its_chord_and_not_on_the_box_around_it(offset): + """The gap a hole punches in a cut face is the chord the plane actually + cuts, and it shrinks to nothing as the plane leaves the centre. Removing + the hole by its bounding box reports the full 10 mm diameter at every + offset: 32 mm2 of missing cut face at 4 mm off centre and 64 mm2 at 4.9, + with an 8 mm gap drawn through solid metal where the plane merely grazes + the hole.""" + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 25.0 + offset), "p2": (100.0, 25.0 + offset)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + chord = 2.0 * math.sqrt(5.0**2 - offset**2) + total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea)) + assert total == pytest.approx((80.0 - chord) * 8.0) + if offset > EPS: + assert total != pytest.approx((80.0 - 10.0) * 8.0) + + +def test_a_plane_tangent_to_a_hole_leaves_the_cut_face_whole(): + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 30.0), "p2": (100.0, 30.0)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + hatches = [prim for prim in view.prims if isinstance(prim, HatchArea)] + assert len(hatches) == 1 + assert polygon_area(hatches[0].loops[0]) == pytest.approx(80.0 * 8.0) + + +def test_an_angled_slot_is_cut_on_its_obround_and_not_on_the_box_around_it(): + """A 45 degree slot's bounding box is 24.14 mm across the cutting line; the + slot itself is 14.14. The box would erase 10 mm of material the slot never + touches.""" + slot = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0, angle=45.0) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(slot), "section", plane=plane, style="full") + total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea)) + assert total == pytest.approx((80.0 - 10.0 * math.sqrt(2.0)) * 8.0) + + def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness(): pocket = Pocket( id="p1", From 01213fc5f5c2b15bb7eb9d405b6307c6c81ed7f1 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 22:27:09 +0300 Subject: [PATCH 08/11] feat(task-6): dimension intents, ISO 286 fits and an overlap-free layout engineering/mech/dimension.py collects what the features asked to have dimensioned, adds the part's own sizes (chain, baseline or ordinate for the axial lengths, a diameter per segment and per bore, the two overall sizes of a plate), and removes the duplicates - ISO 129-1 says each dimension appears once, and a test asserts no measurement is placed twice. Fits and tolerances are resolved through the modules that already own them: engineering.fits for an ISO 286 code and engineering.tolerances for an explicit ISO 129 tolerance, including build_dim_override's sign convention (the lower deviation is passed as the magnitude of the minus side, exactly as server.py::_fit_to_tolerances does). A H7 bore at 40 mm resolves to +0.025/0 - the correctness check the spec names. A fit together with an explicit tolerance, a fit outside the authored ISO 286 range, and a fit on an intent with no measurable nominal are each refused by name rather than guessed. The layout is the part that had to be measured rather than asserted: every intent's text is turned into a rectangle by the same helper the test uses, and an intent is placed on the lowest level whose rectangle is free, with the level pitch clamped to clear the text whatever the caller passed. The test then asserts the placed rectangles are pairwise disjoint - including on a crowded plate and with a deliberately too-small step. That is the same property the repository's dim_overlap critique focus checks on the finished drawing. The plan's layout clamped that pitch to the text HEIGHT on every axis, which is only the stacking direction for the row of dimensions under the view. Beside the view the levels march in x, so the extent they have to clear is the text WIDTH: measured here, three diameter labels are 9.45 mm wide (9.45, 5.6) at a 8.0 mm pitch, so a shaft's three segment diameters - all on the y = 0 centreline, hence all on different levels - overlapped by 1.45 mm. The pitch and the within-level span are now both taken per axis from the same _text_size helper text_rects uses, which makes the disjointness a property of the algorithm rather than of the label lengths that happened to be in the test. Over hole_table_threshold holes the per-hole diameters leave the view and become a table instead, grouped by diameter, thread, counterbore and depth, one tag per group and the quantity on every row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 20 ++ engineering/mech/dimension.py | 380 ++++++++++++++++++++++++++++++++++ tests/test_mech_dimension.py | 276 ++++++++++++++++++++++++ 3 files changed, 676 insertions(+) create mode 100644 engineering/mech/dimension.py create mode 100644 tests/test_mech_dimension.py diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index ffb6f43..b58a1d7 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -6,6 +6,17 @@ and alphabetical — keep every side's names. from __future__ import annotations +from .dimension import ( + DIM_STYLES, + TEXT_WIDTH_FACTOR, + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + measured_value, + resolve_tolerance, + text_rects, +) from .features import ( FEATURE_KINDS, AxialBore, @@ -100,6 +111,7 @@ __all__ = [ "CornerChamfer", "CornerFillet", "CrossHole", + "DIM_STYLES", "DimIntent", "FEATURE_KINDS", "Feature", @@ -128,6 +140,7 @@ __all__ = [ "SECTION_STYLES", "Segment", "Slot", + "TEXT_WIDTH_FACTOR", "Text", "Thread", "Undercut", @@ -139,10 +152,15 @@ __all__ = [ "cut_loops", "detail_marker_prims", "detail_view", + "dimension_intents", "feature_from_dict", + "hole_table_rows", "inner_radius_at", + "intent_text", "layer_for", + "layout_dimensions", "layout_origin", + "measured_value", "mirror_about_x", "normalise_plane", "outer_radius_at", @@ -154,12 +172,14 @@ __all__ = [ "point_in_outline", "points_of", "radial_profile", + "resolve_tolerance", "rotate", "scale", "section_profile_prims", "section_view", "segment_bounds", "silhouette_prims", + "text_rects", "translate", "validate_part", ] diff --git a/engineering/mech/dimension.py b/engineering/mech/dimension.py new file mode 100644 index 0000000..41e29e0 --- /dev/null +++ b/engineering/mech/dimension.py @@ -0,0 +1,380 @@ +"""Dimension intents for a view, laid out so no two texts overlap. + +Pure: no backend, no async, no DXF. The features declare what they want +dimensioned; this module adds the part's own sizes, removes the duplicates +(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129 +tolerances through the modules that already own them, and then *places* the +texts. + +The placement is not a matter of taste. Every intent's text is measured into a +rectangle by :func:`text_rects`, and an intent is put on the lowest free level +whose rectangle does not overlap one already placed on that level. Two +rectangles are disjoint when they are separated along *either* axis, so the +layout separates them along both in turn: within a level, along the axis the +level runs in; between levels, along the axis the levels stack in. The second +one is why the level pitch is clamped to the extent of the text *in the +stacking direction* - which is the text height for the row of dimensions under +the view, and the text **width** for the column of diameters beside it. A pitch +clamped only to the height would let two 9.45 mm wide diameter labels sit +8 mm apart on the same centreline, overlapping by 1.45 mm. + +The test asserts pairwise disjointness with the same helper, which makes "no +overlapping dimension text" measured rather than claimed - and it is the same +property the repository's `dim_overlap` critique focus checks on the finished +drawing. +""" + +from __future__ import annotations + +import math + +from engineering.fits import fit_lookup +from engineering.mech.features import Hole +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + outline_bbox, + part_length, + segment_bounds, +) +from engineering.mech.primitives import DimIntent +from engineering.tolerances import build_dim_override + +_EPS = 1e-9 + +DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate") + +#: Mean glyph width as a fraction of the text height, for measuring a label +#: into a rectangle. Deliberately generous: over-estimating the width can only +#: push two dimensions further apart. +TEXT_WIDTH_FACTOR = 0.7 + +#: Padding added around a measured label, in text heights. +TEXT_PADDING = 0.6 + + +def measured_value(intent: DimIntent) -> float: + """What the dimension measures, in drawing units.""" + p1, p2 = intent.p1, intent.p2 + if intent.kind == "linear": + dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1]) + return dx if dx >= dy else dy + return math.dist(p1, p2) + + +def _format(value: float) -> str: + return f"{round(value, 3):g}" + + +def intent_text(intent: DimIntent) -> str: + """The text a dimension will carry - the override, or the measurement.""" + if intent.text_override: + return intent.text_override + if intent.kind == "diameter": + return f"⌀{_format(measured_value(intent))}" + if intent.kind == "radius": + return f"R{_format(measured_value(intent))}" + return _format(measured_value(intent)) + + +def _text_size(intent: DimIntent, height: float) -> tuple[float, float]: + """``(width, height)`` of the rectangle this intent's label occupies.""" + label = intent_text(intent) + return ( + (len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height, + height * (1.0 + TEXT_PADDING), + ) + + +def text_rects(intents, *, height: float = 3.5): + """``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``.""" + rects = [] + for intent in intents: + if intent.text_at is None: + raise ValueError( + f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; " + "run layout_dimensions first." + ) + width, tall = _text_size(intent, height) + cx, cy = float(intent.text_at[0]), float(intent.text_at[1]) + rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0)) + return tuple(rects) + + +# -- tolerances -------------------------------------------------------------- + + +def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: + """Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments. + + Mirrors `server.py::_fit_to_tolerances`, including its sign convention: + ``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus + deviation, so a resolved fit passes ``-deviation.lower_mm``. + """ + has_tol = bool(intent.tol) + if intent.fit and has_tol: + raise ValueError( + f"{intent.feature or intent.kind}: pass either fit= or an explicit " + "tol, not both." + ) + if intent.fit: + size = float(nominal) if nominal is not None else measured_value(intent) + if size <= _EPS: + raise ValueError( + f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and " + f"the intent measures {size}. Pass nominal= explicitly." + ) + deviation = fit_lookup(intent.fit, size) + return { + "tol_upper": deviation.upper_mm, + "tol_lower": -deviation.lower_mm, + "tol_mode": "deviation", + "text_override": intent.text_override or f"<> {deviation.code}", + } + if has_tol: + tol = dict(intent.tol) + mode = str(tol.get("mode", "none")) + upper = tol.get("upper") + lower = tol.get("lower") + build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises + return { + "tol_upper": upper, + "tol_lower": lower, + "tol_mode": mode, + "text_override": intent.text_override, + } + return { + "tol_upper": None, + "tol_lower": None, + "tol_mode": "none", + "text_override": intent.text_override, + } + + +# -- collecting the intents -------------------------------------------------- + + +def _dedupe(intents) -> tuple[DimIntent, ...]: + seen: set[tuple] = set() + out: list[DimIntent] = [] + for intent in intents: + key = ( + intent.kind, + round(float(intent.p1[0]), 6), + round(float(intent.p1[1]), 6), + round(float(intent.p2[0]), 6), + round(float(intent.p2[1]), 6), + ) + if key in seen: + continue + seen.add(key) + out.append(intent) + return tuple(out) + + +def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]: + stations = [0.0] + [x1 for _, x1 in segment_bounds(part)] + intents: list[DimIntent] = [] + if style == "chain": + for a, b in zip(stations, stations[1:], strict=False): + intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part")) + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part") + ) + else: # baseline and ordinate both measure from the left face + for station in stations[1:]: + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part") + ) + return intents + + +def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]: + intents: list[DimIntent] = [] + for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True): + mid = (x0 + x1) / 2.0 + radius = segment.d_outer / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(mid, radius), p2=(mid, -radius), feature="part") + ) + if segment.d_inner > _EPS: + bore = segment.d_inner / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(mid, bore), p2=(mid, -bore), feature="part") + ) + return intents + + +def _hole_groups(part) -> dict[tuple, list[Hole]]: + groups: dict[tuple, list[Hole]] = {} + for feature in getattr(part, "features", ()): + if not isinstance(feature, Hole): + continue + key = ( + round(float(feature.diameter), 6), + feature.thread or "", + None if feature.cbore_d is None else round(float(feature.cbore_d), 6), + None if feature.depth is None else round(float(feature.depth), 6), + ) + groups.setdefault(key, []).append(feature) + return groups + + +def dimension_intents( + part, + view, + *, + style: str = "chain", + hole_table_threshold: int = 8, +) -> tuple[DimIntent, ...]: + """Every dimension this view wants: the features' own, plus the part's sizes.""" + name = str(style or "").strip().lower() + if name not in DIM_STYLES: + raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.") + + intents: list[DimIntent] = list(view.dims) + kind = "front" if view.kind == "section" else view.kind + + if isinstance(part, RevolvedPart): + if kind == "front": + intents.extend(_revolved_axial(part, name)) + intents.extend(_revolved_diameters(part)) + elif kind == "side": + near = part.segments[-1] + radius = near.d_outer / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(-radius, 0.0), p2=(radius, 0.0), feature="part") + ) + if near.d_inner > _EPS: + bore = near.d_inner / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(-bore, 0.0), p2=(bore, 0.0), feature="part") + ) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if kind == "front": + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part")) + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part")) + else: + # An edge view sees the thickness, measured at the near edge of + # whichever outline axis this view runs along. + u0 = y0 if kind == "side" else x0 + intents.append( + DimIntent( + kind="linear", + p1=(u0, 0.0), + p2=(u0, float(part.thickness)), + feature="part", + ) + ) + + groups = _hole_groups(part) + total_holes = sum(len(holes) for holes in groups.values()) + if total_holes > int(hole_table_threshold): + hole_ids = {hole.id for holes in groups.values() for hole in holes} + intents = [i for i in intents if i.feature not in hole_ids] + + return _dedupe(intents) + + +def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]: + """One row per hole, tagged and counted by group - empty under the threshold. + + ``view`` is part of the pinned signature and is accepted unused, so a later + view-specific filter (a hole that does not appear in this view) has one + place to go. + """ + del view + groups = _hole_groups(part) + total = sum(len(holes) for holes in groups.values()) + if total <= int(hole_table_threshold): + return () + rows: list[dict] = [] + for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))): + tag = chr(ord("A") + index) + holes = groups[key] + diameter, thread, cbore, depth = key + note_parts = [] + if thread: + note_parts.append(str(thread)) + if cbore is not None: + note_parts.append(f"c'bore ⌀{cbore:g}") + if depth is not None: + note_parts.append(f"deep {depth:g}") + note = " ".join(note_parts) + for hole in holes: + rows.append( + { + "tag": tag, + "x": float(hole.x), + "y": float(hole.y), + "d": float(diameter), + "qty": len(holes), + "note": note, + } + ) + return tuple(rows) + + +# -- the layout -------------------------------------------------------------- + + +def _axis_of(intent: DimIntent) -> str: + """ "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right".""" + if intent.kind in ("diameter", "radius", "angular"): + return "right" + dx = abs(intent.p2[0] - intent.p1[0]) + dy = abs(intent.p2[1] - intent.p1[1]) + return "bottom" if dx >= dy else "left" + + +def layout_dimensions( + view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5 +) -> tuple[DimIntent, ...]: + """Give every intent a ``text_at`` such that no two text rectangles overlap.""" + x0, y0, x1, y1 = view.bbox + del y1 + + # Measure first: a level's pitch has to clear the widest text that stacks + # across it, and that is the text WIDTH on the left/right axes, where the + # levels march in x, not the text height. + measured: list[tuple[DimIntent, str, float, float]] = [] + across: dict[str, float] = {} + for intent in intents: + axis = _axis_of(intent) + width, tall = _text_size(intent, height) + # ``along`` is the extent parallel to the level, ``across`` the extent + # perpendicular to it - the one successive levels have to clear. + along, over = (width, tall) if axis == "bottom" else (tall, width) + measured.append((intent, axis, along, over)) + across[axis] = max(across.get(axis, 0.0), over) + pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()} + + occupied: dict[tuple[str, int], list[tuple[float, float]]] = {} + placed: list[DimIntent] = [] + + for intent, axis, along, _over in measured: + if axis == "bottom": + center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0 + else: + center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0 + span = (center - along / 2.0, center + along / 2.0) + + level = 0 + while True: + taken = occupied.setdefault((axis, level), []) + if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken): + taken.append(span) + break + level += 1 + + distance = float(offset) + level * pitch[axis] + if axis == "bottom": + text_at = (center, y0 - distance) + elif axis == "left": + text_at = (x0 - distance, center) + else: + text_at = (x1 + distance, center) + placed.append(intent._replace(text_at=text_at)) + + return tuple(placed) diff --git a/tests/test_mech_dimension.py b/tests/test_mech_dimension.py new file mode 100644 index 0000000..298508e --- /dev/null +++ b/tests/test_mech_dimension.py @@ -0,0 +1,276 @@ +"""Dimension intents, ISO 286 fits, the hole table and the overlap-free layout. + +The layout test is the one that matters: it asserts the placed text rectangles +are pairwise disjoint, computed by the same helper the layout uses, so "no +overlapping dimension text" is a measured property rather than a claim. +""" + +from __future__ import annotations + +import itertools + +import pytest + +from engineering.mech.dimension import ( + DIM_STYLES, + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + measured_value, + resolve_tolerance, + text_rects, +) +from engineering.mech.features import Hole, Keyway +from engineering.mech.part import PrismaticPart, RevolvedPart, Segment +from engineering.mech.primitives import DimIntent +from engineering.mech.views import build_view + + +def shaft(*features) -> RevolvedPart: + return RevolvedPart( + name="shaft", + segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)), + features=tuple(features), + ) + + +def plate(*holes) -> 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(holes), + ) + + +def grid(count: int, diameter: float = 6.0): + return tuple(Hole(id=f"h{i}", x=5.0 + 7.0 * i, y=25.0, diameter=diameter) for i in range(count)) + + +def test_the_three_styles_are_the_ones_the_spec_names(): + assert DIM_STYLES == ("chain", "baseline", "ordinate") + + +def test_measured_value_and_text_follow_the_intent(): + linear = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)) + assert measured_value(linear) == 20.0 + assert intent_text(linear) == "20" + override = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), text_override="M20") + assert measured_value(override) == 40.0 + assert intent_text(override) == "M20" + + +# -- ISO 286 fits and ISO 129 tolerances ------------------------------------- + + +def test_a_h7_fit_on_a_40_mm_bore_resolves_to_plus_25_microns_over_zero(): + intent = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), fit="H7") + resolved = resolve_tolerance(intent) + assert resolved["tol_upper"] == pytest.approx(0.025) + assert resolved["tol_lower"] == pytest.approx(0.0) + assert resolved["tol_mode"] == "deviation" + assert resolved["text_override"] == "<> H7" + + +def test_a_shaft_fit_keeps_the_build_dim_override_sign_convention(): + intent = DimIntent(kind="diameter", p1=(-10.0, 0.0), p2=(10.0, 0.0), fit="g6") + resolved = resolve_tolerance(intent) + # ISO 286 g6 at 20 mm: es = -0.007, ei = -0.020; build_dim_override wants the + # lower deviation as a positive magnitude of the minus side. + assert resolved["tol_upper"] == pytest.approx(-0.007) + assert resolved["tol_lower"] == pytest.approx(0.020) + + +def test_an_explicit_tolerance_passes_through_and_is_validated(): + intent = DimIntent( + kind="linear", + p1=(0.0, 0.0), + p2=(30.0, 0.0), + tol={"mode": "symmetric", "upper": 0.1}, + ) + resolved = resolve_tolerance(intent) + assert resolved["tol_mode"] == "symmetric" + assert resolved["tol_upper"] == pytest.approx(0.1) + bad = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "loose"}) + with pytest.raises(ValueError, match="tol_mode"): + resolve_tolerance(bad) + + +def test_a_fit_and_an_explicit_tolerance_together_are_refused(): + intent = DimIntent( + kind="diameter", + p1=(-20.0, 0.0), + p2=(20.0, 0.0), + fit="H7", + tol={"mode": "symmetric", "upper": 0.1}, + ) + with pytest.raises(ValueError, match="not both"): + resolve_tolerance(intent) + + +def test_a_fit_outside_the_iso_286_tables_is_refused_by_name(): + intent = DimIntent(kind="diameter", p1=(-400.0, 0.0), p2=(400.0, 0.0), fit="H7") + with pytest.raises(ValueError, match="ISO 286"): + resolve_tolerance(intent) + + +def test_a_fit_with_no_measurable_nominal_is_refused_rather_than_guessed(): + intent = DimIntent(kind="diameter", p1=(5.0, 0.0), p2=(5.0, 0.0), fit="H7") + with pytest.raises(ValueError, match="nominal"): + resolve_tolerance(intent) + + +# -- collecting the intents -------------------------------------------------- + + +def test_the_front_view_of_a_shaft_gets_its_overall_length_and_each_diameter_once(): + view = build_view(shaft(), "front") + intents = dimension_intents(shaft(), view) + lengths = [i for i in intents if i.kind == "linear"] + diameters = [i for i in intents if i.kind == "diameter"] + assert any(measured_value(i) == 75.0 for i in lengths) + assert sorted({round(measured_value(i), 6) for i in diameters}) == [20.0, 25.0, 30.0] + + +def test_chain_and_baseline_place_the_same_stations_differently(): + part = shaft() + view = build_view(part, "front") + chain = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"] + baseline = [i for i in dimension_intents(part, view, style="baseline") if i.kind == "linear"] + assert {round(measured_value(i), 6) for i in chain} >= {20.0, 40.0, 15.0} + assert {round(measured_value(i), 6) for i in baseline} >= {20.0, 60.0, 75.0} + + +def test_ordinate_measures_every_station_from_the_origin(): + part = shaft() + view = build_view(part, "front") + intents = [i for i in dimension_intents(part, view, style="ordinate") if i.kind == "linear"] + assert all(i.p1[0] == 0.0 for i in intents) + + +def test_a_feature_intent_survives_collection_and_keeps_its_feature_id(): + part = shaft(Keyway(id="kw", segment=1, length=20.0)) + view = build_view(part, "front") + intents = dimension_intents(part, view) + assert any(i.feature == "kw" for i in intents) + + +def test_iso129_the_same_measurement_is_not_dimensioned_twice(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view) + keys = [ + (i.kind, round(i.p1[0], 6), round(i.p1[1], 6), round(i.p2[0], 6), round(i.p2[1], 6)) + for i in intents + ] + assert len(keys) == len(set(keys)) + + +def test_a_prismatic_front_view_gets_its_two_overall_sizes(): + view = build_view(plate(), "front") + intents = dimension_intents(plate(), view) + assert sorted({round(measured_value(i), 6) for i in intents if i.kind == "linear"}) == [ + 50.0, + 80.0, + ] + + +# -- the hole table ---------------------------------------------------------- + + +def test_under_the_threshold_the_holes_keep_their_own_diameters_and_there_is_no_table(): + part = plate(*grid(4)) + view = build_view(part, "front") + intents = dimension_intents(part, view, hole_table_threshold=8) + assert sum(1 for i in intents if i.kind == "diameter") == 4 + assert hole_table_rows(part, view) == () + + +def test_over_the_threshold_the_hole_diameters_move_into_a_table(): + part = plate(*grid(10)) + view = build_view(part, "front") + intents = dimension_intents(part, view, hole_table_threshold=8) + assert not any(i.kind == "diameter" for i in intents) + rows = hole_table_rows(part, view) + assert len(rows) == 10 + assert {row["tag"] for row in rows} == {"A"} + assert rows[0]["qty"] == 10 + assert rows[0]["d"] == 6.0 + assert set(rows[0]) == {"tag", "x", "y", "d", "qty", "note"} + + +def test_two_hole_sizes_get_two_tags(): + part = plate( + *grid(6), *(Hole(id=f"b{i}", x=5.0 + 9.0 * i, y=40.0, diameter=10.0) for i in range(4)) + ) + view = build_view(part, "front") + rows = hole_table_rows(part, view) + assert {row["tag"] for row in rows} == {"A", "B"} + by_tag = {row["tag"]: row["qty"] for row in rows} + assert by_tag == {"A": 6, "B": 4} + + +def test_a_tapped_hole_carries_its_designation_in_the_note(): + part = plate( + *(Hole(id=f"t{i}", x=5.0 + 9.0 * i, y=25.0, diameter=10.0, thread="M10") for i in range(9)) + ) + view = build_view(part, "front") + rows = hole_table_rows(part, view) + assert rows[0]["note"] == "M10" + + +# -- the layout, and its overlap proof --------------------------------------- + + +def test_the_layout_places_every_intent_and_the_text_rectangles_are_disjoint(): + part = shaft(Keyway(id="kw", segment=1, length=20.0)) + view = build_view(part, "front") + intents = dimension_intents(part, view) + placed = layout_dimensions(view, intents) + assert len(placed) == len(intents) + assert all(intent.text_at is not None for intent in placed) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}" + + +def test_a_crowded_prismatic_view_still_lays_out_disjointly(): + part = plate(*grid(6)) + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view, hole_table_threshold=100)) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}" + + +def test_horizontal_dimensions_stack_below_the_view_and_vertical_ones_to_its_left(): + part = shaft() + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=8.0) + horizontal = [i for i in placed if i.kind == "linear" and abs(i.p1[1] - i.p2[1]) < 1e-9] + assert horizontal and all(i.text_at[1] <= view.bbox[1] - 10.0 + 1e-9 for i in horizontal) + diameters = [i for i in placed if i.kind == "diameter"] + assert diameters and all(i.text_at[0] >= view.bbox[2] + 10.0 - 1e-9 for i in diameters) + + +def test_the_step_is_never_smaller_than_the_text_it_has_to_clear(): + part = shaft() + view = build_view(part, "front") + placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=1.0) + rects = text_rects(placed) + for a, b in itertools.combinations(rects, 2): + overlap_x = min(a[2], b[2]) - max(a[0], b[0]) + overlap_y = min(a[3], b[3]) - max(a[1], b[1]) + assert overlap_x <= 1e-9 or overlap_y <= 1e-9 + + +def test_an_unknown_style_is_refused(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="chain"): + dimension_intents(part, view, style="freehand") From bd73b3a854b1b2dd59948cf030f0ed7d3b836b07 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 23:04:25 +0300 Subject: [PATCH 09/11] fix(task-6): a reachable ISO 286 route, a tolerance schema that refuses, a reference dimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all root causes in `engineering/mech/dimension.py`. 1. The ISO 286 fit path was unreachable from the module's own pipeline. `resolve_tolerance` resolved `intent.fit` correctly, but nothing produced a DimIntent carrying one: no feature and no segment has a fit, and `dimension_intents` constructed every intent without `fit=`. Only a hand-built DimIntent - which is what the tests used - reached the code, so the spec's `mech_iso286_on_dimension` and the `mech_assembly` benchmark's "ISO 286 fits on the bearing seats" had no route. `dimension_intents(..., fits={key: code})` now stamps the code onto the intent its key names, and the part's own diameters became addressable one at a time through the new `segment_key(i, bore=...)` -> "segment[1]", "segment[1].bore"; a feature's stay addressable by id. A key that names nothing is refused with the names that exist, a malformed code is refused where it is attached rather than at draw time, a fit on a reference dimension or on one that already carries a tol is refused, and a fit on a hole whose diameter moved into the hole table (which has no tolerance column) is refused by name instead of vanishing. Task 7's planned `dimension_part(..., fits=None)` passes straight through - the argument is additive and keyword-only, so every call written against the pinned signature keeps working. The fit also now reaches the *text*: `intent_text` expands the `<>` placeholder and appends the code, so `text_rects` measures the string that is actually drawn and a hole that already names itself ("⌀6") keeps its own text and gains the code. 2. An explicit tolerance was silently discarded when `tol` omitted "mode". `mode = tol.get("mode", "none")` and `build_dim_override(0.05, 0.05, "none", None)` returns `({}, None)` without raising, so a ±0.05 resolved, echoed its own numbers back in the payload, and drew a dimension with no DIMTOL/DIMTP/DIMTM at all. `DimIntent.tol` now has a schema - {"mode", "upper", "lower"} and nothing else - validated by `_explicit_tol`: an unknown key, a missing "mode", a non-dict, and deviations under a mode that writes none are each refused by name. The mutual-exclusion guard reads `tol is not None` rather than truthiness, so `tol={}` is refused too. 3. The ISO 129-1 "no measurement twice" test was vacuous and the property was false. The old test built the key `_dedupe` uniquifies as the last step of `dimension_intents`, so it could not fail; meanwhile `style="chain"` emitted 20, 40, 15 *and* 75 as four plain DIMENSIONs. `redundant_linear` now finds the span that shorter ones tile end to end on the same axis and level, and `dimension_intents` marks it as an auxiliary (reference) dimension - the new `REFERENCE_FORMAT` "(<>)", which renders "(75)". The plan's own test still holds (the 75 is present); it is no longer a fourth plain dimension. The error is one-sided on purpose: a tiling that is found is real, a tiling the greedy walk misses leaves the dimension plain, which is the direction the repository's critique rule wants. Baseline and ordinate parenthesise nothing, and two horizontal chains at different heights are not each other's pieces. Tests: the vacuous gate is replaced by four that fail against the pre-fix code - `redundant_linear` on the shaft's plain intents returns (3,) there and () here; the parametrised "a tolerance is either refused or reaches the dimension variables" gate resolves the mode-less shape to an empty DIMVAR override there; and `fits=` is a TypeError there. `_dedupe` is now exercised through a duplicate fed in via the view's own dims, which the collector does not generate itself. 46 tests in tests/test_mech_dimension.py (was 23). Live AutoCAD: no finding was measured live and no new ActiveX call was written - this module is pure Python with no backend, no async and no DXF. A DIMTOL probe against the running seat was attempted twice and `GetActiveObject("AutoCAD.Application")` blocked past the ~90 s contention window both times, so the live engine was unavailable; no scratch document was created (the probe never got the application object) and none was left open. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 10 ++ engineering/mech/dimension.py | 314 +++++++++++++++++++++++++++++++--- tests/test_mech_dimension.py | 250 ++++++++++++++++++++++++++- 3 files changed, 546 insertions(+), 28 deletions(-) diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index b58a1d7..da0eaa4 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -8,13 +8,18 @@ from __future__ import annotations from .dimension import ( DIM_STYLES, + REFERENCE_FORMAT, TEXT_WIDTH_FACTOR, + TOL_KEYS, dimension_intents, hole_table_rows, intent_text, + is_reference, layout_dimensions, measured_value, + redundant_linear, resolve_tolerance, + segment_key, text_rects, ) from .features import ( @@ -131,6 +136,7 @@ __all__ = [ "Prim", "PrismaticPart", "Pt", + "REFERENCE_FORMAT", "ROLES", "ROLE_LAYER", "RetainingGroove", @@ -141,6 +147,7 @@ __all__ = [ "Segment", "Slot", "TEXT_WIDTH_FACTOR", + "TOL_KEYS", "Text", "Thread", "Undercut", @@ -157,6 +164,7 @@ __all__ = [ "hole_table_rows", "inner_radius_at", "intent_text", + "is_reference", "layer_for", "layout_dimensions", "layout_origin", @@ -172,12 +180,14 @@ __all__ = [ "point_in_outline", "points_of", "radial_profile", + "redundant_linear", "resolve_tolerance", "rotate", "scale", "section_profile_prims", "section_view", "segment_bounds", + "segment_key", "silhouette_prims", "text_rects", "translate", diff --git a/engineering/mech/dimension.py b/engineering/mech/dimension.py index 41e29e0..4e532b1 100644 --- a/engineering/mech/dimension.py +++ b/engineering/mech/dimension.py @@ -22,13 +22,34 @@ The test asserts pairwise disjointness with the same helper, which makes "no overlapping dimension text" measured rather than claimed - and it is the same property the repository's `dim_overlap` critique focus checks on the finished drawing. + +Three rules the module enforces rather than assumes: + +*ISO 286 fits reach the pipeline.* :func:`dimension_intents` takes +``fits={key: code}`` and stamps the code onto the intent it names, so a bearing +seat gets its k6 without anyone hand-building a ``DimIntent``. The part's own +diameters are addressable by segment (``"segment[1]"``, ``"segment[1].bore"``); +a feature's are addressable by its id. An unknown key is refused by name - a fit +that lands nowhere is a manufacturing requirement lost in silence. + +*An explicit tolerance is validated as a whole.* ``DimIntent.tol`` is +``{"mode", "upper", "lower"}`` and nothing else. ``mode`` is required, because +``build_dim_override`` reads a missing mode as ``"none"`` and returns an empty +override: a +-0.05 with no ``mode`` used to resolve, report its own numbers back +and then draw a dimension carrying no tolerance at all. + +*ISO 129-1: each measurement appears once.* A closed chain plus the overall +states the overall twice. :func:`redundant_linear` finds the span that the +others tile end to end, and :func:`dimension_intents` marks it as an auxiliary +(reference) dimension - ``(<>)``, drawn in parentheses - rather than emitting a +fourth plain DIMENSION that contradicts nothing and adds nothing. """ from __future__ import annotations import math -from engineering.fits import fit_lookup +from engineering.fits import fit_lookup, parse_fit_code from engineering.mech.features import Hole from engineering.mech.part import ( PrismaticPart, @@ -38,7 +59,7 @@ from engineering.mech.part import ( segment_bounds, ) from engineering.mech.primitives import DimIntent -from engineering.tolerances import build_dim_override +from engineering.tolerances import TOL_MODES, build_dim_override _EPS = 1e-9 @@ -52,6 +73,18 @@ TEXT_WIDTH_FACTOR = 0.7 #: Padding added around a measured label, in text heights. TEXT_PADDING = 0.6 +#: How an auxiliary (reference) dimension is written - ISO 129-1 puts a +#: measurement that is already given elsewhere in parentheses. ``<>`` is the +#: DIMENSION text placeholder for the measured value, on both engines. +REFERENCE_FORMAT = "(<>)" + +#: The only keys ``DimIntent.tol`` accepts. +TOL_KEYS: frozenset[str] = frozenset({"mode", "upper", "lower"}) + +#: Tolerance modes that write no deviation, so carrying upper/lower with them +#: would silently discard the numbers. +_NO_DEVIATION_MODES: frozenset[str] = frozenset({"none", "basic"}) + def measured_value(intent: DimIntent) -> float: """What the dimension measures, in drawing units.""" @@ -66,10 +99,20 @@ def _format(value: float) -> str: return f"{round(value, 3):g}" -def intent_text(intent: DimIntent) -> str: - """The text a dimension will carry - the override, or the measurement.""" - if intent.text_override: - return intent.text_override +def _with_fit(text: str, fit: str | None) -> str: + """Append an ISO 286 code to a dimension's text, once. + + A feature that already names itself (a hole's ``⌀6``) keeps its own text and + gains the code; without this the deviations would be written and the code + the inspector reads would not be on the sheet. + """ + code = (fit or "").strip() + if not code or code in text: + return text + return f"{text} {code}" + + +def _measured_text(intent: DimIntent) -> str: if intent.kind == "diameter": return f"⌀{_format(measured_value(intent))}" if intent.kind == "radius": @@ -77,6 +120,28 @@ def intent_text(intent: DimIntent) -> str: return _format(measured_value(intent)) +def intent_text(intent: DimIntent) -> str: + """The text a dimension will carry, as the engine will render it. + + ``<>`` is the DIMENSION placeholder for the measured value, so an override + of ``"(<>)"`` reads ``"(75)"`` and a resolved fit's ``"<> H7"`` reads + ``"⌀40 H7"``. Expanding it here is what makes :func:`text_rects` measure the + string that is actually drawn - a label measured as ``"<> H7"`` and drawn as + ``"⌀40 H7"`` is a layout computed against the wrong width. + """ + measured = _measured_text(intent) + base = intent.text_override.replace("<>", measured) if intent.text_override else measured + # The fit code is appended to the text by `resolve_tolerance`; the rectangle + # has to reserve room for it before the layout runs. + return _with_fit(base, intent.fit) + + +def is_reference(intent: DimIntent) -> bool: + """True for an auxiliary (reference) dimension - ISO 129-1 parentheses.""" + override = intent.text_override or "" + return override.startswith("(") and override.endswith(")") + + def _text_size(intent: DimIntent, height: float) -> tuple[float, float]: """``(width, height)`` of the rectangle this intent's label occupies.""" label = intent_text(intent) @@ -104,6 +169,44 @@ def text_rects(intents, *, height: float = 3.5): # -- tolerances -------------------------------------------------------------- +def _explicit_tol(intent: DimIntent) -> tuple[str, float | None, float | None]: + """Validate ``intent.tol`` as a whole and return ``(mode, upper, lower)``. + + ``build_dim_override`` alone is not enough: it defaults a missing mode to + ``"none"`` and answers with an empty override, so ``{"upper": 0.05, + "lower": 0.05}`` used to resolve, echo its own numbers back and draw a + dimension with no DIMTOL/DIMTP/DIMTM at all. A tolerance that disappears + between the spec and the sheet is the silent wrong value this repository + refuses, so the schema is checked here, by name. + """ + who = intent.feature or intent.kind + tol = intent.tol + if not isinstance(tol, dict): + raise ValueError( + f"{who}.tol: expected a dict with keys {sorted(TOL_KEYS)}, got {type(tol).__name__}." + ) + unknown = set(tol) - TOL_KEYS + if unknown: + raise ValueError( + f"{who}.tol: unknown key(s) {sorted(unknown)}; a tolerance takes {sorted(TOL_KEYS)}." + ) + if "mode" not in tol: + raise ValueError( + f"{who}.tol: needs an explicit 'mode' (one of {list(TOL_MODES)}). Without it the " + "deviations are carried in the payload and no tolerance reaches the drawing." + ) + mode = str(tol["mode"]).strip().lower() + upper = tol.get("upper") + lower = tol.get("lower") + if mode in _NO_DEVIATION_MODES and (upper is not None or lower is not None): + raise ValueError( + f"{who}.tol: tol_mode={mode!r} writes no deviation, but upper={upper!r} / " + f"lower={lower!r} were given. Use 'symmetric', 'deviation' or 'limit', or drop " + "the values." + ) + return mode, upper, lower + + def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: """Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments. @@ -111,7 +214,7 @@ def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: ``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus deviation, so a resolved fit passes ``-deviation.lower_mm``. """ - has_tol = bool(intent.tol) + has_tol = intent.tol is not None if intent.fit and has_tol: raise ValueError( f"{intent.feature or intent.kind}: pass either fit= or an explicit " @@ -129,13 +232,10 @@ def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: "tol_upper": deviation.upper_mm, "tol_lower": -deviation.lower_mm, "tol_mode": "deviation", - "text_override": intent.text_override or f"<> {deviation.code}", + "text_override": _with_fit(intent.text_override or "<>", deviation.code), } if has_tol: - tol = dict(intent.tol) - mode = str(tol.get("mode", "none")) - upper = tol.get("upper") - lower = tol.get("lower") + mode, upper, lower = _explicit_tol(intent) build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises return { "tol_upper": upper, @@ -172,6 +272,129 @@ def _dedupe(intents) -> tuple[DimIntent, ...]: return tuple(out) +def _linear_span(intent: DimIntent) -> tuple[str, float, float, float] | None: + """``(axis, level, lo, hi)`` of an axis-parallel linear dimension, else None. + + ``level`` is the coordinate the dimension does *not* run along, so two + horizontal dimensions at different heights are never compared with each + other. A skewed linear intent has no span: it is not part of a chain. + """ + if intent.kind != "linear": + return None + (ax, ay), (bx, by) = intent.p1, intent.p2 + if abs(bx - ax) >= abs(by - ay): + if abs(by - ay) > _EPS: + return None + return ("x", round(float(ay), 6), round(min(ax, bx), 6), round(max(ax, bx), 6)) + if abs(bx - ax) > _EPS: + return None + return ("y", round(float(ax), 6), round(min(ay, by), 6), round(max(ay, by), 6)) + + +def redundant_linear(intents) -> tuple[int, ...]: + """Indices of the linear intents that shorter ones already tile end to end. + + ISO 129-1: a measurement appears once. A closed chain (20 + 40 + 15) that + also carries the overall (75) states the overall twice, and the second + statement has to be an auxiliary (reference) dimension or be left off. + + The error is one-sided on purpose. A tiling that is found is a real one - + the pieces butt, in order, from ``lo`` to ``hi`` on the same axis and level + - so a dimension is never called redundant when it is not. A tiling that + the greedy walk misses simply leaves the dimension plain, which is the way + round the repository's critique rule wants: a check that misses a bad + drawing costs a review, a check that calls a good drawing bad gets + switched off. + """ + groups: dict[tuple[str, float], list[tuple[float, float, int]]] = {} + for index, intent in enumerate(intents): + span = _linear_span(intent) + if span is None: + continue + axis, level, lo, hi = span + groups.setdefault((axis, level), []).append((lo, hi, index)) + + out: list[int] = [] + for group in groups.values(): + for lo, hi, index in group: + pieces = sorted( + (a, b) + for a, b, other in group + if other != index + and a >= lo - 1e-6 + and b <= hi + 1e-6 + and (b - a) < (hi - lo) - 1e-6 + ) + cursor = lo + for a, b in pieces: + if abs(a - cursor) <= 1e-6: + cursor = b + if cursor > lo + 1e-6 and abs(cursor - hi) <= 1e-6: + out.append(index) + return tuple(sorted(out)) + + +def _mark_references(intents: list[DimIntent]) -> list[DimIntent]: + """Put the redundant measurements in parentheses, ISO 129-1.""" + for index in redundant_linear(intents): + intent = intents[index] + if intent.text_override is None: + intents[index] = intent._replace(text_override=REFERENCE_FORMAT) + return intents + + +def _apply_fits(intents: list[DimIntent], fits, *, table_ids: frozenset[str]) -> list[DimIntent]: + """Stamp each ``{key: ISO 286 code}`` onto the intent its key names. + + Without this the ``fit`` branch of :func:`resolve_tolerance` is reachable + only from a hand-built ``DimIntent``: no feature and no segment carries a + fit, so the ISO 286 path would be real in the table and absent from every + drawing the module actually produces. + """ + if not fits: + return intents + if not isinstance(fits, dict): + raise ValueError( + f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}." + ) + + available = {intent.feature for intent in intents if intent.feature} + by_key: dict[str, list[int]] = {} + for index, intent in enumerate(intents): + if intent.feature: + by_key.setdefault(intent.feature, []).append(index) + + for key, code in fits.items(): + name = str(key) + if name not in available: + if name in table_ids: + raise ValueError( + f"fits[{name!r}]: that hole's diameter moved into the hole table, which has " + "no tolerance column. Raise hole_table_threshold, or dimension the hole " + "directly." + ) + raise ValueError( + f"fits[{name!r}]: nothing in this view is dimensioned under that name. " + f"Available: {sorted(available)}." + ) + parse_fit_code(str(code)) # refuses a typo here, not at draw time + for index in by_key[name]: + intent = intents[index] + if is_reference(intent): + raise ValueError( + f"fits[{name!r}]: {intent_text(intent)} is an auxiliary (reference) " + "dimension - ISO 129-1 gives it no tolerance. Put the fit on the " + "dimension that states the size." + ) + if intent.tol is not None: + raise ValueError( + f"fits[{name!r}]: that dimension already carries an explicit tol. Pass " + "either fit= or an explicit tol, not both." + ) + intents[index] = intent._replace(fit=str(code)) + return intents + + def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]: stations = [0.0] + [x1 for _, x1 in segment_bounds(part)] intents: list[DimIntent] = [] @@ -189,18 +412,39 @@ def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]: return intents +def segment_key(index: int, *, bore: bool = False) -> str: + """The ``fits`` key that names one segment's outer diameter, or its bore. + + The part's own diameters have to be addressable one at a time: "ISO 286 + fits on the bearing seats" means a code on *segment 1*, not on every + dimension the part emits. + """ + return f"segment[{int(index)}].bore" if bore else f"segment[{int(index)}]" + + def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]: intents: list[DimIntent] = [] - for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True): + bounds = zip(segment_bounds(part), part.segments, strict=True) + for index, ((x0, x1), segment) in enumerate(bounds): mid = (x0 + x1) / 2.0 radius = segment.d_outer / 2.0 intents.append( - DimIntent(kind="diameter", p1=(mid, radius), p2=(mid, -radius), feature="part") + DimIntent( + kind="diameter", + p1=(mid, radius), + p2=(mid, -radius), + feature=segment_key(index), + ) ) if segment.d_inner > _EPS: bore = segment.d_inner / 2.0 intents.append( - DimIntent(kind="diameter", p1=(mid, bore), p2=(mid, -bore), feature="part") + DimIntent( + kind="diameter", + p1=(mid, bore), + p2=(mid, -bore), + feature=segment_key(index, bore=True), + ) ) return intents @@ -226,8 +470,21 @@ def dimension_intents( *, style: str = "chain", hole_table_threshold: int = 8, + fits: dict | None = None, ) -> tuple[DimIntent, ...]: - """Every dimension this view wants: the features' own, plus the part's sizes.""" + """Every dimension this view wants: the features' own, plus the part's sizes. + + ``fits`` maps a dimension's name to an ISO 286 code - a feature id, or one + of the part's own diameters by :func:`segment_key` (``"segment[1]"``, + ``"segment[1].bore"``). The code is stamped onto the matching intent, so + :func:`resolve_tolerance` resolves it and :func:`layout_dimensions` reserves + room for the widened text. A key that names nothing is refused with the + names that exist. + + ISO 129-1 is applied last: a measurement the other dimensions already tile + end to end is marked as an auxiliary (reference) dimension, in parentheses, + instead of being drawn a second time as a plain DIMENSION. + """ name = str(style or "").strip().lower() if name not in DIM_STYLES: raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.") @@ -240,15 +497,26 @@ def dimension_intents( intents.extend(_revolved_axial(part, name)) intents.extend(_revolved_diameters(part)) elif kind == "side": - near = part.segments[-1] + last = len(part.segments) - 1 + near = part.segments[last] radius = near.d_outer / 2.0 intents.append( - DimIntent(kind="diameter", p1=(-radius, 0.0), p2=(radius, 0.0), feature="part") + DimIntent( + kind="diameter", + p1=(-radius, 0.0), + p2=(radius, 0.0), + feature=segment_key(last), + ) ) if near.d_inner > _EPS: bore = near.d_inner / 2.0 intents.append( - DimIntent(kind="diameter", p1=(-bore, 0.0), p2=(bore, 0.0), feature="part") + DimIntent( + kind="diameter", + p1=(-bore, 0.0), + p2=(bore, 0.0), + feature=segment_key(last, bore=True), + ) ) elif isinstance(part, PrismaticPart): x0, y0, x1, y1 = outline_bbox(part) @@ -270,11 +538,13 @@ def dimension_intents( groups = _hole_groups(part) total_holes = sum(len(holes) for holes in groups.values()) + table_ids: frozenset[str] = frozenset() if total_holes > int(hole_table_threshold): - hole_ids = {hole.id for holes in groups.values() for hole in holes} - intents = [i for i in intents if i.feature not in hole_ids] + table_ids = frozenset(hole.id for holes in groups.values() for hole in holes) + intents = [i for i in intents if i.feature not in table_ids] - return _dedupe(intents) + collected = _mark_references(list(_dedupe(intents))) + return tuple(_apply_fits(collected, fits, table_ids=table_ids)) def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]: diff --git a/tests/test_mech_dimension.py b/tests/test_mech_dimension.py index 298508e..23ee80d 100644 --- a/tests/test_mech_dimension.py +++ b/tests/test_mech_dimension.py @@ -7,6 +7,7 @@ overlapping dimension text" is a measured property rather than a claim. from __future__ import annotations +import dataclasses import itertools import pytest @@ -16,15 +17,19 @@ from engineering.mech.dimension import ( dimension_intents, hole_table_rows, intent_text, + is_reference, layout_dimensions, measured_value, + redundant_linear, resolve_tolerance, + segment_key, text_rects, ) from engineering.mech.features import Hole, Keyway from engineering.mech.part import PrismaticPart, RevolvedPart, Segment from engineering.mech.primitives import DimIntent from engineering.mech.views import build_view +from engineering.tolerances import build_dim_override def shaft(*features) -> RevolvedPart: @@ -121,6 +126,175 @@ def test_a_fit_with_no_measurable_nominal_is_refused_rather_than_guessed(): resolve_tolerance(intent) +@pytest.mark.parametrize( + "tol", + [ + {"mode": "symmetric", "upper": 0.05}, + {"mode": "deviation", "upper": 0.05, "lower": 0.02}, + {"mode": "limit", "upper": 30.05, "lower": 29.98}, + {"upper": 0.05, "lower": 0.05}, # the shape whose tolerance used to vanish + {"mode": "none", "upper": 0.05}, + {}, + ], +) +def test_a_tolerance_is_either_refused_or_reaches_the_dimension_variables(tol): + """Accepted and then written as nothing is the one answer that is not allowed. + + `build_dim_override` defaults a missing mode to "none" and answers with an + empty override, so a tol with no mode used to resolve, echo its own numbers + back and draw a dimension carrying no DIMTOL/DIMTP/DIMTM at all. + """ + intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=tol) + try: + resolved = resolve_tolerance(intent) + except ValueError: + return # refused up front, which is the other legal answer + override, _ = build_dim_override( + resolved["tol_upper"], + resolved["tol_lower"], + resolved["tol_mode"], + resolved["text_override"], + ) + assert override, f"{tol} resolved to {resolved} and wrote no dimension variable" + + +def test_a_tolerance_with_no_mode_is_refused_rather_than_silently_discarded(): + intent = DimIntent( + kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"upper": 0.05, "lower": 0.05} + ) + with pytest.raises(ValueError, match="needs an explicit 'mode'"): + resolve_tolerance(intent) + + +def test_an_empty_tol_is_refused_rather_than_read_as_no_tolerance(): + intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={}) + with pytest.raises(ValueError, match="mode"): + resolve_tolerance(intent) + + +def test_a_tol_key_outside_the_schema_is_refused_by_name(): + intent = DimIntent( + kind="linear", + p1=(0.0, 0.0), + p2=(30.0, 0.0), + tol={"mode": "symmetric", "upper": 0.05, "plus": 0.05}, + ) + with pytest.raises(ValueError, match="plus"): + resolve_tolerance(intent) + + +def test_deviations_under_a_mode_that_writes_none_are_refused(): + intent = DimIntent( + kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "none", "upper": 0.05} + ) + with pytest.raises(ValueError, match="writes no deviation"): + resolve_tolerance(intent) + + +def test_a_tol_that_is_not_a_dict_is_refused(): + intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=0.05) + with pytest.raises(ValueError, match="expected a dict"): + resolve_tolerance(intent) + + +# -- the ISO 286 route from the part to the dimension ------------------------ + + +def bush() -> RevolvedPart: + """A 60 mm bore housing: one segment, ⌀60 outside, ⌀40 bore.""" + return RevolvedPart(name="bush", segments=(Segment(60.0, 60.0, 40.0),)) + + +def test_a_fit_reaches_a_dimension_through_the_modules_own_pipeline(): + """`mech_iso286_on_dimension`, end to end: a ⌀40 H7 bore carries +0.025 / 0. + + `resolve_tolerance` always resolved a fit; nothing produced an intent that + carried one, so the headline claim was reachable only from a hand-built + DimIntent - which is what the tests used. + """ + part = bush() + view = build_view(part, "front") + intents = dimension_intents(part, view, fits={segment_key(0, bore=True): "H7"}) + fitted = [i for i in intents if i.fit] + assert len(fitted) == 1 + assert measured_value(fitted[0]) == pytest.approx(40.0) + resolved = resolve_tolerance(fitted[0]) + assert resolved["tol_upper"] == pytest.approx(0.025) + assert resolved["tol_lower"] == pytest.approx(0.0) + assert resolved["tol_mode"] == "deviation" + assert resolved["text_override"] == "<> H7" + # the fit has to survive the layout, or the number is right in the table + # and absent from the sheet + placed = layout_dimensions(view, intents) + assert [i.fit for i in placed if i.fit] == ["H7"] + + +def test_a_bearing_seat_takes_its_fit_by_segment(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view, fits={segment_key(1): "k6"}) + seat = [i for i in intents if i.fit] + assert [i.feature for i in seat] == ["segment[1]"] + assert measured_value(seat[0]) == pytest.approx(30.0) + assert intent_text(seat[0]) == "⌀30 k6" + + +def test_a_fit_on_a_feature_id_lands_on_that_features_dimension(): + part = plate(*grid(3)) + view = build_view(part, "front") + intents = dimension_intents(part, view, fits={"h1": "H7"}) + assert [i.feature for i in intents if i.fit] == ["h1"] + # a hole already names itself; the code is appended, never swallowed + hole = next(i for i in intents if i.fit) + assert intent_text(hole) == "⌀6 H7" + assert resolve_tolerance(hole)["text_override"] == "⌀6 H7" + + +def test_a_fit_widens_the_measured_label_so_the_layout_reserves_room(): + part = shaft() + view = build_view(part, "front") + plain = next(i for i in dimension_intents(part, view) if i.feature == segment_key(1)) + fitted = next( + i + for i in dimension_intents(part, view, fits={segment_key(1): "k6"}) + if i.feature == segment_key(1) + ) + + def width(intent): + rect = text_rects(layout_dimensions(view, [intent]))[0] + return rect[2] - rect[0] + + assert width(fitted) > width(plain) + + +def test_a_fit_key_that_names_nothing_is_refused_with_the_names_that_exist(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match=r"segment\[0\]"): + dimension_intents(part, view, fits={"bearing_seat": "k6"}) + + +def test_a_malformed_fit_code_is_refused_where_it_is_attached(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="fit code"): + dimension_intents(part, view, fits={segment_key(1): "H"}) + + +def test_a_fit_on_a_reference_dimension_is_refused(): + part = shaft() + view = build_view(part, "front") + with pytest.raises(ValueError, match="reference"): + dimension_intents(part, view, fits={"part": "H7"}) + + +def test_a_fit_on_a_hole_that_moved_into_the_table_is_refused_by_name(): + part = plate(*grid(10)) + view = build_view(part, "front") + with pytest.raises(ValueError, match="hole table"): + dimension_intents(part, view, fits={"h1": "H7"}, hole_table_threshold=8) + + # -- collecting the intents -------------------------------------------------- @@ -156,15 +330,79 @@ def test_a_feature_intent_survives_collection_and_keeps_its_feature_id(): assert any(i.feature == "kw" for i in intents) -def test_iso129_the_same_measurement_is_not_dimensioned_twice(): +def test_a_duplicate_intent_is_collapsed_rather_than_drawn_twice(): + """`_dedupe` against an intent the collector did not generate itself. + + Asserting uniqueness over what `dimension_intents` returns proves nothing - + it is the key `_dedupe` uniquifies. Feeding the duplicate in through the + view's own dims is what exercises the gate. + """ part = shaft() view = build_view(part, "front") - intents = dimension_intents(part, view) - keys = [ - (i.kind, round(i.p1[0], 6), round(i.p1[1], 6), round(i.p2[0], 6), round(i.p2[1], 6)) - for i in intents + once = dimension_intents(part, view) + repeated = next(i for i in once if i.kind == "diameter") + doubled = dataclasses.replace(view, dims=view.dims + (repeated._replace(text_at=None),)) + assert len(dimension_intents(part, doubled)) == len(once) + + +def test_iso129_a_closed_chain_marks_the_overall_as_a_reference_dimension(): + """20 + 40 + 15 and 75 state the same distance twice; ISO 129-1 parenthesises one. + + The plan's own test asserts the 75 is present, which a bare fourth plain + DIMENSION satisfies. What matters is that it is drawn as an auxiliary + (reference) dimension. + """ + part = shaft() + view = build_view(part, "front") + linear = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"] + assert sorted(round(measured_value(i), 6) for i in linear) == [15.0, 20.0, 40.0, 75.0] + overall = [i for i in linear if measured_value(i) == 75.0] + assert len(overall) == 1 + assert is_reference(overall[0]) + assert intent_text(overall[0]) == "(75)" + assert [i for i in linear if is_reference(i)] == overall + + +def test_iso129_no_plain_dimension_restates_a_measurement_the_others_give(): + """The real property, on every style and both part kinds.""" + parts = (shaft(Keyway(id="kw", segment=1, length=20.0)), plate(*grid(4))) + for style in DIM_STYLES: + for part in parts: + view = build_view(part, "front") + intents = dimension_intents(part, view, style=style) + plain = [i for i in intents if not is_reference(i)] + assert redundant_linear(plain) == (), f"{part.name} / {style}" + + +def test_the_redundancy_check_fires_instead_of_restating_its_own_post_condition(): + """A gate that cannot fail is not a gate: hand it the un-marked chain.""" + chain = [ + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)), + DimIntent(kind="linear", p1=(20.0, 0.0), p2=(60.0, 0.0)), + DimIntent(kind="linear", p1=(60.0, 0.0), p2=(75.0, 0.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)), ] - assert len(keys) == len(set(keys)) + assert redundant_linear(chain) == (3,) + baseline = [ + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(60.0, 0.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)), + ] + assert redundant_linear(baseline) == () + # two horizontal chains at different heights measure different things + stacked = [ + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)), + DimIntent(kind="linear", p1=(20.0, 5.0), p2=(75.0, 5.0)), + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)), + ] + assert redundant_linear(stacked) == () + + +def test_baseline_states_every_station_once_so_nothing_is_parenthesised(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view, style="baseline") + assert not any(is_reference(i) for i in intents) def test_a_prismatic_front_view_gets_its_two_overall_sizes(): From 8b14d4ef8df5e59e543d06d8e0311622ac384237 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 23:17:29 +0300 Subject: [PATCH 10/11] feat(task-7): SECTION 21 - the mechanical part model as six tools engineering/mech/draw.py is the only module in the package that touches a backend: it turns the pure engine's primitive descriptions into entity_create_* and dimension_* calls, places them in WCS, and writes the part model back onto the drawing as ACADMCP_MECH XDATA on an anchor POINT. It is not a contract member and neither are the tools - exactly like gear_draw_* and titleblock_apply_iso_a3, it composes the generic members, so it is written once and works on both engines by construction and no capability key is needed. No new ActiveX call was written, so there was nothing to probe on the live seat; measured instead that every one of the 23 backend members draw.py calls exists on ComBackend and that ComBackend has no abstract methods left. mech_part_draw takes one model - a turned profile or a plate outline plus typed features - and draws its views in first-angle positions (third on request), dimensions them and records the placement. mech_view_add reads the model back out of the drawing and adds a side, top, section (full/half/offset/revolved) or detail view months later without the caller re-describing the part. mech_dimension_part lays out ISO 129 dimensions with no overlapping text, resolves ISO 286 fits against the measured nominal, and moves the holes into a table past a threshold. mech_hole_pattern exposes the same expansion for a drawing that was never built from the model - with its own diameter guard, because Hole.validate needs a part and a standalone pattern has none. mech_part_inspect reads the payload back and never modifies the drawing. mech_part_from_spec draws a whole sheet inside one transaction. Every part is validated before the transaction opens, so a refusal leaves the drawing untouched and names the path (parts[1]: segments[0].d_outer: ...), and the rollback is shielded with the shape engineering/pid/spec.py documents: anyio cancellation is level-triggered, so a bare await in the handler would be cancelled before the rollback body ran and leave half a sheet inside an open transaction. fits goes through dimension_intents(fits=...), the API Task 6 shipped, rather than being stamped on at draw time: that is the path that validates a key by name and refuses a fit on a reference dimension or on a hole that moved into the table. It is scoped per view first - a fit on segment[0] names nothing in the end view, and refusing there would make a correct three-view sheet undrawable - and any key no view claimed is handed back to dimension_intents so the refusal is written in its own vocabulary. SECTION 21 registers the six tools in the new mech pack, with alias records (SECTIONPLANE, ARRAY plus shop-floor synonyms in English and Turkish - mil, kademeli mil, kama, pah, kesit, gorunus), a regenerated tool inventory, the README release snapshot at 210 tools in 24 groups, and the frozen group-size snapshot gaining mech: 6. Lean is deliberately untouched here: spec section 11 puts three of these on the lean surface together with group G's two sheet tools, and Task 15 makes that edit once across all four branches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 6 +- discovery/aliases.py | 90 ++++ docs/tool-inventory.json | 89 +++- engineering/mech/__init__.py | 18 + engineering/mech/draw.py | 804 +++++++++++++++++++++++++++++++++++ server.py | 412 +++++++++++++++++- tests/test_mech_draw.py | 283 ++++++++++++ tests/test_tool_packs.py | 19 +- tests/test_tool_registry.py | 14 +- 9 files changed, 1722 insertions(+), 13 deletions(-) create mode 100644 engineering/mech/draw.py create mode 100644 tests/test_mech_draw.py diff --git a/README.md b/README.md index 6d056a1..a632df5 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests. -> 204 is the **registered** count; a default install advertises 199 over `tools/list`, +> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests. +> 210 is the **registered** count; a default install advertises 205 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. ## Why this exists @@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -204 tools in 23 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. +210 tools in 24 groups; TOOL_PACKS=core hides the nine pid_* tools and the 22 environment tools (TOOL_PACKS=core,settings keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates. **Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy. diff --git a/discovery/aliases.py b/discovery/aliases.py index f139c03..5f0df86 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1859,6 +1859,96 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "save a detail view", ), ), + # ── Mechanical parts (SECTION 21, tracks B+G group M) ─────────────────── + "mech_part_draw": ToolAliases( + acad=(), + synonyms=( + "draw a shaft", + "stepped shaft", + "draw a machine part", + "turned part", + "draw a plate", + "flange", + "part from a segment list", + "shaft with a keyway", + "mil", + "mil ciz", + "kademeli mil", + "kama", + "pah", + "plaka", + "flans", + ), + ), + "mech_view_add": ToolAliases( + acad=("SECTIONPLANE",), + synonyms=( + "add a section view", + "section a-a", + "cross section", + "half section", + "offset section", + "detail view", + "blown-up detail", + "end view", + "side view", + "another view of the part", + "kesit", + "kesit al", + "gorunus", + "yan gorunus", + "detay", + ), + ), + "mech_dimension_part": ToolAliases( + acad=(), + synonyms=( + "dimension the part", + "put the dimensions on", + "chain dimensions", + "baseline dimensions", + "ordinate dimensions", + "hole table", + "iso 286 fit on a dimension", + "h7 bore", + "olculendir", + "olcu ver", + "tolerans", + ), + ), + "mech_hole_pattern": ToolAliases( + acad=("ARRAY",), + synonyms=( + "bolt circle", + "hole pattern", + "hole array", + "pitch circle of holes", + "grid of holes", + "delik deseni", + "civata dairesi", + "delik aynasi", + ), + ), + "mech_part_from_spec": ToolAliases( + acad=(), + synonyms=( + "draw the whole sheet", + "several parts at once", + "mechanical sheet from a spec", + "batch draw parts", + "parca sayfasi", + ), + ), + "mech_part_inspect": ToolAliases( + acad=(), + synonyms=( + "what part is this", + "read the part model back", + "part features", + "which views were drawn", + "parca bilgisi", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index c5c16f1..55bb8ab 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,10 +3,10 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 204, + "tools": 210, "resources": 8, "prompts": 5, - "groups": 23 + "groups": 24 }, "groups": { "analysis": 12, @@ -22,6 +22,7 @@ "environment": 22, "layers": 14, "layouts": 12, + "mech": 6, "page_setup": 6, "pid": 9, "premium": 12, @@ -1968,6 +1969,90 @@ "readOnlyHint": false } }, + { + "name": "mech_dimension_part", + "group": "mech", + "cost": "mutate", + "summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.", + "tags": [ + "dimension", + "mech" + ], + "annotations": { + "title": "Mechanical: Dimension Part", + "destructiveHint": false + } + }, + { + "name": "mech_hole_pattern", + "group": "mech", + "cost": "mutate", + "summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Hole Pattern", + "destructiveHint": false + } + }, + { + "name": "mech_part_draw", + "group": "mech", + "cost": "mutate", + "summary": "Draw a machine part from a segment list or an outline plus typed features.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Draw Part", + "destructiveHint": false + } + }, + { + "name": "mech_part_from_spec", + "group": "mech", + "cost": "mutate", + "summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Sheet From Spec", + "destructiveHint": false + } + }, + { + "name": "mech_part_inspect", + "group": "mech", + "cost": "read", + "summary": "Read the part model back off the drawing: segments, features, views, omissions.", + "tags": [ + "mech", + "query" + ], + "annotations": { + "title": "Mechanical: Inspect Part", + "readOnlyHint": true + } + }, + { + "name": "mech_view_add", + "group": "mech", + "cost": "mutate", + "summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.", + "tags": [ + "create", + "mech" + ], + "annotations": { + "title": "Mechanical: Add View", + "destructiveHint": false + } + }, { "name": "mleaderstyle_create", "group": "styles", diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index da0eaa4..2afd332 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -22,6 +22,16 @@ from .dimension import ( segment_key, text_rects, ) +from .draw import ( + ANCHOR_LAYER, + add_view, + dimension_part, + draw_from_spec, + draw_hole_pattern, + draw_part, + draw_prims, + read_part, +) from .features import ( FEATURE_KINDS, AxialBore, @@ -105,6 +115,7 @@ from .views import ( ) __all__ = [ + "ANCHOR_LAYER", "Arc", "AxialBore", "Band", @@ -153,6 +164,7 @@ __all__ = [ "Undercut", "VIEW_KINDS", "View", + "add_view", "bbox", "build_part", "build_view", @@ -160,6 +172,11 @@ __all__ = [ "detail_marker_prims", "detail_view", "dimension_intents", + "dimension_part", + "draw_from_spec", + "draw_hole_pattern", + "draw_part", + "draw_prims", "feature_from_dict", "hole_table_rows", "inner_radius_at", @@ -180,6 +197,7 @@ __all__ = [ "point_in_outline", "points_of", "radial_profile", + "read_part", "redundant_linear", "resolve_tolerance", "rotate", diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py new file mode 100644 index 0000000..cb4a598 --- /dev/null +++ b/engineering/mech/draw.py @@ -0,0 +1,804 @@ +"""The only module in `engineering/mech/` that touches a backend. + +Everything above it is pure: the part model, the features, the view engine and +the dimension layout take numbers and return primitive *descriptions*. This +module turns those descriptions into `entity_create_*` / `dimension_*` calls, +places them in WCS, and writes the part model back onto the drawing as +`ACADMCP_MECH` XDATA so `mech_view_add` can add a view months later without the +caller re-describing the part. + +It is not a contract member and neither are the tools above it - exactly like +`gear_draw_*`, `keyway_draw_*` and `titleblock_apply_iso_a3`, it composes the +generic members, so it is written once and works on both engines by +construction. +""" + +from __future__ import annotations + +import logging +import math +from typing import Any + +import anyio + +from engineering.mech.dimension import ( + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + resolve_tolerance, +) +from engineering.mech.features import HolePattern +from engineering.mech.part import build_part, part_to_dict, validate_part +from engineering.mech.primitives import ( + ROLE_LAYER, + Arc, + Circle, + DimIntent, + HatchArea, + Line, + Poly, + Text, + layer_for, + rotate, + translate, +) +from engineering.mech.standards.materials import hatch_for +from engineering.mech.views import ( + View, + build_view, + detail_marker_prims, + layout_origin, + normalise_plane, +) +from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values + +log = logging.getLogger(__name__) + +#: The layer the invisible per-part anchor POINT lives on. It carries the +#: ACADMCP_MECH payload and nothing else, so `read_part` can find every part in +#: a drawing with one filtered `entity_list`. +ANCHOR_LAYER = "MECH" + +DEFAULT_GAP = 20.0 + +#: How far a standalone hole pattern's ISO 128-23 centre mark reaches past the +#: hole it marks, in drawing units. DECLARED, not standard: ISO 128-23 fixes +#: that the mark overruns the circle, not by how much. +CENTRE_MARK_OVERRUN = 3.0 + + +# -- the XDATA adapter ------------------------------------------------------- +# +# `engineering/mech/xdata.encode` returns DXF (group code, value) rows, which is +# the on-disk shape. `entity_set_xdata` takes plain JSON values and assigns the +# codes itself through backends/xdata_specs.py, so the 1001 APPID row is the +# backend's business and only the 1000 chunks travel. `to_values(payload)` is +# that projection and it lives in `engineering/mech/xdata.py` (Task 1) - every +# writer in this repository calls it rather than re-deriving the split, so a +# change to the chunking is made in one place. + + +def _xdata_payload(values) -> dict: + return decode(values) + + +def _number(value, where: str, *, positive: bool = True) -> float: + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"{where}: expected a number, got {value!r}.") from None + if not math.isfinite(number): + raise ValueError(f"{where}: must be finite, got {value!r}.") + if positive and number <= 0.0: + raise ValueError(f"{where}: must be greater than zero, got {number:g}.") + return number + + +# -- primitives to entities -------------------------------------------------- + + +async def draw_prims( + backend, + prims, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + layer_prefix: str = "", +) -> dict: + """Draw primitive descriptions, placed at ``at`` and turned by ``rotation``.""" + placed = tuple(prims) + if abs(float(rotation)) > 1e-12: + placed = rotate(placed, float(rotation), (0.0, 0.0)) + if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12: + placed = translate(placed, float(at[0]), float(at[1])) + + handles: list[str] = [] + counts: dict[str, int] = {} + + for prim in placed: + layer = f"{layer_prefix}{layer_for(prim)}" + if isinstance(prim, Line): + info = await backend.entity_create_line( + prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer + ) + elif isinstance(prim, Arc): + info = await backend.entity_create_arc( + prim.center[0], + prim.center[1], + prim.radius, + prim.start_deg, + prim.end_deg, + layer=layer, + ) + elif isinstance(prim, Circle): + info = await backend.entity_create_circle( + prim.center[0], prim.center[1], prim.radius, layer=layer + ) + elif isinstance(prim, Poly): + info = await backend.entity_create_polyline( + [[x, y] for x, y in prim.points], prim.closed, layer=layer + ) + elif isinstance(prim, Text): + info = await backend.entity_create_text( + prim.text, + prim.at[0], + prim.at[1], + prim.height, + prim.rotation, + layer=layer, + ) + elif isinstance(prim, HatchArea): + style = hatch_for(prim.material) + info = await backend.entity_create_hatch( + style["pattern"], + [[x, y] for x, y in prim.loops[0]], + style["scale"], + style["angle"], + layer=layer, + ) + for island in prim.loops[1:]: + points = list(island) + edges = [ + { + "type": "line", + "start": [points[i][0], points[i][1]], + "end": [points[(i + 1) % len(points)][0], points[(i + 1) % len(points)][1]], + } + for i in range(len(points)) + ] + await backend.hatch_add_boundary(info.handle, edges) + else: # pragma: no cover - the Prim union is closed + raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.") + handles.append(info.handle) + key = type(prim).__name__.lower() + counts[key] = counts.get(key, 0) + 1 + + return {"handles": handles, "counts": counts} + + +# -- dimensions -------------------------------------------------------------- + + +async def _draw_dimension(backend, intent) -> tuple[str | None, dict]: + """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row).""" + resolved = resolve_tolerance(intent) + row = { + "feature": intent.feature, + "kind": intent.kind, + "text": intent_text(intent), + "fit": intent.fit, + **resolved, + } + text_at = intent.text_at or ( + (intent.p1[0] + intent.p2[0]) / 2.0, + (intent.p1[1] + intent.p2[1]) / 2.0, + ) + tol = { + "tol_upper": resolved["tol_upper"], + "tol_lower": resolved["tol_lower"], + "tol_mode": resolved["tol_mode"], + "text_override": resolved["text_override"], + } + + if intent.kind == "linear": + horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) + info = await backend.dimension_linear( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + text_at[0], + text_at[1], + 0.0 if horizontal else 90.0, + ROLE_LAYER["dim"], + **tol, + ) + elif intent.kind == "aligned": + if tol["tol_mode"] != "none" or tol["text_override"]: + angle = math.degrees( + math.atan2(intent.p2[1] - intent.p1[1], intent.p2[0] - intent.p1[0]) + ) + # dimension_aligned carries no tolerance arguments, so a toleranced + # aligned dimension is drawn as a rotated linear one - the same + # measurement, with the tolerance the caller asked for. + info = await backend.dimension_linear( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + text_at[0], + text_at[1], + angle, + ROLE_LAYER["dim"], + **tol, + ) + else: + info = await backend.dimension_aligned( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + text_at[0], + text_at[1], + ROLE_LAYER["dim"], + ) + elif intent.kind == "diameter": + info = await backend.dimension_diameter( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + 10.0, + ROLE_LAYER["dim"], + **tol, + ) + elif intent.kind == "radius": + info = await backend.dimension_radius( + intent.p1[0], + intent.p1[1], + intent.p2[0], + intent.p2[1], + 10.0, + ROLE_LAYER["dim"], + **tol, + ) + else: + return None, { + **row, + "skipped": "an angular intent needs a vertex, which a DimIntent does not carry; " + "no feature in this release emits one", + } + return info.handle, row + + +# -- the part payload -------------------------------------------------------- + + +def _view_record(view: View, offset: tuple[float, float], kw: dict) -> dict: + x0, y0, x1, y1 = view.bbox + return { + "kind": view.kind, + "label": view.label, + "scale": view.scale, + "offset": [float(offset[0]), float(offset[1])], + "bbox": [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]], + "kw": kw, + } + + +def _offset_for(view: View, lower_left: tuple[float, float]) -> tuple[float, float]: + return (lower_left[0] - view.bbox[0], lower_left[1] - view.bbox[1]) + + +def _placed(view: View, offset) -> View: + x0, y0, x1, y1 = view.bbox + return View( + kind=view.kind, + prims=view.prims, + dims=view.dims, + omitted=view.omitted, + bbox=(x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]), + label=view.label, + scale=view.scale, + ) + + +def _view_from_record(part, payload: dict, record: dict) -> View: + """Rebuild the view a record describes, with the keywords it was drawn with.""" + kw = record.get("kw") or {} + return build_view( + part, + record["kind"], + side=str(kw.get("side", "right")), + plane=kw.get("plane"), + style=str(kw.get("style", "full")), + scale=float(kw.get("scale", 1.0)), + projection=str((payload.get("placement") or {}).get("projection", "first")), + detail=kw.get("detail"), + ) + + +async def draw_part( + backend, + part, + *, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, + views=("front",), + projection: str = "first", + dimension: bool = True, + style: str = "chain", + scale: float = 1.0, +) -> dict: + """Draw a part's views, dimension them, and write the model onto the drawing.""" + validate_part(part) + kinds = [str(kind).strip().lower() for kind in (views or ())] + if not kinds: + raise ValueError("views: name at least one view to draw.") + + handles: list[str] = [] + records: list[dict] = [] + omitted: list[dict] = [] + parent: View | None = None + parent_offset = (0.0, 0.0) + + for index, kind in enumerate(kinds): + view = build_view(part, kind, projection=projection, scale=scale) + if index == 0: + offset = _offset_for(view, (float(at[0]), float(at[1]))) + else: + lower_left = layout_origin( + _placed(parent, parent_offset), + kind, + projection=projection, + gap=DEFAULT_GAP, + child=view, + ) + offset = _offset_for(view, lower_left) + drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation) + handles.extend(drawn["handles"]) + omitted.extend(view.omitted) + records.append(_view_record(view, offset, {"scale": scale})) + if index == 0: + parent, parent_offset = view, offset + + anchor = await backend.entity_create_point(float(at[0]), float(at[1]), layer=ANCHOR_LAYER) + payload = { + "v": PAYLOAD_VERSION, + "kind": "part", + "id": anchor.handle, + "view": records[0]["kind"], + "part": part_to_dict(part), + "placement": { + "at": [float(at[0]), float(at[1])], + "rotation": float(rotation), + "projection": projection, + }, + "views": records, + } + await backend.entity_set_xdata(anchor.handle, APP_ID, to_values(payload)) + + result = { + "part_id": anchor.handle, + "views": records, + "handles": handles, + "omitted": omitted, + "backend": backend.name, + } + if dimension: + dimensioned = await dimension_part(backend, anchor.handle, style=style) + result["dimensions"] = dimensioned["count"] + result["handles"].extend(dimensioned["handles"]) + return result + + +async def read_part(backend, part_id: str | None = None) -> dict: + """The part model written onto the drawing, read back out of its anchor.""" + if part_id: + raw = await backend.entity_get_xdata(part_id, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if not values: + raise ValueError(f"{part_id}: carries no {APP_ID} part payload.") + payload = _xdata_payload(values) + return {"part_id": part_id, **payload} + + anchors = await backend.entity_list(type_filter="POINT", layer_filter=ANCHOR_LAYER, limit=1000) + found: list[tuple[str, dict]] = [] + for anchor in anchors: + raw = await backend.entity_get_xdata(anchor.handle, APP_ID) + values = (raw.get("xdata") or {}).get(APP_ID) or [] + if values: + found.append((anchor.handle, _xdata_payload(values))) + if not found: + raise ValueError( + f"this drawing carries no {APP_ID} part payload; draw one with mech_part_draw." + ) + if len(found) > 1: + raise ValueError( + "this drawing carries " + f"{len(found)} parts ({', '.join(handle for handle, _ in found)}); " + "name one with part_id." + ) + handle, payload = found[0] + return {"part_id": handle, **payload} + + +async def add_view(backend, part_id: str, kind: str, **kw) -> dict: + """Add another view of a part already on the drawing, laid out against its first.""" + payload = await read_part(backend, part_id) + part = build_part(payload["part"]) + records = list(payload.get("views") or []) + if not records: + raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.") + placement = payload.get("placement") or {} + projection = str(placement.get("projection", "first")) + rotation = float(placement.get("rotation", 0.0)) + + plane = kw.pop("plane", None) + if plane is not None: + plane = normalise_plane(plane) + detail = kw.pop("detail", None) + gap = float(kw.pop("gap", DEFAULT_GAP)) + side = str(kw.pop("side", "right")) + style = str(kw.pop("style", "full")) + scale = float(kw.pop("scale", 1.0)) + if kw: + raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.") + + view = build_view( + part, + kind, + side=side, + plane=plane, + style=style, + scale=scale, + projection=projection, + detail=detail, + ) + + parent_record = records[0] + parent_view = _view_from_record(part, payload, parent_record) + lower_left = layout_origin( + _placed(parent_view, parent_record["offset"]), + kind, + side=side, + projection=projection, + gap=gap, + child=view, + ) + # step past every view already placed, so a third view never lands on the second + width = view.bbox[2] - view.bbox[0] + for record in records[1:]: + x0, y0, x1, y1 = record["bbox"] + del y0, y1 + if lower_left[0] < x1 + gap and lower_left[0] + width > x0 - gap: + lower_left = (x0 - gap - width, lower_left[1]) + offset = _offset_for(view, lower_left) + + drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation) + marker_handles: list[str] = [] + if kind == "detail": + marker = detail_marker_prims(detail or {}) + marker_drawn = await draw_prims( + backend, marker, at=parent_record["offset"], rotation=rotation + ) + marker_handles = marker_drawn["handles"] + + records.append( + _view_record( + view, + offset, + { + "side": side, + "style": style, + "scale": scale, + "plane": _plane_to_json(plane), + "detail": detail, + }, + ) + ) + payload["views"] = records + await backend.entity_set_xdata( + part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"}) + ) + return { + "part_id": part_id, + "view": records[-1], + "handles": drawn["handles"] + marker_handles, + "omitted": list(view.omitted), + "backend": backend.name, + } + + +def _plane_to_json(plane: dict | None) -> dict | None: + """`normalise_plane` answers with tuples; JSON round-trips them as lists.""" + if plane is None: + return None + return { + "p1": [float(plane["p1"][0]), float(plane["p1"][1])], + "p2": [float(plane["p2"][0]), float(plane["p2"][1])], + "label": str(plane["label"]), + "direction": [float(plane["direction"][0]), float(plane["direction"][1])], + "style": str(plane["style"]), + "via": [[float(p[0]), float(p[1])] for p in plane.get("via", ())], + } + + +def _fits_for_view(part, view, fits: dict, *, style: str, threshold: int) -> dict: + """The subset of ``fits`` this view actually dimensions. + + ``dimension_intents`` refuses a key that names nothing *in the view it is + given*, which is right for one view and wrong for a part drawn in three: + a fit on ``segment[0]`` names nothing in the end view, and refusing there + would make a correct sheet undrawable. So the split happens here and the + keys nothing anywhere claims are re-offered to :func:`dimension_intents` + afterwards, which refuses them in its own words. + """ + if not fits: + return {} + plain = dimension_intents(part, view, style=style, hole_table_threshold=threshold) + available = {intent.feature for intent in plain if intent.feature} + return {key: code for key, code in fits.items() if str(key) in available} + + +async def dimension_part( + backend, + part_id: str, + *, + style: str = "chain", + hole_table_threshold: int = 8, + fits: dict | None = None, +) -> dict: + """Dimension every view of a part, laid out so no two texts overlap.""" + payload = await read_part(backend, part_id) + part = build_part(payload["part"]) + records = payload.get("views") or [] + if fits is not None and not isinstance(fits, dict): + raise ValueError( + f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}." + ) + fits = dict(fits or {}) + unclaimed = set(fits) + + handles: list[str] = [] + resolved_rows: list[dict] = [] + skipped: list[dict] = [] + table_handle: str | None = None + + for record in records: + view = _view_from_record(part, payload, record) + mine = _fits_for_view(part, view, fits, style=style, threshold=int(hole_table_threshold)) + unclaimed -= set(mine) + intents = dimension_intents( + part, view, style=style, hole_table_threshold=hole_table_threshold, fits=mine or None + ) + placed = layout_dimensions(view, intents) + offset = record["offset"] + for intent in placed: + moved = intent._replace( + p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]), + p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]), + text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]), + ) + handle, row = await _draw_dimension(backend, moved) + if handle is None: + skipped.append(row) + continue + handles.append(handle) + resolved_rows.append(row) + + rows = hole_table_rows(part, view, hole_table_threshold=hole_table_threshold) + if rows and table_handle is None: + info = await backend.entity_create_table( + record["bbox"][2] + DEFAULT_GAP, + record["bbox"][3], + [[r["tag"], f"{r['d']:g}", str(r["qty"]), r["note"]] for r in rows], + ["TAG", "DIA", "QTY", "NOTE"], + None, + 7.0, + 2.5, + "HOLE TABLE", + ROLE_LAYER["text"], + ) + table_handle = info.handle + handles.append(info.handle) + + if unclaimed: + # No view claimed these keys. Hand them back to the module that owns the + # vocabulary so the refusal names the keys that do exist, and the hole + # table's own message survives. + dimension_intents( + part, + _view_from_record(part, payload, records[0]), + style=style, + hole_table_threshold=hole_table_threshold, + fits={key: fits[key] for key in sorted(unclaimed)}, + ) + + return { + "part_id": part_id, + "count": len(handles) - (1 if table_handle else 0), + "handles": handles, + "resolved": resolved_rows, + "skipped": skipped, + "hole_table": table_handle, + "backend": backend.name, + } + + +# -- the standalone hole pattern --------------------------------------------- + + +async def draw_hole_pattern( + backend, + *, + pattern: str, + x: float, + y: float, + diameter: float, + 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, + layer: str | None = None, + centre_marks: bool = True, + dimension: bool = False, +) -> dict: + """Draw a hole pattern on any drawing - no part model needed.""" + # A standalone pattern has no part, so `Hole.validate` (which needs one) is + # out of reach: the size guard is made here rather than left to the drawing. + diameter = _number(diameter, "diameter") + _number(x, "x", positive=False) + _number(y, "y", positive=False) + expansion = HolePattern( + id="pattern", + pattern=pattern, + base={"x": float(x), "y": float(y), "diameter": diameter}, + count=int(count), + spacing=float(spacing), + count_y=int(count_y), + spacing_y=float(spacing_y), + pcd=float(pcd), + start_angle=float(start_angle), + angle=float(angle), + ).expand() # validates the pattern before anything is drawn + + prims: list[Any] = [] + for hole in expansion: + radius = float(hole.diameter) / 2.0 + prims.append(Circle((float(hole.x), float(hole.y)), radius, "visible")) + if centre_marks: + reach = radius + CENTRE_MARK_OVERRUN + prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center")) + prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center")) + drawn = await draw_prims(backend, prims) + if layer: + # the role map owns the layers; an explicit override moves the circles + # only, so the ISO 128-23 centre marks stay on CENTER. + for handle, prim in zip(drawn["handles"], prims, strict=True): + if isinstance(prim, Circle): + await backend.entity_set_properties(handle, layer=layer) + + result = { + "count": len(expansion), + "handles": drawn["handles"], + "centers": [[float(h.x), float(h.y)] for h in expansion], + "backend": backend.name, + } + if dimension: + first = expansion[0] + radius = float(first.diameter) / 2.0 + handle, _row = await _draw_dimension( + backend, + DimIntent( + kind="diameter", + p1=(first.x - radius, first.y), + p2=(first.x + radius, first.y), + text_at=(first.x + radius + 12.0, first.y + 12.0), + text_override=f"{len(expansion)}x ⌀{float(first.diameter):g}", + feature="pattern", + ), + ) + if handle: + result["handles"].append(handle) + return result + + +# -- the whole sheet --------------------------------------------------------- + + +async def draw_from_spec(backend, spec: dict) -> dict: + """A whole mechanical sheet from one declarative spec, inside one transaction. + + Every part is validated before the transaction opens, so a refusal leaves + the drawing untouched. The rollback is shielded for the reason + `engineering/pid/spec.py` documents at length: the MCP SDK cancels a request + by cancelling an anyio scope, anyio cancellation is level-triggered, and a + bare ``await`` in the handler would itself be cancelled before the rollback + body ran - leaving half a sheet inside an open transaction. + """ + if not isinstance(spec, dict): + raise ValueError(f"spec: expected a dict, got {type(spec).__name__}.") + raw_parts = spec.get("parts") + if not isinstance(raw_parts, (list, tuple)) or not raw_parts: + raise ValueError("parts: name at least one part to draw.") + + jobs: list[dict] = [] + for index, item in enumerate(raw_parts): + if not isinstance(item, dict): + raise ValueError(f"parts[{index}]: expected a dict with a 'part' key.") + try: + part = build_part(item.get("part") or {}) + except ValueError as exc: + raise ValueError(f"parts[{index}]: {exc}") from exc + at = item.get("at") or [0.0, 0.0] + jobs.append( + { + "part": part, + "at": (float(at[0]), float(at[1])), + "rotation": float(item.get("rotation", 0.0)), + "views": tuple(item.get("views") or ("front",)), + "projection": str(item.get("projection", "first")), + "dimension": bool(item.get("dimension", True)), + "style": str(item.get("style", "chain")), + } + ) + + sheet = spec.get("sheet") or {} + if backend.get_plan_spec() is None: + await backend.drawing_plan( + sheet.get("intent", "mechanical part sheet"), + sheet.get("sheet_size", "A3"), + float(sheet.get("scale", 1.0)), + sheet.get("layer_set", "mech"), + ) + + begun = await backend.transaction_begin() + if not isinstance(begun, dict) or not begun.get("ok"): + raise RuntimeError(f"mech_part_from_spec: could not open a transaction: {begun}") + + drawn: list[dict] = [] + try: + for index, job in enumerate(jobs): + try: + drawn.append( + await draw_part( + backend, + job["part"], + at=job["at"], + rotation=job["rotation"], + views=job["views"], + projection=job["projection"], + dimension=job["dimension"], + style=job["style"], + ) + ) + except ValueError as exc: + raise ValueError(f"parts[{index}]: {exc}") from exc + except BaseException as exc: + cancelled = isinstance(exc, anyio.get_cancelled_exc_class()) + with anyio.CancelScope(shield=True): + try: + await backend.transaction_rollback() + except Exception as rollback_exc: + if not cancelled: + raise + log.error( + "mech_part_from_spec: rollback after a cancelled request failed (%s: %s); " + "the sheet may be half-drawn - check system_status and " + "transaction_rollback before retrying", + type(rollback_exc).__name__, + rollback_exc, + ) + raise + await backend.transaction_commit() + + return { + "parts": drawn, + "part_ids": [item["part_id"] for item in drawn], + "backend": backend.name, + } diff --git a/server.py b/server.py index 1635d7e..0707d87 100644 --- a/server.py +++ b/server.py @@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None: # entity/drawing tags. Tools whose tags match none fall into "other". _GROUP_TAG_PRIORITY = ( "pid", + "mech", "style", "pagesetup", "environment", @@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = ( # Map the winning tag to a human-readable group label for the breakdown. _GROUP_TAG_LABELS = { "pid": "pid", + "mech": "mech", "style": "styles", "pagesetup": "page_setup", "environment": "environment", @@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset( # mechanically should not pay for the P&ID surface, and a client that never # talks to a live seat should not pay for the environment surface. Styles and # page setup (SECTION 18/19) are drafting essentials and stay in core. -TOOL_PACK_NAMES = ("core", "pid", "settings") +TOOL_PACK_NAMES = ("core", "pid", "settings", "mech") PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "pid": frozenset( { @@ -1002,6 +1004,17 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = { "system_prompt_message", } ), + # SECTION 21 - the mechanical part model and its view engine (tracks B+G). + "mech": frozenset( + { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", + } + ), } _active_tool_profile: dict | None = None @@ -8123,6 +8136,403 @@ async def drawing_properties_set( return await _backend(ctx).drawing_properties_set(summary, custom) +# --------------------------------------------------------------------------- +# ── SECTION 21: Mechanical Parts (6 tools) ────────────────────────────────── +# --------------------------------------------------------------------------- + + +@cad_tool( + summary="Draw a machine part from a segment list or an outline plus typed features.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Draw Part", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_part_draw( + spec: Annotated[ + dict, + Field( + description=( + "The part: {kind: 'revolved'|'prismatic', name, material, " + "segments:[{length, d_outer, d_inner, taper_to}] | outline:[[x,y]] + thickness, " + "features:[{kind, id, ...}]}" + ) + ), + ], + at_x: Annotated[ + float, Field(default=0.0, description="WCS X of the first view's lower-left") + ] = 0.0, + at_y: Annotated[ + float, Field(default=0.0, description="WCS Y of the first view's lower-left") + ] = 0.0, + rotation: Annotated[ + float, Field(default=0.0, description="Degrees CCW applied to every view") + ] = 0.0, + views: Annotated[ + list[str] | None, + Field( + default=None, + description="Views to draw in order: front | side | top (default ['front'])", + ), + ] = None, + projection: Annotated[ + str, Field(default="first", description="first (ISO 128-30, default) | third") + ] = "first", + dimension: Annotated[ + bool, Field(default=True, description="Also dimension the part (ISO 129 + ISO 286 fits)") + ] = True, + style: Annotated[ + str, + Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"), + ] = "chain", + ctx: Context = None, +) -> dict: + """One part model - a turned profile or a plate outline plus typed features - drawn as views. + + The model is written onto the drawing as `ACADMCP_MECH` XDATA on an anchor + POINT, so `mech_view_add` can add a section months later without the caller + re-describing the part, and `mech_part_inspect` reads it back. + + Refused **before anything is drawn**, by path: a non-finite or non-positive + number (`segments[2].d_outer`), a bore that is not smaller than its outside, + an outline with fewer than three distinct vertices or one that crosses + itself, an unknown material, a feature whose placement is off the part, two + features that would remove the same material (both names given), a thread + outside the transcribed ISO 261 table, a keyway outside DIN 6885's bore + range, gear teeth whose tip diameter disagrees with their segment. A DIN 509 + undercut, a DIN 471/472 groove, a DIN 332 centre hole and an ISO 3601-2 + O-ring groove need explicit dimensions in this build: those tables are not + transcribed and the lookup refuses by name rather than interpolate. A + feature that cannot be projected into a requested view is listed in + `omitted`, never dropped. + """ + from engineering.mech.draw import draw_part + from engineering.mech.part import build_part + + try: + part = build_part(spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc + await ctx.info(f"mech_part_draw: {part.name} at ({at_x}, {at_y})") + try: + return await draw_part( + _backend(ctx), + part, + at=(at_x, at_y), + rotation=rotation, + views=tuple(views or ("front",)), + projection=projection, + dimension=dimension, + style=style, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Add another view of a drawn part: side, top, section (full/half/offset) or detail.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Add View", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_view_add( + part_id: Annotated[ + str | None, + Field( + default=None, + description="Anchor handle from mech_part_draw; omit if the drawing holds one part", + ), + ] = None, + kind: Annotated[ + str, Field(default="section", description="front | side | top | section | detail") + ] = "section", + side: Annotated[ + str, Field(default="right", description="Which end an end view looks from: right | left") + ] = "right", + style: Annotated[ + str, + Field(default="full", description="Section style: full | half | offset | revolved"), + ] = "full", + plane: Annotated[ + dict | None, + Field( + default=None, + description="Cutting plane {p1:[x,y], p2:[x,y], label, direction:[dx,dy], via:[[x,y]]} - required for a section", + ), + ] = None, + detail: Annotated[ + dict | None, + Field( + default=None, + description="Detail region {center:[x,y], radius, scale, label, of:'front'} - required for a detail", + ), + ] = None, + gap: Annotated[ + float, Field(default=20.0, description="Gap between this view and its parent (mm)") + ] = 20.0, + scale: Annotated[ + float, Field(default=1.0, description="View scale recorded on the view") + ] = 1.0, + ctx: Context = None, +) -> dict: + """Another view of a part already on the drawing, laid out on its projection axis. + + The part model comes back out of the drawing's `ACADMCP_MECH` XDATA, so the + caller never re-describes it, and the placement follows the projection angle + recorded when the part was drawn (first angle by default: the view from the + right goes to the left). + + Refused by name: a section without a plane, or with a plane of zero length, + a plane that misses the part, or one that grazes an outline vertex so its + crossings cannot be paired; a half or offset section on the wrong kind of + plane (the message names the style that does apply); a detail without a + radius or with a scale of zero; an unknown part_id, and `part_id=None` on a + drawing that holds more than one part. + """ + from engineering.mech.draw import add_view, read_part + + backend = _backend(ctx) + try: + resolved = part_id or (await read_part(backend))["part_id"] + await ctx.info(f"mech_view_add: {kind} on {resolved}") + return await add_view( + backend, + resolved, + kind, + side=side, + style=style, + plane=plane, + detail=detail, + gap=gap, + scale=scale, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Dimension Part", "destructiveHint": False}, + tags={"mech", "dimension"}, +) +async def mech_dimension_part( + part_id: Annotated[ + str | None, + Field( + default=None, + description="Anchor handle from mech_part_draw; omit if the drawing holds one part", + ), + ] = None, + style: Annotated[ + str, + Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"), + ] = "chain", + hole_table_threshold: Annotated[ + int, + Field(default=8, description="Above this many holes the diameters move into a hole table"), + ] = 8, + fits: Annotated[ + dict | None, + Field( + default=None, + description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}", + ), + ] = None, + ctx: Context = None, +) -> dict: + """Every dimension the part and its features ask for, laid out without overlaps. + + Axial lengths are chained, stacked from a baseline or measured from an + origin; diameters stack outside the silhouette; fillets get a radius. Each + measurement appears once (ISO 129-1). `fits` resolves an ISO 286 code + against the measured nominal through the repository's authored tables and + appends the code to the dimension text; an explicit `tol` on a feature's own + intent does the same through the ISO 129 path. + + Refused by name: a fit together with an explicit tolerance, a fit outside + the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an + intent with no measurable nominal, an unknown style, an unknown part_id, and + `part_id=None` on a drawing that holds more than one part. An angular intent + is reported in `skipped` rather than drawn, because a DimIntent carries no + vertex. + """ + from engineering.mech.draw import dimension_part, read_part + + backend = _backend(ctx) + try: + resolved = part_id or (await read_part(backend))["part_id"] + await ctx.info(f"mech_dimension_part: {resolved} ({style})") + return await dimension_part( + backend, + resolved, + style=style, + hole_table_threshold=hole_table_threshold, + fits=fits, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a linear, grid or polar hole pattern with centre marks on any drawing.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Hole Pattern", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_hole_pattern( + pattern: Annotated[str, Field(description="linear | grid | polar")], + x: Annotated[float, Field(description="WCS X of the first hole (polar: the circle centre)")], + y: Annotated[float, Field(description="WCS Y of the first hole (polar: the circle centre)")], + diameter: Annotated[float, Field(description="Hole diameter in drawing units")], + count: Annotated[ + int, Field(default=2, description="Holes along the pattern (polar: around the circle)") + ] = 2, + spacing: Annotated[ + float, Field(default=0.0, description="Pitch between holes (linear, grid columns)") + ] = 0.0, + count_y: Annotated[int, Field(default=1, description="Rows, for a grid")] = 1, + spacing_y: Annotated[float, Field(default=0.0, description="Row pitch, for a grid")] = 0.0, + pcd: Annotated[ + float, Field(default=0.0, description="Pitch-circle diameter, for a polar pattern") + ] = 0.0, + start_angle: Annotated[ + float, Field(default=0.0, description="Angle of the first hole (polar), degrees CCW") + ] = 0.0, + angle: Annotated[ + float, Field(default=0.0, description="Direction of a linear pattern, degrees CCW") + ] = 0.0, + layer: Annotated[ + str | None, Field(default=None, description="Override the GEOMETRY layer for the circles") + ] = None, + centre_marks: Annotated[ + bool, Field(default=True, description="Draw ISO 128-23 centre marks") + ] = True, + dimension: Annotated[ + bool, Field(default=False, description="Add a '6x ⌀8' diameter callout") + ] = False, + ctx: Context = None, +) -> dict: + """A hole pattern on any drawing - the part model is not required. + + The same expansion `HolePattern` performs inside a part, exposed for a + drawing that was not built from the model. + + Refused before anything is drawn: an unknown pattern, a count below 1 (below + 2 for polar), a linear pattern with no spacing, a grid with more than one + column or row and no pitch for it, a polar pattern with no pitch-circle + diameter, and a non-finite or non-positive diameter. + """ + from engineering.mech.draw import draw_hole_pattern + + await ctx.info(f"mech_hole_pattern: {pattern} x{count}") + try: + return await draw_hole_pattern( + _backend(ctx), + pattern=pattern, + x=x, + y=y, + diameter=diameter, + count=count, + spacing=spacing, + count_y=count_y, + spacing_y=spacing_y, + pcd=pcd, + start_angle=start_angle, + angle=angle, + layer=layer, + centre_marks=centre_marks, + dimension=dimension, + ) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Draw a whole mechanical sheet - several parts and their views - in one transaction.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Mechanical: Sheet From Spec", "destructiveHint": False}, + tags={"mech", "create"}, +) +async def mech_part_from_spec( + spec: Annotated[ + dict, + Field( + description=( + "{sheet: {intent, sheet_size, scale, layer_set}, " + "parts: [{part: {...}, at: [x, y], rotation, views: ['front','side'], " + "projection, dimension, style}]}" + ) + ), + ], + ctx: Context = None, +) -> dict: + """Several parts, their views and their dimensions, drawn inside one transaction. + + Every part is validated before the transaction opens, so a refusal leaves + the drawing untouched and names the offending path (`parts[1]: + segments[0].d_outer: ...`). If a draw fails or the request is cancelled + part-way, the transaction is rolled back under an anyio shield, so a + cancelled call cannot leave half a sheet inside an open transaction. + + Refused by name: an empty `parts` list, any part refusal from + `mech_part_draw`, and a transaction that cannot be opened. + """ + from engineering.mech.draw import draw_from_spec + + await ctx.info(f"mech_part_from_spec: {len((spec or {}).get('parts') or [])} part(s)") + try: + return await draw_from_spec(_backend(ctx), spec) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + +@cad_tool( + summary="Read the part model back off the drawing: segments, features, views, omissions.", + cost="read", +) +@mcp.tool( + annotations={"title": "Mechanical: Inspect Part", "readOnlyHint": True}, + tags={"mech", "query"}, +) +async def mech_part_inspect( + part_id: Annotated[ + str | None, + Field( + default=None, + description="Anchor handle from mech_part_draw; omit if the drawing holds one part", + ), + ] = None, + ctx: Context = None, +) -> dict: + """The `ACADMCP_MECH` payload a drawn part carries, decoded. + + Never modifies the drawing. Returns the part model exactly as + `mech_part_draw` accepts it (`part`), the views drawn so far with their + placements, and the payload version. + + Refused by name: an unknown part_id, a handle that carries no + `ACADMCP_MECH` payload, a corrupt or foreign payload, and `part_id=None` on + a drawing that holds no part or more than one (the message lists the + handles). + """ + from engineering.mech.draw import read_part + + try: + return await read_part(_backend(ctx), part_id) + except ValueError as exc: + raise ToolError(str(exc)) from exc + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_mech_draw.py b/tests/test_mech_draw.py new file mode 100644 index 0000000..6340e2b --- /dev/null +++ b/tests/test_mech_draw.py @@ -0,0 +1,283 @@ +"""The async layer: primitives to entities, the part payload, and the sheet. + +Headless ezdxf against a real document - the `backend` fixture in +tests/conftest.py. The COM paths are the same code (draw.py composes the +generic contract members), which is the whole point of the pattern +`gear_draw_*` and `titleblock_apply_iso_a3` already use. +""" + +from __future__ import annotations + +import pytest + +from engineering.mech.draw import ( + ANCHOR_LAYER, + add_view, + dimension_part, + draw_from_spec, + draw_hole_pattern, + draw_part, + draw_prims, + read_part, +) +from engineering.mech.part import build_part +from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Poly, Text + +pytestmark = pytest.mark.asyncio + +SHAFT = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [ + {"length": 20.0, "d_outer": 25.0}, + {"length": 40.0, "d_outer": 30.0}, + {"length": 15.0, "d_outer": 20.0}, + ], + "features": [{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.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]], + "features": [{"kind": "hole", "id": "h1", "x": 40.0, "y": 25.0, "diameter": 10.0}], +} + + +async def entities(backend, type_filter=None, layer=None): + return await backend.entity_list(type_filter=type_filter, layer_filter=layer, limit=2000) + + +async def test_draw_prims_puts_every_role_on_its_own_layer(backend): + result = await draw_prims( + backend, + [ + Line((0.0, 0.0), (10.0, 0.0), "visible"), + Line((0.0, 5.0), (10.0, 5.0), "hidden"), + Line((-2.0, 2.5), (12.0, 2.5), "center"), + Circle((5.0, 2.5), 1.0, "visible"), + Arc((5.0, 2.5), 2.0, 0.0, 90.0, "visible"), + Poly(((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), True, "visible"), + Text((5.0, 8.0), "A", 3.5, 0.0, "text"), + HatchArea((((0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)),), "steel"), + ], + ) + assert len(result["handles"]) == 8 + layers = {e.layer for e in await entities(backend)} + assert {"GEOMETRY", "HIDDEN", "CENTER", "TEXT", "HATCH"} <= layers + + +async def test_draw_prims_places_and_rotates_into_wcs(backend): + await draw_prims(backend, [Line((0.0, 0.0), (10.0, 0.0), "visible")], at=(100.0, 50.0)) + (line,) = await entities(backend, type_filter="LINE") + start = line.properties["start"] + assert (round(start[0], 6), round(start[1], 6)) == (100.0, 50.0) + + +async def test_draw_part_writes_the_model_back_onto_the_drawing(backend): + part = build_part(SHAFT) + result = await draw_part(backend, part, at=(50.0, 100.0)) + assert result["part_id"] + assert result["views"][0]["kind"] == "front" + assert result["handles"] + + anchors = await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + assert len(anchors) == 1 + assert anchors[0].handle == result["part_id"] + + read = await read_part(backend, result["part_id"]) + assert read["part"]["name"] == "shaft" + assert [f["id"] for f in read["part"]["features"]] == ["c1"] + assert build_part(read["part"]) == part + + +async def test_read_part_without_an_id_finds_the_only_part(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part) + read = await read_part(backend) + assert read["part_id"] == drawn["part_id"] + + +async def test_read_part_refuses_when_there_is_more_than_one_and_no_id(backend): + await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0)) + await draw_part(backend, build_part(PLATE), at=(200.0, 0.0)) + with pytest.raises(ValueError) as excinfo: + await read_part(backend) + assert "part_id" in str(excinfo.value) + + +async def test_two_views_are_placed_in_first_angle_positions(backend): + part = build_part(SHAFT) + result = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side")) + front, end = result["views"] + assert end["kind"] == "side" + # first angle: the view from the right sits to the LEFT of the front view + assert end["bbox"][2] <= front["bbox"][0] + 1e-6 + + +async def test_third_angle_puts_the_end_view_on_the_other_side(backend): + part = build_part(SHAFT) + result = await draw_part( + backend, part, at=(0.0, 0.0), views=("front", "side"), projection="third" + ) + front, end = result["views"] + assert end["bbox"][0] >= front["bbox"][2] - 1e-6 + + +async def test_add_view_reads_the_model_back_and_appends_a_section(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0)) + added = await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [0.0, 0.0], "p2": [100.0, 0.0], "label": "A"}, + ) + assert added["view"]["kind"] == "section" + assert added["view"]["label"] == "A-A" + assert len(await entities(backend, type_filter="HATCH")) == 2 + read = await read_part(backend, drawn["part_id"]) + assert [v["kind"] for v in read["views"]] == ["front", "section"] + + +async def test_a_detail_view_also_marks_its_circle_on_the_parent(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0)) + added = await add_view( + backend, + drawn["part_id"], + "detail", + detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"}, + ) + assert added["view"]["label"] == "D (5:1)" + texts = [e for e in await entities(backend, type_filter="TEXT")] + assert any(e.properties.get("text") == "D" for e in texts) + + +async def test_dimension_part_creates_real_dimension_entities(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False) + assert not await entities(backend, type_filter="DIMENSION") + result = await dimension_part(backend, drawn["part_id"]) + assert result["count"] > 0 + dims = await entities(backend, type_filter="DIMENSION") + assert len(dims) == result["count"] + assert {e.layer for e in dims} == {"DIM"} + + +async def test_an_iso_286_fit_reaches_the_dimension_as_a_real_tolerance(backend): + """`fits` names a dimension, and 0.025 is H7 on the 40 mm bore. + + The key is the one `engineering/mech/dimension.segment_key` mints, not the + part's name: `dimension_intents` addresses the part's own diameters one at + a time (`segment[0]`, `segment[0].bore`) precisely so a fit lands on the + bore rather than on every dimension the part emits. Measured: H7 is + +0.025/0 at 40 mm, +0.030/0 at 60 mm and +0.021/0 at the 30 mm length. + """ + spec = { + "kind": "revolved", + "name": "bore", + "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}], + "features": [], + } + part = build_part(spec) + drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False) + result = await dimension_part(backend, drawn["part_id"], fits={"segment[0].bore": "H7"}) + assert result["count"] > 0 + assert any( + row.get("fit") == "H7" + and row["tol_upper"] == pytest.approx(0.025) + and row["tol_lower"] == pytest.approx(0.0) + and row["tol_mode"] == "deviation" + and "H7" in row["text_override"] + for row in result["resolved"] + ) + # and the outside diameter of the same segment is untouched + assert all( + row["tol_mode"] == "none" + for row in result["resolved"] + if row["feature"] != "segment[0].bore" + ) + + +async def test_a_fit_that_names_nothing_is_refused_with_the_names_that_exist(backend): + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False) + with pytest.raises(ValueError) as excinfo: + await dimension_part(backend, drawn["part_id"], fits={"bearing_seat": "k6"}) + assert "bearing_seat" in str(excinfo.value) + assert "segment[0]" in str(excinfo.value) + + +async def test_a_fit_on_one_view_is_not_refused_because_another_view_lacks_it(backend): + """A part drawn front + side: `segment[0]` is dimensioned in the front view + only, and the end view (which dimensions the *last* segment) must not turn + a correct sheet into a refusal.""" + part = build_part(SHAFT) + drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False) + result = await dimension_part(backend, drawn["part_id"], fits={"segment[0]": "k6"}) + assert any(row.get("fit") == "k6" for row in result["resolved"]) + + +async def test_a_hole_pattern_draws_its_holes_and_centre_marks(backend): + result = await draw_hole_pattern( + backend, pattern="polar", x=0.0, y=0.0, diameter=8.0, count=6, pcd=80.0 + ) + assert result["count"] == 6 + circles = await entities(backend, type_filter="CIRCLE") + assert len(circles) == 6 + assert len(await entities(backend, layer="CENTER")) >= 12 + + +async def test_a_hole_pattern_with_no_spacing_is_refused_before_anything_is_drawn(backend): + with pytest.raises(ValueError, match="spacing"): + await draw_hole_pattern(backend, pattern="linear", x=0.0, y=0.0, diameter=8.0, count=4) + assert not await entities(backend, type_filter="CIRCLE") + + +async def test_draw_from_spec_draws_a_whole_sheet_in_one_transaction(backend): + result = await draw_from_spec( + backend, + { + "sheet": {"intent": "mechanical sheet", "sheet_size": "A3", "scale": 1.0}, + "parts": [ + {"part": SHAFT, "at": [40.0, 200.0], "views": ["front", "side"]}, + {"part": PLATE, "at": [40.0, 60.0], "views": ["front"]}, + ], + }, + ) + assert len(result["parts"]) == 2 + assert len(await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)) == 2 + + +async def test_draw_from_spec_validates_every_part_before_it_draws_anything(backend): + bad = dict(SHAFT, segments=[{"length": 20.0, "d_outer": float("inf")}]) + with pytest.raises(ValueError) as excinfo: + await draw_from_spec( + backend, + {"parts": [{"part": PLATE, "at": [0.0, 0.0]}, {"part": bad, "at": [200.0, 0.0]}]}, + ) + assert "parts[1]" in str(excinfo.value) + assert "segments[0].d_outer" in str(excinfo.value) + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + + +async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monkeypatch): + calls = {"n": 0} + real = backend.entity_create_line + + async def explode(*args, **kwargs): + calls["n"] += 1 + if calls["n"] > 3: + raise RuntimeError("the seat went away") + return await real(*args, **kwargs) + + monkeypatch.setattr(backend, "entity_create_line", explode) + with pytest.raises(RuntimeError, match="the seat went away"): + await draw_from_spec(backend, {"parts": [{"part": SHAFT, "at": [0.0, 0.0]}]}) + monkeypatch.undo() + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + assert not await entities(backend, type_filter="LINE") diff --git a/tests/test_tool_packs.py b/tests/test_tool_packs.py index fc370c2..e423f98 100644 --- a/tests/test_tool_packs.py +++ b/tests/test_tool_packs.py @@ -86,7 +86,16 @@ LEAN_SETTINGS_ESSENTIALS = { "textstyle_set_current", } -ALL_PACKS = ["core", "pid", "settings"] +ALL_PACKS = ["core", "pid", "settings", "mech"] +ALL_PACKS_SORTED = sorted(ALL_PACKS) +MECH_TOOLS = { + "mech_part_draw", + "mech_view_add", + "mech_dimension_part", + "mech_hole_pattern", + "mech_part_from_spec", + "mech_part_inspect", +} @pytest_asyncio.fixture(autouse=True) @@ -97,8 +106,9 @@ async def _restore(monkeypatch): def test_pack_registry_names_real_tools_and_only_them(): - assert server.TOOL_PACK_NAMES == ("core", "pid", "settings") + assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech") assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS) + assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS) assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS) assert len(SETTINGS_TOOLS) == 22 assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"]) @@ -137,7 +147,7 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch): info = await server._apply_tool_profile("full") disabled = set(info["disabled_tools"]) assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled) - assert info["tool_packs"]["enabled"] == ALL_PACKS + assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped( @@ -184,7 +194,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups(): assert set(groups["styles"]) == STYLE_TOOLS assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS assert set(groups["environment"]) == SETTINGS_TOOLS - assert len(groups) == 23 + assert set(groups["mech"]) == MECH_TOOLS + assert len(groups) == 24 async def test_system_about_reports_packs(): diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 4b55428..36edea0 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -527,8 +527,15 @@ async def test_tool_group_sizes_are_unchanged(): merge task tagged them, the four groups had filed those 28 tools under `layouts` / `templates` / `drawing` / `layers` / `view` / `system` by their secondary tags; those counts returned to their pre-track-E values - when the section tags landed.) Every other number here has been unchanged - since the snapshot was taken. + when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's + SECTION 21 opened with the part model and its view engine + (`mech_part_draw` / `mech_view_add` / `mech_dimension_part` / + `mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the + `mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so + those tools file under `mech` rather than under their secondary `create` / + `dimension` / `query` tags. This snapshot is branch-local: the merge task + recomputes it once all four track B+G branches have landed. Every other + number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged(): "environment": 22, "layers": 14, "layouts": 12, + "mech": 6, "page_setup": 6, "pid": 9, "premium": 12, @@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 204 + assert sum(sizes.values()) == 210 From 5388440c274c5e5240b30bc142e4f8297d12e7b4 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 12:20:25 +0300 Subject: [PATCH 11/11] fix(task-7): rotation honoured end to end, each view dimensioned once, refusals that name their path - rotation turns the whole sheet of views about `at` as one rigid body: the prims, the recorded WCS `bbox` (plus the sheet-frame `frame_bbox` the next view is laid out against), the detail marker, and every dimension intent (points, text position, and the linear measuring direction + rotation). Measured headless: rotation=90 at (100,100) now draws (70,100)-(100,172), records exactly that box, and leaves only the 2 diameter chord defpoints off-geometry (was 4, up to 47.5 mm away). - dimension_part dimensions only views whose record is not `dimensioned`, takes `views=[indices]`, refuses a view already dimensioned by name (ISO 129-1), writes the flag back, places the hole table once, and refuses a fit no target view claims before drawing anything. A view the dimension layer emits nothing for (a detail) is reported in `skipped` and left unflagged. mech_dimension_part gains the `views` argument. - draw_prims checks hatch_add_boundary's {"ok": False}: the hatch is removed and the failure raised, never left solid through the bore. An engine whose capability map says `hatch_edge_paths` is unsupported (the live one) is refused before any prim of the view is drawn. - a view scale other than 1.0 is refused by name in draw_part and add_view (views are drawn full size; the scale would only have been recorded). - add_view's collision step compares real rectangles on both axes and steps along the projection axis layout_origin chose, so a third-angle view is never shoved back onto the front view and a top view keeps its alignment. - draw_from_spec validates `at` / `rotation` into the `parts[i]:` path and a transaction that cannot open is a ValueError (a ToolError at the tool). No new ActiveX member is called: the change composes existing contract members (dimension_linear rotation, entity_delete, capabilities()). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/draw.py | 421 +++++++++++++++++++++++++++++++++------ server.py | 46 ++++- tests/test_mech_draw.py | 211 ++++++++++++++++++++ 3 files changed, 613 insertions(+), 65 deletions(-) diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py index cb4a598..0561412 100644 --- a/engineering/mech/draw.py +++ b/engineering/mech/draw.py @@ -106,8 +106,9 @@ async def draw_prims( rotation: float = 0.0, layer_prefix: str = "", ) -> dict: - """Draw primitive descriptions, placed at ``at`` and turned by ``rotation``.""" + """Draw primitive descriptions, turned by ``rotation`` about their origin, then placed at ``at``.""" placed = tuple(prims) + _refuse_islands_without_edge_paths(backend, placed) if abs(float(rotation)) > 1e-12: placed = rotate(placed, float(rotation), (0.0, 0.0)) if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12: @@ -157,7 +158,7 @@ async def draw_prims( style["angle"], layer=layer, ) - for island in prim.loops[1:]: + for number, island in enumerate(prim.loops[1:], start=1): points = list(island) edges = [ { @@ -167,7 +168,24 @@ async def draw_prims( } for i in range(len(points)) ] - await backend.hatch_add_boundary(info.handle, edges) + # `hatch_add_boundary` answers a bad edge list or a bad handle + # with {"ok": False} rather than raising. An island that did not + # attach leaves the hatch solid straight through the bore it was + # meant to exclude, so the hatch is taken back out and the + # failure is raised - a missing hatch is honest, a wrong one is not. + try: + added = await backend.hatch_add_boundary(info.handle, edges) + except Exception: + await _discard(backend, info.handle) + raise + if not isinstance(added, dict) or not added.get("ok"): + await _discard(backend, info.handle) + error = added.get("error") if isinstance(added, dict) else added + raise ValueError( + f"hatch island {number} of {len(prim.loops) - 1} ({prim.material}) " + f"did not attach: {error}; the hatch was removed rather than left " + "filling the material it was meant to exclude." + ) else: # pragma: no cover - the Prim union is closed raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.") handles.append(info.handle) @@ -177,11 +195,111 @@ async def draw_prims( return {"handles": handles, "counts": counts} +def _refuse_islands_without_edge_paths(backend, prims) -> None: + """Refuse a hatch with islands on an engine that cannot attach them - before drawing. + + Islands go through `hatch_add_boundary`, which the live engine refuses + (capability ``hatch_edge_paths``: ActiveX appends loops as objects, not + typed edges). Finding that out at the hatch would leave the rest of the + view already drawn; asking the capability map first leaves nothing behind. + """ + if not any(isinstance(p, HatchArea) and len(p.loops) > 1 for p in prims): + return + try: + feature = backend.capabilities().features.get("hatch_edge_paths") + except Exception: # a backend without a capability map: let the member answer + return + if feature is not None and not feature.supported: + from backends.capability import UnsupportedCapabilityError + + raise UnsupportedCapabilityError( + "hatch_edge_paths", + "this view hatches a cut face with an island (a bore or a hole through the " + f"section), and the {getattr(backend, 'name', 'current')} backend cannot attach " + f"hatch islands ({feature.reason}); nothing was drawn. Draw this view on the " + "headless backend (AUTOCAD_MCP_BACKEND=ezdxf).", + ) + + +async def _discard(backend, handle: str) -> None: + """Delete an entity this module just made; a failure here must not mask the cause.""" + try: + await backend.entity_delete(handle) + except Exception as exc: # pragma: no cover - the original error is the one to raise + log.warning("draw_prims: could not remove hatch %s after a failed island: %s", handle, exc) + + +# -- the placement: the sheet frame and WCS ---------------------------------- +# +# Every view of a part is laid out in one *sheet frame*: the unrotated frame in +# which the first view's lower-left corner sits at `placement.at`. Rotation is +# then applied to the whole sheet as a rigid body about `at`, so the views stay +# on their projection axes and the anchor POINT stays on the part's corner. +# A view record keeps both boxes: `frame_bbox` is what `add_view` lays the next +# view out against, `bbox` is the WCS extent of what was drawn. + + +def _turn(point, at, rotation: float) -> tuple[float, float]: + x, y = float(point[0]), float(point[1]) + if abs(rotation) <= 1e-12: + return (x, y) + cos = math.cos(math.radians(rotation)) + sin = math.sin(math.radians(rotation)) + dx, dy = x - float(at[0]), y - float(at[1]) + return (float(at[0]) + dx * cos - dy * sin, float(at[1]) + dx * sin + dy * cos) + + +def _to_wcs(prims, offset, at, rotation: float) -> tuple: + """Primitives in a view's own coordinates -> WCS: offset into the frame, turn about ``at``.""" + placed = translate(prims, float(offset[0]), float(offset[1])) + if abs(rotation) > 1e-12: + placed = rotate(placed, float(rotation), (float(at[0]), float(at[1]))) + return placed + + +def _wcs_bbox(frame_bbox, at, rotation: float) -> list[float]: + x0, y0, x1, y1 = (float(v) for v in frame_bbox) + corners = [_turn(p, at, rotation) for p in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] + xs = [p[0] for p in corners] + ys = [p[1] for p in corners] + return [min(xs), min(ys), max(xs), max(ys)] + + +def _frame_bbox(record: dict) -> list[float]: + """The sheet-frame box of a record (a payload written before it existed: its WCS box).""" + return [float(v) for v in (record.get("frame_bbox") or record["bbox"])] + + +def _full_size(scale, where: str) -> float: + """Refuse a view scale the geometry would not have. + + The view engine builds every view but a detail at 1:1, so a scale here + would only be *recorded* - and `mech_part_inspect` would then report a + scale the drawing does not have. Refused by name until it is honoured. + """ + value = _number(scale, where) + if abs(value - 1.0) > 1e-12: + raise ValueError( + f"{where}: {value:g} is refused - views are drawn full size (1:1) in this build, " + "so the scale would be recorded but not drawn. Scale the sheet through a paper-space " + "viewport (viewport_set_scale); a detail view takes its own scale in detail['scale']." + ) + return value + + # -- dimensions -------------------------------------------------------------- -async def _draw_dimension(backend, intent) -> tuple[str | None, dict]: - """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row).""" +async def _draw_dimension( + backend, intent, *, linear_angle: float | None = None +) -> tuple[str | None, dict]: + """One laid-out intent as a real DIMENSION. Returns (handle, the resolved row). + + ``linear_angle`` is the measuring direction of a linear intent when the + caller knows it better than the points do: a part turned by its placement + rotation measures along its own axes, which the WCS points alone no longer + reveal. ``None`` reads it from the points (horizontal or vertical). + """ resolved = resolve_tolerance(intent) row = { "feature": intent.feature, @@ -202,7 +320,9 @@ async def _draw_dimension(backend, intent) -> tuple[str | None, dict]: } if intent.kind == "linear": - horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) + if linear_angle is None: + horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0]) + linear_angle = 0.0 if horizontal else 90.0 info = await backend.dimension_linear( intent.p1[0], intent.p1[1], @@ -210,7 +330,7 @@ async def _draw_dimension(backend, intent) -> tuple[str | None, dict]: intent.p2[1], text_at[0], text_at[1], - 0.0 if horizontal else 90.0, + float(linear_angle), ROLE_LAYER["dim"], **tol, ) @@ -275,14 +395,26 @@ async def _draw_dimension(backend, intent) -> tuple[str | None, dict]: # -- the part payload -------------------------------------------------------- -def _view_record(view: View, offset: tuple[float, float], kw: dict) -> dict: +def _view_record( + view: View, + offset: tuple[float, float], + kw: dict, + at: tuple[float, float] = (0.0, 0.0), + rotation: float = 0.0, +) -> dict: x0, y0, x1, y1 = view.bbox + frame = [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]] return { "kind": view.kind, "label": view.label, "scale": view.scale, "offset": [float(offset[0]), float(offset[1])], - "bbox": [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]], + # `bbox` is the WCS extent of what was drawn - the part turned by the + # placement rotation - and `frame_bbox` the same box in the sheet frame + # the next view is laid out in. They are equal while rotation is 0. + "bbox": _wcs_bbox(frame, at, rotation), + "frame_bbox": frame, + "dimensioned": False, "kw": kw, } @@ -319,6 +451,12 @@ def _view_from_record(part, payload: dict, record: dict) -> View: ) +def _placement(payload: dict) -> tuple[tuple[float, float], float]: + placement = payload.get("placement") or {} + at = placement.get("at") or [0.0, 0.0] + return (float(at[0]), float(at[1])), float(placement.get("rotation", 0.0)) + + async def draw_part( backend, part, @@ -331,8 +469,22 @@ async def draw_part( style: str = "chain", scale: float = 1.0, ) -> dict: - """Draw a part's views, dimension them, and write the model onto the drawing.""" + """Draw a part's views, dimension them, and write the model onto the drawing. + + ``at`` is where the first view's lower-left corner goes, and ``rotation`` + turns the whole sheet of views about that point as one rigid body, so the + views keep their projection axes and the dimensions turn with them. + ``scale`` must be 1.0: every view is drawn full size in this build and a + recorded-but-undrawn scale is refused rather than written into the model. + """ validate_part(part) + try: + at_x, at_y = at + except (TypeError, ValueError): + raise ValueError(f"at: expected [x, y], got {at!r}.") from None + origin = (_number(at_x, "at[0]", positive=False), _number(at_y, "at[1]", positive=False)) + turn = _number(rotation, "rotation", positive=False) + scale = _full_size(scale, "scale") kinds = [str(kind).strip().lower() for kind in (views or ())] if not kinds: raise ValueError("views: name at least one view to draw.") @@ -346,7 +498,7 @@ async def draw_part( for index, kind in enumerate(kinds): view = build_view(part, kind, projection=projection, scale=scale) if index == 0: - offset = _offset_for(view, (float(at[0]), float(at[1]))) + offset = _offset_for(view, origin) else: lower_left = layout_origin( _placed(parent, parent_offset), @@ -356,14 +508,14 @@ async def draw_part( child=view, ) offset = _offset_for(view, lower_left) - drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation) + drawn = await draw_prims(backend, _to_wcs(view.prims, offset, origin, turn)) handles.extend(drawn["handles"]) omitted.extend(view.omitted) - records.append(_view_record(view, offset, {"scale": scale})) + records.append(_view_record(view, offset, {"scale": scale}, origin, turn)) if index == 0: parent, parent_offset = view, offset - anchor = await backend.entity_create_point(float(at[0]), float(at[1]), layer=ANCHOR_LAYER) + anchor = await backend.entity_create_point(origin[0], origin[1], layer=ANCHOR_LAYER) payload = { "v": PAYLOAD_VERSION, "kind": "part", @@ -371,8 +523,8 @@ async def draw_part( "view": records[0]["kind"], "part": part_to_dict(part), "placement": { - "at": [float(at[0]), float(at[1])], - "rotation": float(rotation), + "at": [origin[0], origin[1]], + "rotation": turn, "projection": projection, }, "views": records, @@ -388,6 +540,8 @@ async def draw_part( } if dimension: dimensioned = await dimension_part(backend, anchor.handle, style=style) + for index in dimensioned["views_dimensioned"]: + records[index]["dimensioned"] = True result["dimensions"] = dimensioned["count"] result["handles"].extend(dimensioned["handles"]) return result @@ -424,6 +578,45 @@ async def read_part(backend, part_id: str | None = None) -> dict: return {"part_id": handle, **payload} +def _overlaps(box, other, gap: float) -> bool: + """Do two frame rectangles come closer than ``gap`` on *both* axes?""" + eps = 1e-6 + return ( + box[0] < other[2] + gap - eps + and box[2] > other[0] - gap + eps + and box[1] < other[3] + gap - eps + and box[3] > other[1] - gap + eps + ) + + +def _clear_of(records, lower_left, width: float, height: float, parent_box, gap: float): + """Step a new view past every view already placed, away from its parent. + + `layout_origin` put the view on its projection axis (left, right, above or + below the parent); a collision is resolved by stepping further along that + same axis, so a third-angle end view is never shoved back into the front + view it was placed away from, and a view that only shares an X range with + another (a top view under the front) is not a collision at all. + """ + cx = lower_left[0] + width / 2.0 + cy = lower_left[1] + height / 2.0 + dx = cx - (parent_box[0] + parent_box[2]) / 2.0 + dy = cy - (parent_box[1] + parent_box[3]) / 2.0 + boxes = [_frame_bbox(record) for record in records] + for _ in range(len(boxes) + 1): + box = (lower_left[0], lower_left[1], lower_left[0] + width, lower_left[1] + height) + hit = next((other for other in boxes if _overlaps(box, other, gap)), None) + if hit is None: + return lower_left + if abs(dx) >= abs(dy): + x = hit[2] + gap if dx > 0 else hit[0] - gap - width + lower_left = (x, lower_left[1]) + else: + y = hit[3] + gap if dy > 0 else hit[1] - gap - height + lower_left = (lower_left[0], y) + return lower_left # pragma: no cover - each step clears one box for good + + async def add_view(backend, part_id: str, kind: str, **kw) -> dict: """Add another view of a part already on the drawing, laid out against its first.""" payload = await read_part(backend, part_id) @@ -431,9 +624,8 @@ async def add_view(backend, part_id: str, kind: str, **kw) -> dict: records = list(payload.get("views") or []) if not records: raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.") - placement = payload.get("placement") or {} - projection = str(placement.get("projection", "first")) - rotation = float(placement.get("rotation", 0.0)) + projection = str((payload.get("placement") or {}).get("projection", "first")) + origin, rotation = _placement(payload) plane = kw.pop("plane", None) if plane is not None: @@ -442,7 +634,7 @@ async def add_view(backend, part_id: str, kind: str, **kw) -> dict: gap = float(kw.pop("gap", DEFAULT_GAP)) side = str(kw.pop("side", "right")) style = str(kw.pop("style", "full")) - scale = float(kw.pop("scale", 1.0)) + scale = _full_size(kw.pop("scale", 1.0), "scale") if kw: raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.") @@ -459,29 +651,31 @@ async def add_view(backend, part_id: str, kind: str, **kw) -> dict: parent_record = records[0] parent_view = _view_from_record(part, payload, parent_record) + parent_frame = _placed(parent_view, parent_record["offset"]) lower_left = layout_origin( - _placed(parent_view, parent_record["offset"]), + parent_frame, kind, side=side, projection=projection, gap=gap, child=view, ) - # step past every view already placed, so a third view never lands on the second - width = view.bbox[2] - view.bbox[0] - for record in records[1:]: - x0, y0, x1, y1 = record["bbox"] - del y0, y1 - if lower_left[0] < x1 + gap and lower_left[0] + width > x0 - gap: - lower_left = (x0 - gap - width, lower_left[1]) + lower_left = _clear_of( + records, + lower_left, + view.bbox[2] - view.bbox[0], + view.bbox[3] - view.bbox[1], + parent_frame.bbox, + gap, + ) offset = _offset_for(view, lower_left) - drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation) + drawn = await draw_prims(backend, _to_wcs(view.prims, offset, origin, rotation)) marker_handles: list[str] = [] if kind == "detail": marker = detail_marker_prims(detail or {}) marker_drawn = await draw_prims( - backend, marker, at=parent_record["offset"], rotation=rotation + backend, _to_wcs(marker, parent_record["offset"], origin, rotation) ) marker_handles = marker_drawn["handles"] @@ -496,6 +690,8 @@ async def add_view(backend, part_id: str, kind: str, **kw) -> dict: "plane": _plane_to_json(plane), "detail": detail, }, + origin, + rotation, ) ) payload["views"] = records @@ -542,6 +738,47 @@ def _fits_for_view(part, view, fits: dict, *, style: str, threshold: int) -> dic return {key: code for key, code in fits.items() if str(key) in available} +def _view_name(index: int, record: dict) -> str: + return f"{index} ({record.get('label') or record.get('kind')})" + + +def _dimension_targets(part_id: str, records: list[dict], views) -> list[int]: + """Which views this call dimensions - never one that already carries its dimensions. + + ISO 129-1: each dimension appears once. A view record carries a + ``dimensioned`` flag, so a second call (or `mech_dimension_part` after a + `mech_part_draw` that already dimensioned) adds only the views that have + none, and naming a dimensioned view is refused rather than stacked. + """ + if views is None: + targets = [i for i, record in enumerate(records) if not record.get("dimensioned")] + if not targets: + names = ", ".join(_view_name(i, r) for i, r in enumerate(records)) + raise ValueError( + f"{part_id}: every view is already dimensioned ({names}); each measurement " + "appears once (ISO 129-1). Add a view with mech_view_add, then dimension it." + ) + return targets + if isinstance(views, (str, bytes)) or not isinstance(views, (list, tuple)) or not views: + raise ValueError(f"views: expected a list of view indices such as [1], got {views!r}.") + targets: list[int] = [] + for n, value in enumerate(views): + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"views[{n}]: expected a view index (int), got {value!r}.") + if not 0 <= value < len(records): + names = ", ".join(_view_name(i, r) for i, r in enumerate(records)) + raise ValueError(f"views[{n}]: {value} is not a view of {part_id}; it has {names}.") + if value not in targets: + targets.append(value) + already = [_view_name(i, records[i]) for i in targets if records[i].get("dimensioned")] + if already: + raise ValueError( + f"views: {', '.join(already)} already dimensioned; each measurement appears once " + "(ISO 129-1), so dimensioning it again would stack a duplicate on every measurement." + ) + return targets + + async def dimension_part( backend, part_id: str, @@ -549,47 +786,88 @@ async def dimension_part( style: str = "chain", hole_table_threshold: int = 8, fits: dict | None = None, + views: list[int] | None = None, ) -> dict: - """Dimension every view of a part, laid out so no two texts overlap.""" + """Dimension the views of a part that carry no dimensions yet, laid out without overlaps. + + ``views`` names view indices (the order `read_part` lists them in); by + default every view not yet dimensioned is. A view already dimensioned is + never dimensioned again (ISO 129-1), and the flag that says so is written + back into the part payload. + """ payload = await read_part(backend, part_id) part = build_part(payload["part"]) - records = payload.get("views") or [] + records = list(payload.get("views") or []) + if not records: + raise ValueError(f"{part_id}: the payload carries no view to dimension.") if fits is not None and not isinstance(fits, dict): raise ValueError( f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}." ) fits = dict(fits or {}) + targets = _dimension_targets(part_id, records, views) + origin, rotation = _placement(payload) + threshold = int(hole_table_threshold) + + # Everything that can be refused is refused before the first DIMENSION is + # drawn: a fit no target view claims must not leave half a view dimensioned + # and unflagged behind it. + planned: list[tuple[int, View, list]] = [] unclaimed = set(fits) + for index in targets: + view = _view_from_record(part, payload, records[index]) + mine = _fits_for_view(part, view, fits, style=style, threshold=threshold) + unclaimed -= set(mine) + intents = dimension_intents( + part, view, style=style, hole_table_threshold=threshold, fits=mine or None + ) + planned.append((index, view, layout_dimensions(view, intents))) + if unclaimed: + # No view claimed these keys. Hand them back to the module that owns the + # vocabulary so the refusal names the keys that do exist, and the hole + # table's own message survives. + dimension_intents( + part, + planned[0][1], + style=style, + hole_table_threshold=threshold, + fits={key: fits[key] for key in sorted(unclaimed)}, + ) handles: list[str] = [] resolved_rows: list[dict] = [] skipped: list[dict] = [] table_handle: str | None = None - for record in records: - view = _view_from_record(part, payload, record) - mine = _fits_for_view(part, view, fits, style=style, threshold=int(hole_table_threshold)) - unclaimed -= set(mine) - intents = dimension_intents( - part, view, style=style, hole_table_threshold=hole_table_threshold, fits=mine or None - ) - placed = layout_dimensions(view, intents) + for index, view, placed in planned: + record = records[index] offset = record["offset"] for intent in placed: - moved = intent._replace( + frame = intent._replace( p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]), p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]), text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]), ) - handle, row = await _draw_dimension(backend, moved) + angle = None + if intent.kind == "linear": + # the measuring direction is decided in the part's own frame and + # turned with it; the rotated points alone no longer show it + horizontal = abs(frame.p2[1] - frame.p1[1]) <= abs(frame.p2[0] - frame.p1[0]) + angle = (0.0 if horizontal else 90.0) + rotation + moved = frame._replace( + p1=_turn(frame.p1, origin, rotation), + p2=_turn(frame.p2, origin, rotation), + text_at=_turn(frame.text_at, origin, rotation), + ) + handle, row = await _draw_dimension(backend, moved, linear_angle=angle) if handle is None: skipped.append(row) continue handles.append(handle) resolved_rows.append(row) - rows = hole_table_rows(part, view, hole_table_threshold=hole_table_threshold) - if rows and table_handle is None: + rows = hole_table_rows(part, view, hole_table_threshold=threshold) + if rows and table_handle is None and not payload.get("hole_table"): info = await backend.entity_create_table( record["bbox"][2] + DEFAULT_GAP, record["bbox"][3], @@ -603,18 +881,26 @@ async def dimension_part( ) table_handle = info.handle handles.append(info.handle) + if placed or rows: + record["dimensioned"] = True + else: + # Said, not silently counted as done: the view stays unflagged, so a + # later call can dimension it once the dimension layer covers it. + skipped.append( + { + "view": _view_name(index, record), + "kind": view.kind, + "skipped": f"the dimension layer emits no intents for a {view.kind} view " + "in this build; the view is left undimensioned", + } + ) - if unclaimed: - # No view claimed these keys. Hand them back to the module that owns the - # vocabulary so the refusal names the keys that do exist, and the hole - # table's own message survives. - dimension_intents( - part, - _view_from_record(part, payload, records[0]), - style=style, - hole_table_threshold=hole_table_threshold, - fits={key: fits[key] for key in sorted(unclaimed)}, - ) + payload["views"] = records + if table_handle: + payload["hole_table"] = table_handle + await backend.entity_set_xdata( + part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"}) + ) return { "part_id": part_id, @@ -623,6 +909,7 @@ async def dimension_part( "resolved": resolved_rows, "skipped": skipped, "hole_table": table_handle, + "views_dimensioned": [i for i in targets if records[i].get("dimensioned")], "backend": backend.name, } @@ -735,12 +1022,21 @@ async def draw_from_spec(backend, spec: dict) -> dict: part = build_part(item.get("part") or {}) except ValueError as exc: raise ValueError(f"parts[{index}]: {exc}") from exc - at = item.get("at") or [0.0, 0.0] + at = item.get("at") + if at is None: + at = [0.0, 0.0] + if isinstance(at, (str, bytes)) or not isinstance(at, (list, tuple)) or len(at) != 2: + raise ValueError(f"parts[{index}]: at: expected [x, y], got {at!r}.") jobs.append( { "part": part, - "at": (float(at[0]), float(at[1])), - "rotation": float(item.get("rotation", 0.0)), + "at": ( + _number(at[0], f"parts[{index}]: at[0]", positive=False), + _number(at[1], f"parts[{index}]: at[1]", positive=False), + ), + "rotation": _number( + item.get("rotation", 0.0), f"parts[{index}]: rotation", positive=False + ), "views": tuple(item.get("views") or ("front",)), "projection": str(item.get("projection", "first")), "dimension": bool(item.get("dimension", True)), @@ -759,7 +1055,14 @@ async def draw_from_spec(backend, spec: dict) -> dict: begun = await backend.transaction_begin() if not isinstance(begun, dict) or not begun.get("ok"): - raise RuntimeError(f"mech_part_from_spec: could not open a transaction: {begun}") + # a refusal, not a crash: ValueError is what the tool layer turns into + # a ToolError, and nothing has been drawn yet + detail = begun.get("error") if isinstance(begun, dict) else begun + raise ValueError( + f"mech_part_from_spec: could not open a transaction ({detail}); nothing was drawn. " + "If one is already open, close it with transaction_commit or " + "transaction_rollback and retry." + ) drawn: list[dict] = [] try: diff --git a/server.py b/server.py index 0707d87..0871291 100644 --- a/server.py +++ b/server.py @@ -8161,13 +8161,25 @@ async def mech_part_draw( ), ], at_x: Annotated[ - float, Field(default=0.0, description="WCS X of the first view's lower-left") + float, + Field( + default=0.0, + description="WCS X of the first view's lower-left corner (the rotation pivot)", + ), ] = 0.0, at_y: Annotated[ - float, Field(default=0.0, description="WCS Y of the first view's lower-left") + float, + Field( + default=0.0, + description="WCS Y of the first view's lower-left corner (the rotation pivot)", + ), ] = 0.0, rotation: Annotated[ - float, Field(default=0.0, description="Degrees CCW applied to every view") + float, + Field( + default=0.0, + description="Degrees CCW; turns every view and its dimensions about (at_x, at_y)", + ), ] = 0.0, views: Annotated[ list[str] | None, @@ -8274,7 +8286,14 @@ async def mech_view_add( float, Field(default=20.0, description="Gap between this view and its parent (mm)") ] = 20.0, scale: Annotated[ - float, Field(default=1.0, description="View scale recorded on the view") + float, + Field( + default=1.0, + description=( + "Must be 1.0: views are drawn full size (a detail takes its scale in " + "detail.scale; a sheet scale is the viewport's)" + ), + ), ] = 1.0, ctx: Context = None, ) -> dict: @@ -8344,20 +8363,34 @@ async def mech_dimension_part( description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}", ), ] = None, + views: Annotated[ + list[int] | None, + Field( + default=None, + description=( + "View indices to dimension, in mech_part_inspect order " + "(default: every view not yet dimensioned)" + ), + ), + ] = None, ctx: Context = None, ) -> dict: """Every dimension the part and its features ask for, laid out without overlaps. Axial lengths are chained, stacked from a baseline or measured from an origin; diameters stack outside the silhouette; fillets get a radius. Each - measurement appears once (ISO 129-1). `fits` resolves an ISO 286 code + measurement appears once (ISO 129-1): every view record carries a + `dimensioned` flag, so after a `mech_part_draw` that dimensioned the front + view and a `mech_view_add`, this call dimensions only the new view, and + `views` naming a view already dimensioned is refused by name. `fits` resolves an ISO 286 code against the measured nominal through the repository's authored tables and appends the code to the dimension text; an explicit `tol` on a feature's own intent does the same through the ISO 129 path. Refused by name: a fit together with an explicit tolerance, a fit outside the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an - intent with no measurable nominal, an unknown style, an unknown part_id, and + intent with no measurable nominal, an unknown style, an unknown part_id, a + call when every view is already dimensioned (the message lists them), and `part_id=None` on a drawing that holds more than one part. An angular intent is reported in `skipped` rather than drawn, because a DimIntent carries no vertex. @@ -8374,6 +8407,7 @@ async def mech_dimension_part( style=style, hole_table_threshold=hole_table_threshold, fits=fits, + views=views, ) except ValueError as exc: raise ToolError(str(exc)) from exc diff --git a/tests/test_mech_draw.py b/tests/test_mech_draw.py index 6340e2b..800290b 100644 --- a/tests/test_mech_draw.py +++ b/tests/test_mech_draw.py @@ -281,3 +281,214 @@ async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monke monkeypatch.undo() assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) assert not await entities(backend, type_filter="LINE") + + +# -- review round: rotation, single dimensioning, honest refusals ------------- + +TWO_STEP = { + "kind": "revolved", + "name": "shaft", + "material": "steel", + "segments": [{"length": 20.0, "d_outer": 25.0}, {"length": 40.0, "d_outer": 30.0}], + "features": [], +} + +TUBE = { + "kind": "revolved", + "name": "tube", + "material": "steel", + "segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}], + "features": [], +} + + +def _geometry_bbox(backend): + xs: list[float] = [] + ys: list[float] = [] + for e in backend._doc.modelspace(): + if e.dxftype() == "LINE": + xs += [e.dxf.start.x, e.dxf.end.x] + ys += [e.dxf.start.y, e.dxf.end.y] + return [min(xs), min(ys), max(xs), max(ys)] + + +def _defpoints(backend): + return sorted( + (round(e.dxf.get(name).x, 6), round(e.dxf.get(name).y, 6)) + for e in backend._doc.modelspace().query("DIMENSION") + for name in ("defpoint2", "defpoint3") + if e.dxf.hasattr(name) + ) + + +def _turn90(point, about): + return (round(about[0] - (point[1] - about[1]), 6), round(about[1] + (point[0] - about[0]), 6)) + + +async def test_rotation_turns_geometry_record_and_dimensions_about_at(backend): + """rotation=90 about at=(100, 100): the part, its recorded box and every + DIMENSION defpoint are the rotation=0 drawing turned about `at`.""" + from backends.ezdxf_backend import EzdxfBackend + + flat = EzdxfBackend() + await flat.connect() + await flat.drawing_new() + try: + await draw_part(flat, build_part(TWO_STEP), at=(100.0, 100.0), rotation=0.0) + expected = sorted(_turn90(p, (100.0, 100.0)) for p in _defpoints(flat)) + finally: + await flat.disconnect() + + result = await draw_part(backend, build_part(TWO_STEP), at=(100.0, 100.0), rotation=90.0) + geometry = _geometry_bbox(backend) + assert geometry == pytest.approx([70.0, 100.0, 100.0, 172.0]) + assert result["views"][0]["bbox"] == pytest.approx(geometry) + read = await read_part(backend, result["part_id"]) + assert read["views"][0]["bbox"] == pytest.approx(geometry) + assert read["views"][0]["frame_bbox"] == pytest.approx([100.0, 100.0, 172.0, 130.0]) + assert expected + assert _defpoints(backend) == expected + + +async def test_a_view_added_to_a_rotated_part_turns_with_it(backend): + drawn = await draw_part( + backend, build_part(TWO_STEP), at=(0.0, 0.0), rotation=90.0, dimension=False + ) + added = await add_view(backend, drawn["part_id"], "side") + # first angle: the side view sits left of the front in the part's frame, + # which is BELOW it once the sheet is turned 90 degrees CCW + front, side = drawn["views"][0]["bbox"], added["view"]["bbox"] + assert side[3] <= front[1] + 1e-6 + + +async def test_draw_then_add_view_then_dimension_never_duplicates(backend): + drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0), dimension=True) + before = len(await entities(backend, type_filter="DIMENSION")) + assert before == drawn["dimensions"] > 0 + await add_view(backend, drawn["part_id"], "side") + result = await dimension_part(backend, drawn["part_id"]) + assert result["views_dimensioned"] == [1] + after = len(await entities(backend, type_filter="DIMENSION")) + assert after == before + result["count"] + assert result["count"] > 0 + + with pytest.raises(ValueError, match="already dimensioned"): + await dimension_part(backend, drawn["part_id"]) + with pytest.raises(ValueError, match=r"0 \(front\) already dimensioned"): + await dimension_part(backend, drawn["part_id"], views=[0]) + assert len(await entities(backend, type_filter="DIMENSION")) == after + + +async def test_dimension_part_twice_is_refused_not_stacked(backend): + drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False) + first = await dimension_part(backend, drawn["part_id"]) + with pytest.raises(ValueError, match="every view is already dimensioned"): + await dimension_part(backend, drawn["part_id"]) + assert len(await entities(backend, type_filter="DIMENSION")) == first["count"] + + +async def test_a_view_the_dimension_layer_cannot_dimension_is_reported_not_flagged(backend): + drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0)) + await add_view( + backend, + drawn["part_id"], + "detail", + detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"}, + ) + result = await dimension_part(backend, drawn["part_id"]) + assert result["views_dimensioned"] == [] + assert any(row.get("kind") == "detail" for row in result["skipped"]) + assert result["count"] == 0 + + +async def test_an_island_that_does_not_attach_removes_the_hatch_and_raises(backend, monkeypatch): + drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False) + real = backend.hatch_add_boundary + + async def refuse(handle, edges): + return await real(handle, []) + + monkeypatch.setattr(backend, "hatch_add_boundary", refuse) + with pytest.raises(ValueError, match="did not attach"): + await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"}, + ) + assert not await entities(backend, type_filter="HATCH") + + +async def test_an_engine_without_hatch_edge_paths_refuses_before_drawing(backend, monkeypatch): + from backends.base import CapabilityMap, FeatureCapability + from backends.capability import UnsupportedCapabilityError + + drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False) + count = len(await entities(backend)) + monkeypatch.setattr( + backend, + "capabilities", + lambda: CapabilityMap( + "com", {"hatch_edge_paths": FeatureCapability(False, reason="measured")} + ), + ) + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"}, + ) + assert excinfo.value.capability == "hatch_edge_paths" + assert len(await entities(backend)) == count + + +async def test_a_scale_that_would_only_be_recorded_is_refused(backend): + with pytest.raises(ValueError, match="full size"): + await draw_part(backend, build_part(PLATE), scale=0.5) + drawn = await draw_part(backend, build_part(PLATE), dimension=False) + with pytest.raises(ValueError, match="full size"): + await add_view(backend, drawn["part_id"], "side", scale=2.0) + + +async def test_a_second_added_view_steps_along_its_own_projection_axis(backend): + drawn = await draw_part( + backend, build_part(PLATE), at=(0.0, 0.0), dimension=False, projection="third" + ) + side = await add_view(backend, drawn["part_id"], "side") + section = await add_view( + backend, + drawn["part_id"], + "section", + plane={"p1": [0.0, 25.0], "p2": [80.0, 25.0], "label": "A"}, + ) + front = drawn["views"][0]["bbox"] + # third angle puts both to the RIGHT; the second steps further right, + # never back onto the front view + assert section["view"]["bbox"][0] >= side["view"]["bbox"][2] + 20.0 - 1e-6 + assert section["view"]["bbox"][0] > front[2] + + +async def test_a_top_view_sharing_the_x_range_is_not_shoved(backend): + drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False) + await add_view(backend, drawn["part_id"], "side") + top = await add_view(backend, drawn["part_id"], "top", gap=25.0) + front = drawn["views"][0]["bbox"] + assert top["view"]["bbox"][0] == pytest.approx(front[0]) + assert top["view"]["bbox"][2] == pytest.approx(front[2]) + + +@pytest.mark.parametrize("at", [[], [5.0], {"x": 1}, "0,0", [1.0, "a"]]) +async def test_a_malformed_at_is_refused_with_its_part_path(backend, at): + with pytest.raises(ValueError, match=r"parts\[0\]: at"): + await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": at}]}) + assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER) + + +async def test_a_transaction_that_cannot_open_is_a_value_error(backend, monkeypatch): + async def busy(): + return {"ok": False, "error": "a transaction is already open"} + + monkeypatch.setattr(backend, "transaction_begin", busy) + with pytest.raises(ValueError, match="could not open a transaction"): + await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": [0.0, 0.0]}]})