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