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")