diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index b318f68..ffb6f43 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -83,6 +83,7 @@ from .views import ( normalise_plane, outline_prims, radial_profile, + section_profile_prims, section_view, silhouette_prims, ) @@ -155,6 +156,7 @@ __all__ = [ "radial_profile", "rotate", "scale", + "section_profile_prims", "section_view", "segment_bounds", "silhouette_prims", diff --git a/engineering/mech/features.py b/engineering/mech/features.py index 56e5e55..a9b8873 100644 --- a/engineering/mech/features.py +++ b/engineering/mech/features.py @@ -121,6 +121,111 @@ def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]: return tuple(out) +# -- cutting-line chords ----------------------------------------------------- +# +# A section plane meets a feature along a *chord*, not along the feature's +# bounding box. The difference is not cosmetic: a plane 4.9 mm off the centre +# of a 10 mm hole cuts a 1.990 mm chord, and the box would punch the whole +# 10 mm out of the cut face - a wrong hatched area and a wrong drawn outline at +# the same time. Every function here returns parameters measured along ``unit`` +# from ``p1``, on the infinite line; the caller clips them to its own leg. + + +def _box_around(center, radius: float) -> tuple[float, float, float, float]: + """The axis-aligned box of a circle - what ``removal_box`` reports, and + exactly why a cut face may not be built from it (see ``cut_spans``).""" + x, y = float(center[0]), float(center[1]) + return (x - radius, y - radius, x + radius, y + radius) + + +def _merge_spans(spans) -> tuple[tuple[float, float], ...]: + """Sorted, non-overlapping spans. A union, so a shape built from several + pieces (a slot is two circles and a rectangle) reports one interval.""" + items = sorted((float(a), float(b)) for a, b in spans if float(b) - float(a) > _EPS) + out: list[list[float]] = [] + for a, b in items: + if out and a <= out[-1][1] + _EPS: + out[-1][1] = max(out[-1][1], b) + else: + out.append([a, b]) + return tuple((a, b) for a, b in out) + + +def _box_chord(box, p1, unit) -> tuple[tuple[float, float], ...]: + """The exact chord of an axis-aligned rectangle (a slab clip, not a corner + projection: projecting the four corners onto the line reports the box's + whole shadow, which is only the chord when the line is parallel to a side).""" + if box is None: + return () + low, high = -math.inf, math.inf + for axis in (0, 1): + direction = float(unit[axis]) + lo, hi = float(box[axis]), float(box[axis + 2]) + origin = float(p1[axis]) + if abs(direction) < 1e-12: + if origin < lo - _EPS or origin > hi + _EPS: + return () + continue + a, b = (lo - origin) / direction, (hi - origin) / direction + if a > b: + a, b = b, a + low, high = max(low, a), min(high, b) + return ((low, high),) if high - low > _EPS else () + + +def _circle_chord(center, radius, p1, unit) -> tuple[tuple[float, float], ...]: + """``2*sqrt(r**2 - d**2)`` about the foot of the perpendicular - exact, and + empty when the line only grazes the circle.""" + dx, dy = float(center[0]) - float(p1[0]), float(center[1]) - float(p1[1]) + along = dx * unit[0] + dy * unit[1] + off = -dx * unit[1] + dy * unit[0] + inside = float(radius) * float(radius) - off * off + if inside <= _EPS: + return () + half = math.sqrt(inside) + return ((along - half, along + half),) + + +def _rotated_box_chord(center, half_u, half_v, angle_deg, p1, unit): + """The exact chord of a rectangle rotated about its own centre: the line is + taken into the rectangle's frame and slab-clipped there.""" + theta = math.radians(float(angle_deg)) + cos, sin = math.cos(theta), math.sin(theta) + dx, dy = float(p1[0]) - float(center[0]), float(p1[1]) - float(center[1]) + origin = (dx * cos + dy * sin, -dx * sin + dy * cos) + direction = (unit[0] * cos + unit[1] * sin, -unit[0] * sin + unit[1] * cos) + return _box_chord((-half_u, -half_v, half_u, half_v), origin, direction) + + +def _polygon_chord(points, p1, unit, *, what: str = "the removed outline"): + """The intervals of the line that lie inside a simple polygon. + + Crossings are paired in order, exact for a polygon the line crosses + transversally. A line through a vertex gives an odd count and is refused + rather than paired wrong - the same rule the outline itself follows. + """ + ts: list[float] = [] + count = len(points) + for index in range(count): + ax, ay = points[index] + bx, by = points[(index + 1) % count] + ex, ey = bx - ax, by - ay + denominator = ex * unit[1] - ey * unit[0] + if abs(denominator) < 1e-12: + continue + s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator + if -1e-9 <= s <= 1.0 + 1e-9: + px, py = ax + s * ex, ay + s * ey + ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1]) + ts = sorted({round(t, 9) for t in ts}) + if len(ts) % 2 != 0: + raise ValueError( + f"section: the cutting plane grazes a vertex of {what}, so its crossings cannot " + "be paired. Move the plane off the vertex rather than accept a guessed cut face." + ) + return tuple((ts[i], ts[i + 1]) for i in range(0, len(ts), 2)) + + def _number(value: Any, path: str, *, positive: bool = True) -> float: try: number = float(value) @@ -289,6 +394,38 @@ class _Base: def removal_box(self, part) -> tuple[float, float, float, float] | None: return None + def cut_spans(self, part, p1, unit) -> tuple[tuple[float, float], ...]: + """Where a cutting line lies inside the material this feature removes. + + Parameters along ``unit`` measured from ``p1``, in the front view's own + plane. The default is the exact chord of ``removal_box``, which is the + truth for a feature whose removal really is a rectangle. A feature + whose removal is a circle or an obround **must** override this: the + chord of a hole shrinks to nothing as the plane leaves its centre, + while its bounding box stays the full diameter wide, so a box-based cut + face shows an 8 mm gap through solid material where the plane merely + grazes the hole. + """ + return _box_chord(self.removal_box(part), p1, unit) + + def cross_section(self, part, station: float) -> dict | None: + """This feature's shape in a **transverse** cut face at ``station``. + + ``{"start_deg", "end_deg", "path", "radius"}`` - the outer boundary of + the cut face keeps its arc from ``start_deg`` counter-clockwise to + ``end_deg`` (the material that survives the feature) and is closed by + ``path``, a polyline running from the point at ``start_deg`` to the + point at ``end_deg``, which the engine walks backwards to close the + loop. ``radius`` is the outside radius the record was built on, so the + engine can refuse to graft it onto a face of another size rather than + draw a notch that does not reach the surface. + + ``None`` means the feature has no transverse projection here. If the + plane really passes through it, the honesty rule reports it instead of + hatching over it. + """ + return None + def radial_edit(self, part) -> tuple[dict, ...]: return () @@ -559,6 +696,27 @@ class Keyway(_Base): f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm." ) + def _notch(self, part) -> dict: + """The notch the slot cuts out of the round section, once. + + The end view draws it and a transverse cut face closes on it, so both + come out of this one calculation and cannot disagree. ``Arc`` is CCW + from start to end, so the shaft that SURVIVES the slot runs from the + left notch wall the long way round to the right one; emitting + (right -> left) would draw the ~31 degree cap over the opening instead + of the ~329 degrees of material. + """ + _xa, _xb, radius, width, depth = self._span(part) + half = width / 2.0 + top = math.sqrt(max(radius * radius - half * half, 0.0)) + floor = radius - depth + return { + "radius": radius, + "start_deg": math.degrees(math.atan2(top, -half)) % 360.0, + "end_deg": math.degrees(math.atan2(top, half)) % 360.0, + "path": ((-half, top), (-half, floor), (half, floor), (half, top)), + } + def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]: part = _require_revolved(part, "Keyway") xa, xb, radius, width, depth = self._span(part) @@ -570,23 +728,22 @@ class Keyway(_Base): Line((xb, floor), (xb, radius), "visible"), ) if view == "side": - half = width / 2.0 - top = math.sqrt(max(radius * radius - half * half, 0.0)) - floor = radius - depth - right = math.degrees(math.atan2(top, half)) % 360.0 - left = math.degrees(math.atan2(top, -half)) % 360.0 - # `Arc` is CCW from start to end, so the shaft that SURVIVES the slot - # runs from the left notch wall the long way round to the right one. - # Emitting (right -> left) would draw the ~31 degree cap over the - # opening instead of the ~329 degrees of material. + notch = self._notch(part) + path = notch["path"] return ( - Arc((0.0, 0.0), radius, left, right, "visible"), - Line((-half, top), (-half, floor), "visible"), - Line((-half, floor), (half, floor), "visible"), - Line((half, floor), (half, top), "visible"), + Arc((0.0, 0.0), radius, notch["start_deg"], notch["end_deg"], "visible"), + *(Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)), ) return () + def cross_section(self, part, station: float) -> dict | None: + """The notch, when the transverse plane falls inside the slot's length.""" + part = _require_revolved(part, "Keyway") + xa, xb, _radius, _width, _depth = self._span(part) + if not xa - _EPS <= float(station) <= xb + _EPS: + return None + return self._notch(part) + def removal_box(self, part): xa, xb, radius, _, depth = self._span(part) return (xa, radius - depth, xb, radius) @@ -1543,10 +1700,26 @@ class Hole(_Base): return () def removal_box(self, part): - x, y = float(self.x), float(self.y) + return _box_around(self._centre(), self._outer_radius()) + + def _centre(self) -> tuple[float, float]: + return (float(self.x), float(self.y)) + + def _outer_radius(self) -> float: + """The widest circle the plane can meet: the drill, or a counterbore or + countersink that is wider than it.""" extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None] - outer = max([float(self.diameter) / 2.0, *extras]) - return (x - outer, y - outer, x + outer, y + outer) + return max([float(self.diameter) / 2.0, *extras]) + + def cut_spans(self, part, p1, unit): + """The true chord of the drilled circle, not the square that bounds it. + + On an 80x50x8 plate with a 10 mm hole at (40, 25): a plane at y=29.9 + grazes the hole and cuts a 1.990 mm chord, leaving 624.08 mm2 of cut + face. The bounding box reports 10 mm at every offset and hatches + 560 mm2 - a 64 mm2 error, with an 8 mm gap drawn through solid metal. + """ + return _circle_chord(self._centre(), self._outer_radius(), p1, unit) def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: if view != "front": @@ -1659,6 +1832,22 @@ class Slot(_Base): ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half) return (min(xs), min(ys), max(xs), max(ys)) + def cut_spans(self, part, p1, unit): + """The true chord of the obround: the union of its two end circles and + the rectangle between them. The bounding box of an angled slot is far + wider than the slot itself, so the box would erase material the slot + never touches.""" + (c1, c2, half) = self._ends() + middle = ((c1[0] + c2[0]) / 2.0, (c1[1] + c2[1]) / 2.0) + run = math.dist(c1, c2) + return _merge_spans( + [ + *_circle_chord(c1, half, p1, unit), + *_circle_chord(c2, half, p1, unit), + *_rotated_box_chord(middle, run / 2.0, half, float(self.angle), p1, unit), + ] + ) + def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]: if view != "front": return () @@ -1740,6 +1929,16 @@ class Pocket(_Base): ) return () + def cut_spans(self, part, p1, unit): + """The chord of the pocket's own outline, not of the box around it: a + pocket is only rectangular when its outline is.""" + return _polygon_chord( + tuple((float(x), float(y)) for x, y in self.outline), + p1, + unit, + what=f"pocket {self.id!r}", + ) + def removal_box(self, part): xs = [float(x) for x, _ in self.outline] ys = [float(y) for _, y in self.outline] diff --git a/engineering/mech/views.py b/engineering/mech/views.py index 313e469..2666078 100644 --- a/engineering/mech/views.py +++ b/engineering/mech/views.py @@ -29,8 +29,6 @@ from engineering.mech.features import mirror_about_x from engineering.mech.part import ( PrismaticPart, RevolvedPart, - inner_radius_at, - outer_radius_at, outline_bbox, part_length, part_max_diameter, @@ -259,24 +257,18 @@ def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = return tuple(prims), used -def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: - """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" - bands = radial_profile(part) +def _corner_edits(part: RevolvedPart) -> list[dict]: + """Every feature's silhouette corner substitution, tagged with its owner.""" edits: list[dict] = [] for feature in part.features: edit = feature.corner_edit(part) if edit is not None: edits.append({**edit, "owner": feature.id}) + return edits - upper, used = _emit_path(_outer_path(bands), edits, "visible") - prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) - for run in _bore_path(bands): - bore, _ = _emit_path(run, [], "hidden") - prims.extend(bore) - prims.extend(mirror_about_x(bore)) - - unplaced = tuple( +def _unplaced(edits: list[dict], used: set[str]) -> tuple[dict, ...]: + return tuple( { "feature": edit["owner"], "reason": ( @@ -287,7 +279,54 @@ def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, for edit in edits if edit["owner"] not in used ) - return tuple(prims), unplaced + + +def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The mirrored half-outline (end faces included), the bore, and any unplaced edit.""" + bands = radial_profile(part) + edits = _corner_edits(part) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "hidden") + prims.extend(bore) + prims.extend(mirror_about_x(bore)) + + return tuple(prims), _unplaced(edits, used) + + +def section_profile_prims( + part: RevolvedPart, *, half: bool = False +) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: + """The longitudinal **cut** outline of a revolved part. + + Every edge of this outline lies in the cutting plane, so every edge is a + continuous line: ISO 128-50 bounds a surface lying in the cutting plane + with a continuous wide line, and ISO 128-3 leaves hidden detail out of a + sectional view altogether. The bore wall is the case that matters - the + un-cut silhouette draws it dashed because material hides it, but in a + section it *is* the cut face's own boundary, and drawing the hatch loop and + a dashed line on the same coordinates is the one thing a section must not + do. ``half=True`` keeps the dashed bore on the -Y half, which is the half a + half section does not cut. + """ + bands = radial_profile(part) + edits = _corner_edits(part) + + upper, used = _emit_path(_outer_path(bands), edits, "visible") + prims: list[Prim] = list(upper) + list(mirror_about_x(upper)) + + for run in _bore_path(bands): + bore, _ = _emit_path(run, [], "visible") + prims.extend(bore) + mirrored = mirror_about_x(bore) + if half: + mirrored = tuple(prim._replace(role="hidden") for prim in mirrored) + prims.extend(mirrored) + + return tuple(prims), _unplaced(edits, used) def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]: @@ -720,6 +759,54 @@ def _inner_path(bands: tuple[Band, ...]) -> list[Pt]: return points +def _arc_loop_points( + radius: float, start_deg: float, end_deg: float, *, center: Pt = (0.0, 0.0) +) -> tuple[Pt, ...]: + """An arc as points, at the same 2 degree chord ``CIRCLE_SEGMENTS`` states. + + Endpoints are exact, so a loop built from an arc plus a polyline closes on + the polyline's own corners. + """ + sweep = (float(end_deg) - float(start_deg)) % 360.0 + if sweep <= _EPS: + sweep = 360.0 + count = max(1, math.ceil(sweep / (360.0 / CIRCLE_SEGMENTS) - 1e-9)) + out: list[Pt] = [] + for step in range(count + 1): + angle = math.radians(float(start_deg) + sweep * step / count) + out.append((center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle))) + return tuple(out) + + +def _flatten_path(prims) -> tuple[Pt, ...]: + """A contiguous run of ``Line``/``Arc`` primitives as a point path. + + This is what makes the drawn outline and the hatched loop **one** + calculation rather than two that agree by inspection: the loop is the + emitted outline, flattened, so a chamfer or a fillet that substitutes a + silhouette corner substitutes it in the cut face too. + """ + points: list[Pt] = [] + + def push(point: Pt) -> None: + pt = (float(point[0]), float(point[1])) + if not points or math.dist(points[-1], pt) > _EPS: + points.append(pt) + + for prim in prims: + if isinstance(prim, Line): + push(prim.p1) + push(prim.p2) + elif isinstance(prim, Arc): + for point in _arc_loop_points( + prim.radius, prim.start_deg, prim.end_deg, center=prim.center + ): + push(point) + else: # pragma: no cover - only an outline path is ever flattened + raise TypeError(f"_flatten_path: {type(prim).__name__} is not a path primitive.") + return tuple(points) + + def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]: steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS) return tuple( @@ -779,17 +866,6 @@ def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]: return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)] -def _box_span(box, p1: Pt, unit: Pt, normal: Pt): - xs = (box[0], box[2]) - ys = (box[1], box[3]) - corners = [(x, y) for x in xs for y in ys] - distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners] - if min(distances) > 1e-9 or max(distances) < -1e-9: - return None - spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners] - return (min(spans), max(spans)) - - def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]: """The cutting legs of an offset plane. @@ -815,7 +891,6 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): if length < 1e-9: continue unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length) - normal = (-unit[1], unit[0]) intervals = _chords(part.outline, start, unit) intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9] intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals] @@ -823,19 +898,20 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): pockets: list[tuple[tuple[float, float], float, bool]] = [] for feature in part.features: - box = feature.removal_box(part) - if box is None: - continue - span = _box_span(box, start, unit, normal) - if span is None: - continue - span = (max(span[0], 0.0), min(span[1], length)) - if span[1] - span[0] <= 1e-9: - continue - if isinstance(feature, _PocketType): - pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch)) - else: - intervals = _subtract(intervals, span) + # the feature's own chord, never the box around it: a box punches + # the full width of a hole or an angled slot into the cut face + # wherever the plane passes, which is a wrong hatched area and a + # wrong drawn outline at the same time. + for span in feature.cut_spans(part, start, unit): + span = (max(span[0], 0.0), min(span[1], length)) + if span[1] - span[0] <= 1e-9: + continue + if isinstance(feature, _PocketType): + pockets.append( + (span, thickness - float(feature.depth), feature.no_section_hatch) + ) + else: + intervals = _subtract(intervals, span) for pocket_span, floor, unhatched in pockets: intervals = _subtract(intervals, pocket_span) @@ -854,6 +930,131 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str): return rectangles +def _band_at(bands: tuple[Band, ...], station: float) -> Band | None: + for band in bands: + if band.x0 - _TOL <= station <= band.x1 + _TOL: + return band + return None + + +def _represented_by_the_profile(feature, part, station: float) -> bool: + """True when the radial profile already carries this feature at ``station``. + + A groove, an undercut, an axial bore or an ISO 6410 internal thread edits + the profile, and the transverse cut face is read off that profile - so the + feature is in the face already and must not be reported missing from it. + """ + for edit in feature.radial_edit(part): + if float(edit["x0"]) - _TOL <= station <= float(edit["x1"]) + _TOL: + return True + return False + + +def _transverse_face(part: RevolvedPart, station: float) -> dict: + """One transverse cut face: its boundary primitives, its loops, its omissions. + + The radii come from ``radial_profile``, the same one calculation the + silhouette and the longitudinal cut face come out of, so a groove or an + internal thread at that station is in the face without being asked for. + A feature that is *not* axisymmetric is asked for its own ``cross_section`` + notch; one that the plane really passes through and that has no such record + is reported rather than hatched over - a keyed shaft whose cross section + came out as a plain hatched disc is exactly the drawing the honesty rule + exists to prevent. + """ + bands = radial_profile(part) + band = _band_at(bands, station) + if band is None: + raise ValueError( + f"section: the plane at x={station:g} is outside {part.name!r} " + f"(0 .. {part_length(part):g} mm)." + ) + span = band.x1 - band.x0 + fraction = 0.0 if span <= _EPS else (station - band.x0) / span + r_out = band.r_out0 + fraction * (band.r_out1 - band.r_out0) + r_in = band.r_in + + notch: dict | None = None + omitted: list[dict] = [] + for feature in part.features: + record = feature.cross_section(part, station) + if record is not None: + if notch is not None: + omitted.append( + { + "feature": feature.id, + "reason": ( + "the cut face already carries another feature's notch; this " + "engine cuts one notch per transverse face" + ), + } + ) + continue + measured = float(record["radius"]) + if abs(measured - r_out) > _TOL: + omitted.append( + { + "feature": feature.id, + "reason": ( + f"its notch was measured on a {2.0 * measured:g} mm surface but " + f"the cut face at x={station:g} is {2.0 * r_out:g} mm, so the " + "notch would not reach it" + ), + } + ) + continue + notch = record + continue + if _represented_by_the_profile(feature, part, station): + continue + box = feature.removal_box(part) + if box is not None and float(box[0]) - _TOL <= station <= float(box[2]) + _TOL: + omitted.append( + { + "feature": feature.id, + "reason": ( + f"the cutting plane at x={station:g} passes through it, but a " + f"{type(feature).__name__} has no transverse cut-face projection" + ), + } + ) + else: + omitted.append( + { + "feature": feature.id, + "reason": f"it is not on the cutting plane at x={station:g}", + } + ) + + if notch is None: + loops: list[tuple[tuple[Pt, ...], bool]] = [(_circle_loop(r_out), True)] + prims: list[Prim] = [Circle((0.0, 0.0), r_out, "visible")] + else: + path = tuple((float(x), float(y)) for x, y in notch["path"]) + start, end = float(notch["start_deg"]), float(notch["end_deg"]) + # the material arc plus the notch walls: one boundary, and the loop is + # that same boundary sampled, so the hatch cannot cover the notch. The + # arc ends where the path ends, so the path is walked backwards to + # close the loop, and both of its endpoints are already on the arc. + closing = tuple(reversed(path))[1:-1] + loops = [(_arc_loop_points(r_out, start, end) + closing, True)] + prims = [Arc((0.0, 0.0), r_out, start, end, "visible")] + prims.extend( + Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1) + ) + if r_in > _EPS: + loops.append((_circle_loop(r_in, reverse=True), True)) + prims.append(Circle((0.0, 0.0), r_in, "visible")) + + return { + "r_out": r_out, + "r_in": r_in, + "loops": tuple(loops), + "prims": tuple(prims), + "omitted": tuple(omitted), + } + + def cut_loops(part, plane: dict, *, style: str = "full"): """The closed cut-face loops, each flagged with whether it is hatched.""" plane = normalise_plane(plane) @@ -869,7 +1070,10 @@ def cut_loops(part, plane: dict, *, style: str = "full"): "section: an offset plane has no meaning along a revolved part's axis; " "use style='full' (or a transverse plane for a cross section)." ) - upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands))) + # the loop is the *emitted* outline flattened, so a chamfer or a + # fillet is cut out of the hatched area exactly where it is drawn + outline, _used = _emit_path(_outer_path(bands), _corner_edits(part), "visible") + upper = _flatten_path(outline) + tuple(reversed(_inner_path(bands))) loops = [(upper, True)] if name != "half": loops.append((tuple((x, -y) for x, y in upper), True)) @@ -879,13 +1083,7 @@ def cut_loops(part, plane: dict, *, style: str = "full"): "section: a half section is defined on a longitudinal plane through the axis; " "a transverse plane takes style='full' or 'revolved'." ) - station = plane["p1"][0] - r_out = outer_radius_at(part, station) - r_in = inner_radius_at(part, station) - loops = [(_circle_loop(r_out), True)] - if r_in > _EPS: - loops.append((_circle_loop(r_in, reverse=True), True)) - return tuple(loops) + return _transverse_face(part, plane["p1"][0])["loops"] if name in ("half", "revolved"): raise ValueError( @@ -920,7 +1118,7 @@ def section_view( dims: list[DimIntent] = [] if isinstance(part, RevolvedPart) and _is_longitudinal(plane): - body, unplaced = silhouette_prims(part) + body, unplaced = section_profile_prims(part, half=name == "half") prims.extend(body) prims.extend(_axis_prims(part)) omitted.extend(unplaced) @@ -932,13 +1130,10 @@ def section_view( dims.extend(feature.dims(part, "front", ctx)) elif isinstance(part, RevolvedPart): role = "phantom" if name == "revolved" else "visible" - station = plane["p1"][0] - r_out = outer_radius_at(part, station) - r_in = inner_radius_at(part, station) - prims.append(Circle((0.0, 0.0), r_out, role)) - if r_in > _EPS: - prims.append(Circle((0.0, 0.0), r_in, role)) - over = r_out * (1.0 + AXIS_OVERRUN) + face = _transverse_face(part, plane["p1"][0]) + prims.extend(prim._replace(role=role) for prim in face["prims"]) + omitted.extend(face["omitted"]) + over = face["r_out"] * (1.0 + AXIS_OVERRUN) prims.append(Line((-over, 0.0), (over, 0.0), "center")) prims.append(Line((0.0, -over), (0.0, over), "center")) else: diff --git a/tests/test_mech_sections.py b/tests/test_mech_sections.py index 9023ddd..7c40863 100644 --- a/tests/test_mech_sections.py +++ b/tests/test_mech_sections.py @@ -12,9 +12,19 @@ import math import pytest -from engineering.mech.features import AxialBore, Hole, Pocket, Thread +from engineering.mech.features import ( + AxialBore, + Chamfer, + CrossHole, + Hole, + Keyway, + Pocket, + RetainingGroove, + Slot, + Thread, +) from engineering.mech.part import PrismaticPart, RevolvedPart, Segment -from engineering.mech.primitives import Circle, HatchArea, Text +from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Text from engineering.mech.views import ( CIRCLE_SEGMENTS, SECTION_PLANE, @@ -124,6 +134,55 @@ def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter(): assert min(y for x, y in upper if x > 25.0 + EPS) == pytest.approx(8.0) +def test_a_section_draws_its_own_cut_boundary_continuous_and_never_dashed(): + """ISO 128-50: a surface lying in the cutting plane is bounded by a + continuous wide line, and ISO 128-3 leaves hidden detail out of a sectional + view. The bore wall is the case that catches a section built out of the + un-cut silhouette: there the material hides the bore, so the silhouette + draws it dashed - but y = +-10 is the hatch loop's own boundary, and a + dashed line drawn on the hatch boundary means the outline and the hatched + area disagree at identical coordinates. + """ + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "section", plane=axis_plane(), style="full") + assert "hidden" not in {getattr(prim, "role", None) for prim in view.prims} + bore = [ + line + for line in view.prims + if isinstance(line, Line) + and abs(abs(line.p1[1]) - 10.0) < EPS + and abs(abs(line.p2[1]) - 10.0) < EPS + ] + assert len(bore) == 2 + assert {line.role for line in bore} == {"visible"} + (upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full") + assert min(y for _, y in upper) == pytest.approx(10.0) + assert max(y for _, y in lower) == pytest.approx(-10.0) + + +def test_a_half_section_keeps_its_dashed_bore_only_on_the_half_it_does_not_cut(): + part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) + view = build_view(part, "section", plane=axis_plane(style="half"), style="half") + hidden = [prim for prim in view.prims if getattr(prim, "role", None) == "hidden"] + assert hidden, "the un-sectioned half still shows its bore, dashed" + assert all(line.p1[1] < 0.0 and line.p2[1] < 0.0 for line in hidden) + assert all(abs(abs(line.p1[1]) - 10.0) < EPS for line in hidden) + # the cut half carries the hatch, and nothing dashed on it + ((upper, _),) = cut_loops(part, axis_plane(style="half"), style="half") + assert min(y for _, y in upper) == pytest.approx(10.0) + + +def test_a_chamfer_is_cut_out_of_the_hatched_area_exactly_where_it_is_drawn(): + """The loop is the emitted outline flattened, so a corner substitution + reaches the cut face too: a 2 mm 45 deg chamfer takes a 2 mm2 triangle off + the 1000 mm2 half face, and a loop built from the raw profile would hatch + over the chamfer it just drew.""" + (upper, _), _lower = cut_loops( + shaft(Chamfer(id="c1", at="start", size=2.0)), axis_plane(), style="full" + ) + assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0 - 2.0) + + def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material(): view = build_view(shaft(), "section", plane=axis_plane(), style="full") hatches = [p for p in view.prims if isinstance(p, HatchArea)] @@ -177,6 +236,66 @@ def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom(): assert {c.role for c in circles} == {"phantom"} +def keyed_shaft(*features) -> RevolvedPart: + """A plain 40 mm shaft; DIN 6885-1 gives its keyway 12 mm x 5 mm deep.""" + return RevolvedPart(name="keyed", segments=(Segment(60.0, 40.0),), features=tuple(features)) + + +def test_a_cross_section_through_a_keyway_cuts_the_notch_out_of_the_disc(): + """The commonest mechanical cross section there is. The plane at x=20 is + squarely inside the slot, so the face is a notched disc - not the plain + hatched circle a face built from the segment diameter alone produces.""" + keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0) + plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)}) + view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full") + assert view.omitted == () + arcs = [prim for prim in view.prims if isinstance(prim, Arc)] + assert len(arcs) == 1 and arcs[0].radius == pytest.approx(20.0) + assert not [prim for prim in view.prims if isinstance(prim, Circle)] + + radius, half, floor = 20.0, 6.0, 15.0 + top = math.sqrt(radius * radius - half * half) + # the disc, less the notch rectangle, less the cap the slot mouth removes + cap = radius * radius * math.acos(top / radius) - top * half + expected = math.pi * radius * radius - 2.0 * half * (top - floor) - cap + (hatch,) = [prim for prim in view.prims if isinstance(prim, HatchArea)] + assert polygon_area(hatch.loops[0]) == pytest.approx(expected, rel=2.1e-4) + # and it really is a notch: the whole disc is 56 mm2 larger + assert polygon_area(hatch.loops[0]) < math.pi * radius * radius - 50.0 + # the arc the face is bounded by is the arc the end view draws + assert arcs[0].start_deg == pytest.approx(math.degrees(math.atan2(top, -half)) % 360.0) + assert arcs[0].end_deg == pytest.approx(math.degrees(math.atan2(top, half)) % 360.0) + + +def test_a_cross_section_reports_the_feature_it_cannot_project(): + """The honesty rule, on the one view where silence is indistinguishable + from a correct drawing: the face is hatched either way.""" + cross = CrossHole(id="xh", x=20.0, diameter=6.0) + plane = normalise_plane({"p1": (20.0, -40.0), "p2": (20.0, 40.0)}) + view = build_view(keyed_shaft(cross), "section", plane=plane, style="full") + assert [entry["feature"] for entry in view.omitted] == ["xh"] + assert "transverse" in view.omitted[0]["reason"] + + +def test_a_feature_the_transverse_plane_misses_is_reported_as_off_the_plane(): + keyway = Keyway(id="kw", segment=0, length=30.0, offset=5.0) + plane = normalise_plane({"p1": (50.0, -40.0), "p2": (50.0, 40.0)}) + view = build_view(keyed_shaft(keyway), "section", plane=plane, style="full") + assert [entry["feature"] for entry in view.omitted] == ["kw"] + assert "not on the cutting plane" in view.omitted[0]["reason"] + + +def test_a_transverse_face_is_read_off_the_radial_profile_not_the_segment(): + """A groove is in the profile, so it is in the cross section - at its own + reduced diameter, and without being reported missing.""" + groove = RetainingGroove(id="rg", x=30.0, d=40.0, m=1.85, d2=37.5) + plane = normalise_plane({"p1": (30.0, -40.0), "p2": (30.0, 40.0)}) + view = build_view(keyed_shaft(groove), "section", plane=plane, style="full") + assert view.omitted == () + circles = [prim for prim in view.prims if isinstance(prim, Circle)] + assert {round(circle.radius, 6) for circle in circles} == {37.5 / 2.0} + + def test_a_half_transverse_section_is_refused_with_its_reason(): part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),)) plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)}) @@ -205,6 +324,44 @@ def test_a_through_hole_splits_the_cut_face_in_two(): assert total == pytest.approx((80.0 - 10.0) * 8.0) +@pytest.mark.parametrize("offset", [0.0, 4.0, 4.9]) +def test_a_hole_is_cut_on_its_chord_and_not_on_the_box_around_it(offset): + """The gap a hole punches in a cut face is the chord the plane actually + cuts, and it shrinks to nothing as the plane leaves the centre. Removing + the hole by its bounding box reports the full 10 mm diameter at every + offset: 32 mm2 of missing cut face at 4 mm off centre and 64 mm2 at 4.9, + with an 8 mm gap drawn through solid metal where the plane merely grazes + the hole.""" + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 25.0 + offset), "p2": (100.0, 25.0 + offset)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + chord = 2.0 * math.sqrt(5.0**2 - offset**2) + total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea)) + assert total == pytest.approx((80.0 - chord) * 8.0) + if offset > EPS: + assert total != pytest.approx((80.0 - 10.0) * 8.0) + + +def test_a_plane_tangent_to_a_hole_leaves_the_cut_face_whole(): + hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0) + plane = normalise_plane({"p1": (-10.0, 30.0), "p2": (100.0, 30.0)}) + view = build_view(plate(hole), "section", plane=plane, style="full") + hatches = [prim for prim in view.prims if isinstance(prim, HatchArea)] + assert len(hatches) == 1 + assert polygon_area(hatches[0].loops[0]) == pytest.approx(80.0 * 8.0) + + +def test_an_angled_slot_is_cut_on_its_obround_and_not_on_the_box_around_it(): + """A 45 degree slot's bounding box is 24.14 mm across the cutting line; the + slot itself is 14.14. The box would erase 10 mm of material the slot never + touches.""" + slot = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0, angle=45.0) + plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)}) + view = build_view(plate(slot), "section", plane=plane, style="full") + total = sum(polygon_area(prim.loops[0]) for prim in view.prims if isinstance(prim, HatchArea)) + assert total == pytest.approx((80.0 - 10.0 * math.sqrt(2.0)) * 8.0) + + def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness(): pocket = Pocket( id="p1",