From 48eb057273d1819654c48dd823b377243f6d13d1 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 19:56:04 +0300 Subject: [PATCH] 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")