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