feat(task-5): sections, cut faces, material hatch and detail views

engineering/mech/views.py grows the two remaining view kinds. A section's cut
faces come out of the same radial profile the silhouette does, so the hatched
area and the drawn outline are one calculation and cannot disagree: a solid
shaft's half-section loop measures exactly the sum of its segment rectangles,
and a bore closes the loop on itself rather than being hatched over.

ISO 6410's section convention falls out of the model rather than being special-
cased: an internal thread's radial_edit already raises the bore to the major
diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the
feature - a pocket marked no_section_hatch is drawn and left unhatched, while
the rest of the cut face is hatched with the part's ISO 128-50 material.

Four styles, each either implemented or refused by name with the alternative
rather than approximated: full and half on a longitudinal plane through a
revolved axis; full and revolved on a transverse plane (the annulus, flattened
at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than
hidden); full and offset on a prismatic part, where through features split the
cut face and pockets reduce it to the remaining thickness. A plane that misses
the part, or that grazes an outline vertex so its crossings cannot be paired, is
refused instead of producing a guessed cut face.

Details scale a circular region of a parent view, emit the ISO 128-34 circle and
letter for the parent and the "A (5:1)" label for the detail itself, and report
every feature that falls outside the circle in omitted - the honesty rule again.

Three test assertions were corrected to the measured truth rather than the
engine bent to them, each verified by running the code:

* the ISO 6410 test asked for the whole cut-face loop's minimum to be the M20
  major (r=10). It is not, and must not be: over the 5 mm that is drilled but
  not threaded the loop closes on the real 16 mm drill (r=8). The loop is
  ((0,10),(0,25),(25,25),(30,25),(30,8),(30,8),(25,8),(25,10),(0,10)). The test
  now pins both spans, which is the convention it meant to assert.
* the transverse-annulus areas were pinned at rel=2e-4. An inscribed regular
  180-gon is exactly 1 - (n/2pi) sin(2pi/n) = 2.0307e-4 under the circle, so
  2e-4 rejects the very flattening CIRCLE_SEGMENTS declares. The tolerance is
  now 2.1e-4 and the test computes the deficit and pins it, so the number is
  derived rather than guessed. CIRCLE_SEGMENTS' docstring now states both
  figures: 0.015% radial (the 2 degree chord's sagitta) and 0.0203% of area.
* tests/test_mech_views.py pinned VIEW_KINDS == ("front","side","top"), which
  this task deliberately grows. It now pins the three orthographic kinds as the
  first three and the full five-kind tuple.

_is_longitudinal takes only the plane; the plan's signature also took the part
and never read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 21:40:29 +03:00
co-authored by Claude Opus 5
parent b2f858212f
commit 72bd17d024
4 changed files with 777 additions and 3 deletions
+16
View File
@@ -68,14 +68,22 @@ from .primitives import (
translate,
)
from .views import (
CIRCLE_SEGMENTS,
PROJECTIONS,
SECTION_PLANE,
SECTION_STYLES,
VIEW_KINDS,
Band,
View,
build_view,
cut_loops,
detail_marker_prims,
detail_view,
layout_origin,
normalise_plane,
outline_prims,
radial_profile,
section_view,
silhouette_prims,
)
@@ -84,6 +92,7 @@ __all__ = [
"AxialBore",
"Band",
"BendLine",
"CIRCLE_SEGMENTS",
"CentreHole",
"Chamfer",
"Circle",
@@ -114,6 +123,8 @@ __all__ = [
"RetainingGroove",
"RevolvedPart",
"Role",
"SECTION_PLANE",
"SECTION_STYLES",
"Segment",
"Slot",
"Text",
@@ -124,11 +135,15 @@ __all__ = [
"bbox",
"build_part",
"build_view",
"cut_loops",
"detail_marker_prims",
"detail_view",
"feature_from_dict",
"inner_radius_at",
"layer_for",
"layout_origin",
"mirror_about_x",
"normalise_plane",
"outer_radius_at",
"outline_bbox",
"outline_prims",
@@ -140,6 +155,7 @@ __all__ = [
"radial_profile",
"rotate",
"scale",
"section_view",
"segment_bounds",
"silhouette_prims",
"translate",
+441 -2
View File
@@ -24,10 +24,13 @@ import math
from dataclasses import dataclass
from typing import Any, NamedTuple
from engineering.mech.features import Pocket as _PocketType
from engineering.mech.features import mirror_about_x
from engineering.mech.part import (
PrismaticPart,
RevolvedPart,
inner_radius_at,
outer_radius_at,
outline_bbox,
part_length,
part_max_diameter,
@@ -44,7 +47,9 @@ from engineering.mech.primitives import (
Pt,
Text,
bbox,
translate,
)
from engineering.mech.primitives import scale as scale_prims
_EPS = 1e-9
_SNAP = 6 # decimals used to match a corner vertex to a corner edit
@@ -52,7 +57,7 @@ _SNAP = 6 # decimals used to match a corner vertex to a corner edit
#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices.
_TOL = 1e-6
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top")
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail")
PROJECTIONS: tuple[str, ...] = ("first", "third")
SIDES: tuple[str, ...] = ("right", "left")
@@ -60,6 +65,26 @@ SIDES: tuple[str, ...] = ("right", "left")
#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun).
AXIS_OVERRUN = 0.10
#: The cutting-plane record `section_line` (Task 11, group A) produces and this
#: engine consumes. `via` is optional and only meaningful for an offset plane.
SECTION_PLANE = {
"p1": (0.0, 0.0),
"p2": (0.0, 0.0),
"label": "A",
"direction": (0.0, -1.0),
"style": "full",
}
SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
#: A circular cut-face boundary has to be handed to the hatch as points. 180
#: segments is a 2 degree chord: the boundary is never further than
#: r * (1 - cos(1 deg)) = 0.015% of the radius from the true circle, and the
#: area of the inscribed 180-gon is 0.0203% under the circle's. Both figures are
#: stated rather than hidden, and the same loop is what the hatch and any area
#: check read, so they cannot disagree with each other.
CIRCLE_SEGMENTS = 180
@dataclass(frozen=True)
class View:
@@ -543,7 +568,10 @@ def build_view(
where = str(side or "right").strip().lower()
if where not in SIDES:
raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.")
del plane, style, detail # the orthographic kinds take no plane or detail
if name == "section":
return section_view(part, plane, style=style, scale=scale, side=where)
if name == "detail":
return detail_view(part, detail or {}, projection=projection, side=where)
ctx = {"side": where, "scale": float(scale), "projection": str(projection)}
prims: list[Prim] = []
@@ -648,3 +676,414 @@ def layout_origin(
f"layout_origin: kind {kind!r} has no projection position; expected "
"'side', 'top', 'section' or 'detail'."
)
# -- sections ----------------------------------------------------------------
def normalise_plane(plane: dict | None) -> dict:
"""Fill the `SECTION_PLANE` shape and refuse a plane that has no direction."""
if plane is None:
raise ValueError(
"section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, "
"or take one from section_line()."
)
if not isinstance(plane, dict):
raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.")
unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"})
if unknown:
raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.")
p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"]))
p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"]))
if math.dist(p1, p2) < 1e-6:
raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).")
style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower()
if style not in SECTION_STYLES:
raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.")
via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ()))
return {
"p1": p1,
"p2": p2,
"label": str(plane.get("label", SECTION_PLANE["label"])),
"direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])),
"style": style,
"via": via,
}
def _inner_path(bands: tuple[Band, ...]) -> list[Pt]:
points: list[Pt] = [(bands[0].x0, bands[0].r_in)]
for band in bands:
if math.dist(points[-1], (band.x0, band.r_in)) > _EPS:
points.append((band.x0, band.r_in))
points.append((band.x1, band.r_in))
return points
def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]:
steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS)
return tuple(
(
radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS),
radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS),
)
for k in steps
)
def _is_longitudinal(plane: dict) -> bool:
"""True when the cutting plane runs along the revolved axis (y = 0 at both ends)."""
return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6
def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]):
out: list[tuple[float, float]] = []
low, high = cut
for a, b in intervals:
if high <= a + _EPS or low >= b - _EPS:
out.append((a, b))
continue
if low > a + _EPS:
out.append((a, low))
if high < b - _EPS:
out.append((high, b))
return [(a, b) for a, b in out if b - a > 1e-9]
def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]:
"""Parameters along `unit` from `p1` where the line is inside the outline.
Crossings are paired in order, which is exact for a simple polygon whose
edges the line crosses transversally. A line that grazes a vertex produces
an odd crossing count and is refused by the caller rather than paired wrong.
"""
ts: list[float] = []
count = len(outline)
for index in range(count):
ax, ay = outline[index]
bx, by = outline[(index + 1) % count]
ex, ey = bx - ax, by - ay
denominator = ex * unit[1] - ey * unit[0]
if abs(denominator) < 1e-12:
continue
s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator
if -1e-9 <= s <= 1.0 + 1e-9:
px, py = ax + s * ex, ay + s * ey
ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1])
ts = sorted({round(t, 9) for t in ts})
if len(ts) % 2 != 0:
raise ValueError(
"section: the cutting plane grazes an outline vertex, so its crossings cannot be "
"paired. Move the plane off the vertex rather than accept a guessed cut face."
)
return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)]
def _box_span(box, p1: Pt, unit: Pt, normal: Pt):
xs = (box[0], box[2])
ys = (box[1], box[3])
corners = [(x, y) for x in xs for y in ys]
distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners]
if min(distances) > 1e-9 or max(distances) < -1e-9:
return None
spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners]
return (min(spans), max(spans))
def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]:
"""The cutting legs of an offset plane.
ISO 128-40: the offset itself is not drawn, so with ``via`` present the
odd-numbered legs are the transfer jogs and contribute nothing to the cut
face.
"""
points = [plane["p1"], *plane["via"], plane["p2"]]
legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)]
if plane["via"]:
legs = [leg for index, leg in enumerate(legs) if index % 2 == 0]
return legs
def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
"""``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane."""
legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])]
thickness = float(part.thickness)
rectangles: list[tuple[float, float, float, float, bool]] = []
origin = 0.0
for start, end in legs:
length = math.dist(start, end)
if length < 1e-9:
continue
unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length)
normal = (-unit[1], unit[0])
intervals = _chords(part.outline, start, unit)
intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9]
intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals]
intervals = [(a, b) for a, b in intervals if b - a > 1e-9]
pockets: list[tuple[tuple[float, float], float, bool]] = []
for feature in part.features:
box = feature.removal_box(part)
if box is None:
continue
span = _box_span(box, start, unit, normal)
if span is None:
continue
span = (max(span[0], 0.0), min(span[1], length))
if span[1] - span[0] <= 1e-9:
continue
if isinstance(feature, _PocketType):
pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch))
else:
intervals = _subtract(intervals, span)
for pocket_span, floor, unhatched in pockets:
intervals = _subtract(intervals, pocket_span)
rectangles.append(
(origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched)
)
for a, b in intervals:
rectangles.append((origin + a, origin + b, 0.0, thickness, True))
origin += length
if rectangles:
# the section view has its own frame: u = 0 at the first cut.
shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles)
rectangles = [
(u0 - shift, u1 - shift, v0, v1, hatched) for u0, u1, v0, v1, hatched in rectangles
]
return rectangles
def cut_loops(part, plane: dict, *, style: str = "full"):
"""The closed cut-face loops, each flagged with whether it is hatched."""
plane = normalise_plane(plane)
name = str(style or "full").strip().lower()
if name not in SECTION_STYLES:
raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.")
if isinstance(part, RevolvedPart):
bands = radial_profile(part)
if _is_longitudinal(plane):
if name == "offset":
raise ValueError(
"section: an offset plane has no meaning along a revolved part's axis; "
"use style='full' (or a transverse plane for a cross section)."
)
upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands)))
loops = [(upper, True)]
if name != "half":
loops.append((tuple((x, -y) for x, y in upper), True))
return tuple(loops)
if name == "half":
raise ValueError(
"section: a half section is defined on a longitudinal plane through the axis; "
"a transverse plane takes style='full' or 'revolved'."
)
station = plane["p1"][0]
r_out = outer_radius_at(part, station)
r_in = inner_radius_at(part, station)
loops = [(_circle_loop(r_out), True)]
if r_in > _EPS:
loops.append((_circle_loop(r_in, reverse=True), True))
return tuple(loops)
if name in ("half", "revolved"):
raise ValueError(
f"section: a {name} section is defined on a revolved part; a prismatic part takes "
"style='full' or 'offset'."
)
rectangles = _prismatic_cut(part, plane, name)
if not rectangles:
raise ValueError(
f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}."
)
return tuple(
(((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched)
for u0, u1, v0, v1, hatched in rectangles
)
def section_view(
part, plane: dict, *, style: str = "full", scale: float = 1.0, side: str = "right"
) -> View:
"""A cut view: the cut faces as hatch areas, plus the geometry behind them."""
from engineering.mech.standards.materials import hatch_for
plane = normalise_plane(plane)
name = str(style or plane["style"]).strip().lower()
hatch_for(part.material) # refuse an unknown material before anything is built
ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane}
loops = cut_loops(part, plane, style=name)
prims: list[Prim] = []
omitted: list[dict] = []
dims: list[DimIntent] = []
if isinstance(part, RevolvedPart) and _is_longitudinal(plane):
body, unplaced = silhouette_prims(part)
prims.extend(body)
prims.extend(_axis_prims(part))
omitted.extend(unplaced)
for feature in part.features:
own = feature.cut_prims(part, plane, ctx)
if own and getattr(feature, "axisymmetric", False):
own = tuple(own) + mirror_about_x(own)
prims.extend(own)
dims.extend(feature.dims(part, "front", ctx))
elif isinstance(part, RevolvedPart):
role = "phantom" if name == "revolved" else "visible"
station = plane["p1"][0]
r_out = outer_radius_at(part, station)
r_in = inner_radius_at(part, station)
prims.append(Circle((0.0, 0.0), r_out, role))
if r_in > _EPS:
prims.append(Circle((0.0, 0.0), r_in, role))
over = r_out * (1.0 + AXIS_OVERRUN)
prims.append(Line((-over, 0.0), (over, 0.0), "center"))
prims.append(Line((0.0, -over), (0.0, over), "center"))
else:
for points, _hatched in loops:
xs = [p[0] for p in points]
ys = [p[1] for p in points]
prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys)))
hatched = [points for points, flag in loops if flag]
if isinstance(part, RevolvedPart) and not _is_longitudinal(plane):
# a transverse cut face is one region with the bore as its island
if hatched:
prims.append(HatchArea(tuple(hatched), part.material))
else:
for points in hatched:
prims.append(HatchArea((points,), part.material))
label = f"{plane['label']}-{plane['label']}"
return View(
kind="section",
prims=tuple(prims),
dims=tuple(dims),
omitted=tuple(omitted),
bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
label=label,
scale=float(scale),
)
# -- detail views ------------------------------------------------------------
def _prim_bbox(prim) -> tuple[float, float, float, float]:
return bbox([prim])
def _touches(prim, center: Pt, radius: float) -> bool:
x0, y0, x1, y1 = _prim_bbox(prim)
nearest_x = min(max(center[0], x0), x1)
nearest_y = min(max(center[1], y0), y1)
return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9
def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float):
"""The part of the segment inside the circle, or None."""
dx, dy = p2[0] - p1[0], p2[1] - p1[1]
fx, fy = p1[0] - center[0], p1[1] - center[1]
a = dx * dx + dy * dy
if a < 1e-18:
return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None
b = 2.0 * (fx * dx + fy * dy)
c = fx * fx + fy * fy - radius * radius
discriminant = b * b - 4.0 * a * c
if discriminant <= 0.0:
return None
root = math.sqrt(discriminant)
t0 = max(0.0, (-b - root) / (2.0 * a))
t1 = min(1.0, (-b + root) / (2.0 * a))
if t1 - t0 <= 1e-9:
return None
return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy))
def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]:
"""Straight geometry is trimmed to the detail circle; curved geometry is kept
whole when it touches it.
Trimming an arc would mean solving its angular range against the circle, and
a detail that silently re-cuts an arc is worse than one that carries a
slightly long arc into the enlargement - so the rule is stated rather than
approximated, and a detail is meant to be drawn inside its circle.
"""
if isinstance(prim, Line):
clipped = _clip_segment(prim.p1, prim.p2, center, radius)
return (Line(clipped[0], clipped[1], prim.role),) if clipped else ()
if isinstance(prim, Poly):
points = list(prim.points)
if prim.closed and points:
points.append(points[0])
out: list[Prim] = []
for index in range(len(points) - 1):
clipped = _clip_segment(points[index], points[index + 1], center, radius)
if clipped:
out.append(Line(clipped[0], clipped[1], prim.role))
return tuple(out)
return (prim,) if _touches(prim, center, radius) else ()
def detail_marker_prims(detail: dict) -> tuple[Prim, ...]:
"""ISO 128-34 detail circle and its letter, for the **parent** view."""
center = tuple(float(v) for v in detail["center"])
radius = float(detail["radius"])
label = str(detail.get("label", "A"))
return (
Circle(center, radius, "visible"),
Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"),
)
def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View:
"""A circular region of a parent view, enlarged, with the ISO 128-34 labels.
Straight geometry is trimmed to the circle and curved geometry that touches
it is kept whole (see :func:`_clip_to_circle`); every feature whose own
primitives all fall outside is reported in ``omitted``.
"""
if not isinstance(detail, dict):
raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.")
if "radius" not in detail:
raise ValueError("detail.radius: a detail needs the radius of its circle.")
center = tuple(float(v) for v in detail.get("center", (0.0, 0.0)))
radius = float(detail["radius"])
if radius <= _EPS:
raise ValueError(f"detail.radius: must be greater than zero, got {radius}.")
factor = float(detail.get("scale", 5.0))
if factor <= _EPS:
raise ValueError(f"detail.scale: must be greater than zero, got {factor}.")
label = str(detail.get("label", "A"))
of = str(detail.get("of", "front")).strip().lower()
parent = build_view(part, of, side=side, projection=projection)
kept: list[Prim] = []
for prim in parent.prims:
kept.extend(_clip_to_circle(prim, center, radius))
kept = tuple(kept)
if not kept:
raise ValueError(
f"detail: the circle at {center} r={radius} contains no geometry of the {of} view."
)
moved = translate(kept, -center[0], -center[1])
enlarged = scale_prims(moved, factor, (0.0, 0.0))
omitted = list(parent.omitted)
for feature in part.features:
own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection})
if own and not any(_touches(prim, center, radius) for prim in own):
omitted.append({"feature": feature.id, "reason": f"outside the detail circle {label}"})
return View(
kind="detail",
prims=tuple(enlarged),
dims=(),
omitted=tuple(omitted),
bbox=bbox(enlarged),
label=f"{label} ({factor:g}:1)",
scale=factor,
)
+316
View File
@@ -0,0 +1,316 @@
"""Sections, cut faces, material hatch and detail views.
The cut face and the silhouette come out of the same radial profile, so the
tests assert that the hatched loop closes on the real geometry - including the
ISO 6410 rule that an internal thread's hatch runs to the major diameter, and
the ISO 128-3 rule that a rib cut longitudinally is not hatched.
"""
from __future__ import annotations
import math
import pytest
from engineering.mech.features import AxialBore, Hole, Pocket, Thread
from engineering.mech.part import PrismaticPart, RevolvedPart, Segment
from engineering.mech.primitives import Circle, HatchArea, Text
from engineering.mech.views import (
CIRCLE_SEGMENTS,
SECTION_PLANE,
SECTION_STYLES,
VIEW_KINDS,
build_view,
cut_loops,
detail_marker_prims,
normalise_plane,
radial_profile,
)
EPS = 1e-9
def shaft(*features) -> RevolvedPart:
return RevolvedPart(
name="shaft",
segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
features=tuple(features),
)
def plate(*features) -> PrismaticPart:
return PrismaticPart(
name="plate",
outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
thickness=8.0,
features=tuple(features),
)
def axis_plane(**kw) -> dict:
return normalise_plane({"p1": (0.0, 0.0), "p2": (100.0, 0.0), **kw})
def polygon_area(points) -> float:
total = 0.0
count = len(points)
for i in range(count):
x1, y1 = points[i]
x2, y2 = points[(i + 1) % count]
total += x1 * y2 - x2 * y1
return abs(total) / 2.0
def test_the_engine_now_declares_five_kinds_and_four_section_styles():
assert VIEW_KINDS == ("front", "side", "top", "section", "detail")
assert SECTION_STYLES == ("full", "half", "offset", "revolved")
assert set(SECTION_PLANE) == {"p1", "p2", "label", "direction", "style"}
def test_normalise_plane_fills_the_pinned_shape_and_refuses_a_zero_length_plane():
plane = normalise_plane({"p1": (0.0, 0.0), "p2": (10.0, 0.0)})
assert plane["label"] == "A"
assert plane["direction"] == (0.0, -1.0)
assert plane["style"] == "full"
with pytest.raises(ValueError, match="zero length"):
normalise_plane({"p1": (1.0, 1.0), "p2": (1.0, 1.0)})
# -- revolved longitudinal sections ------------------------------------------
def test_a_solid_shaft_full_section_hatches_both_halves_to_the_axis():
loops = cut_loops(shaft(), axis_plane(), style="full")
assert len(loops) == 2
assert all(hatched for _, hatched in loops)
upper, lower = (points for points, _ in loops)
assert max(y for _, y in upper) == 15.0
assert min(y for _, y in upper) == 0.0
assert min(y for _, y in lower) == -15.0
# the cut face is the real cross-section: 20x12.5 + 40x15 + 15x10 per half
assert polygon_area(upper) == pytest.approx(20 * 12.5 + 40 * 15.0 + 15 * 10.0)
def test_a_bore_is_not_hatched_because_the_loop_closes_on_it():
part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
(upper, _), (lower, _) = cut_loops(part, axis_plane(), style="full")
assert polygon_area(upper) == pytest.approx(30.0 * (20.0 - 10.0))
assert min(y for _, y in upper) == 10.0
assert max(y for _, y in lower) == -10.0
def test_a_half_section_hatches_one_half_only():
loops = cut_loops(shaft(), axis_plane(style="half"), style="half")
assert len(loops) == 1
assert min(y for _, y in loops[0][0]) == 0.0
def test_iso6410_an_internal_thread_hatch_runs_to_the_major_diameter():
thread = Thread(id="th", segment=0, designation="M20", length=25.0, internal=True)
# drilled to the ISO 6410 minor (0.8 x 20 = 16 mm); the hatch must stop at
# the 20 mm major, not at the drilled 16.
part = RevolvedPart(name="boss", segments=(Segment(30.0, 50.0, 16.0),), features=(thread,))
bands = radial_profile(part)
threaded = [b for b in bands if b.x1 <= 25.0 + EPS]
assert {round(b.r_in, 6) for b in threaded} == {10.0}
unthreaded = [b for b in bands if b.x0 >= 25.0 - EPS]
assert {round(b.r_in, 6) for b in unthreaded} == {8.0}
(upper, _), _ = cut_loops(part, axis_plane(), style="full")
# The cut face closes on the major over the threaded span - and on the real
# drilled bore over the 5 mm that is drilled but not threaded. Asserting the
# minimum over the *whole* loop would be asserting that the drill does not
# exist beyond the thread, which is a different (and wrong) drawing.
assert min(y for x, y in upper if x < 25.0 - EPS) == pytest.approx(10.0)
assert min(y for x, y in upper if x > 25.0 + EPS) == pytest.approx(8.0)
def test_the_section_view_carries_a_hatch_area_per_loop_with_the_parts_material():
view = build_view(shaft(), "section", plane=axis_plane(), style="full")
hatches = [p for p in view.prims if isinstance(p, HatchArea)]
assert len(hatches) == 2
assert {h.material for h in hatches} == {"steel"}
assert view.label == "A-A"
def test_an_unknown_material_is_refused_before_a_section_is_built():
part = RevolvedPart(name="x", segments=(Segment(10.0, 20.0),), material="unobtainium")
with pytest.raises(ValueError, match="unobtainium"):
build_view(part, "section", plane=axis_plane())
def test_an_offset_plane_on_a_revolved_part_is_refused_with_the_alternative():
with pytest.raises(ValueError) as excinfo:
build_view(shaft(), "section", plane=axis_plane(style="offset"), style="offset")
assert "style='full'" in str(excinfo.value)
# -- revolved transverse (cross) sections ------------------------------------
def test_a_transverse_section_hatches_the_annulus_at_that_station():
part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0), "label": "B"})
view = build_view(part, "section", plane=plane, style="full")
hatches = [p for p in view.prims if isinstance(p, HatchArea)]
assert len(hatches) == 1
outer, island = hatches[0].loops
assert len(outer) == CIRCLE_SEGMENTS and len(island) == CIRCLE_SEGMENTS
# An inscribed regular CIRCLE_SEGMENTS-gon is exactly
# 1 - (n / 2pi) sin(2pi / n) = 2.0307e-4 under the circle, so the tolerance
# is the flattening the constant declares and nothing looser.
flattening = 1.0 - (CIRCLE_SEGMENTS / (2.0 * math.pi)) * math.sin(
2.0 * math.pi / CIRCLE_SEGMENTS
)
assert flattening == pytest.approx(2.0307e-4, rel=1e-3)
assert polygon_area(outer) == pytest.approx(math.pi * 20.0**2, rel=2.1e-4)
assert polygon_area(island) == pytest.approx(math.pi * 10.0**2, rel=2.1e-4)
assert view.label == "B-B"
circles = [p for p in view.prims if isinstance(p, Circle)]
assert {round(c.radius, 6) for c in circles} == {20.0, 10.0}
def test_a_revolved_style_cross_section_draws_its_boundary_as_phantom():
part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)})
view = build_view(part, "section", plane=plane, style="revolved")
circles = [p for p in view.prims if isinstance(p, Circle)]
assert {c.role for c in circles} == {"phantom"}
def test_a_half_transverse_section_is_refused_with_its_reason():
part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
plane = normalise_plane({"p1": (15.0, -30.0), "p2": (15.0, 30.0)})
with pytest.raises(ValueError, match="half section"):
build_view(part, "section", plane=plane, style="half")
# -- prismatic sections ------------------------------------------------------
def test_a_prismatic_full_section_is_the_chord_times_the_thickness():
plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0), "label": "C"})
view = build_view(plate(), "section", plane=plane, style="full")
(hatch,) = [p for p in view.prims if isinstance(p, HatchArea)]
assert polygon_area(hatch.loops[0]) == pytest.approx(80.0 * 8.0)
assert view.bbox == (0.0, 0.0, 80.0, 8.0)
def test_a_through_hole_splits_the_cut_face_in_two():
hole = Hole(id="h1", x=40.0, y=25.0, diameter=10.0)
plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
view = build_view(plate(hole), "section", plane=plane, style="full")
hatches = [p for p in view.prims if isinstance(p, HatchArea)]
assert len(hatches) == 2
total = sum(polygon_area(h.loops[0]) for h in hatches)
assert total == pytest.approx((80.0 - 10.0) * 8.0)
def test_a_pocket_reduces_the_cut_face_to_the_remaining_thickness():
pocket = Pocket(
id="p1",
outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)),
depth=3.0,
)
plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
view = build_view(plate(pocket), "section", plane=plane, style="full")
hatches = [p for p in view.prims if isinstance(p, HatchArea)]
total = sum(polygon_area(h.loops[0]) for h in hatches)
assert total == pytest.approx(40.0 * 8.0 + 40.0 * 5.0)
def test_iso128_3_a_rib_cut_longitudinally_is_drawn_but_not_hatched():
rib = Pocket(
id="rib",
outline=((20.0, 15.0), (60.0, 15.0), (60.0, 35.0), (20.0, 35.0)),
depth=3.0,
no_section_hatch=True,
)
plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
loops = cut_loops(plate(rib), plane, style="full")
hatched = [points for points, flag in loops if flag]
unhatched = [points for points, flag in loops if not flag]
assert len(unhatched) == 1
assert polygon_area(unhatched[0]) == pytest.approx(40.0 * 5.0)
assert sum(polygon_area(p) for p in hatched) == pytest.approx(40.0 * 8.0)
view = build_view(plate(rib), "section", plane=plane, style="full")
assert len([p for p in view.prims if isinstance(p, HatchArea)]) == 2
def test_an_offset_prismatic_section_accumulates_along_the_dog_leg():
plane = normalise_plane(
{
"p1": (-10.0, 15.0),
"p2": (100.0, 35.0),
"via": [[30.0, 15.0], [30.0, 35.0]],
"style": "offset",
}
)
view = build_view(plate(), "section", plane=plane, style="offset")
hatches = [p for p in view.prims if isinstance(p, HatchArea)]
total = sum(polygon_area(h.loops[0]) for h in hatches)
assert total == pytest.approx(80.0 * 8.0)
def test_a_revolved_style_section_of_a_prismatic_part_is_refused():
plane = normalise_plane({"p1": (-10.0, 25.0), "p2": (100.0, 25.0)})
with pytest.raises(ValueError, match="revolved section"):
build_view(plate(), "section", plane=plane, style="revolved")
def test_a_plane_that_misses_the_part_is_refused_rather_than_hatched_empty():
plane = normalise_plane({"p1": (-10.0, 200.0), "p2": (100.0, 200.0)})
with pytest.raises(ValueError, match="does not cut"):
build_view(plate(), "section", plane=plane, style="full")
# -- detail views ------------------------------------------------------------
def test_a_detail_scales_the_region_and_labels_both_places():
detail = {"center": (20.0, 12.5), "radius": 8.0, "scale": 5.0, "label": "D"}
view = build_view(shaft(), "detail", detail=detail)
assert view.kind == "detail"
assert view.scale == 5.0
assert view.label == "D (5:1)"
width = view.bbox[2] - view.bbox[0]
# straight geometry is trimmed to the circle, so the enlargement can never
# be wider than the circle itself at the detail scale
assert width <= 2.0 * 8.0 * 5.0 + 1e-6
assert width > 8.0 * 5.0
marker = detail_marker_prims(detail)
circle = next(p for p in marker if isinstance(p, Circle))
text = next(p for p in marker if isinstance(p, Text))
assert circle.center == (20.0, 12.5) and circle.radius == 8.0
assert text.text == "D"
def test_a_detail_reports_the_features_that_fall_outside_its_circle():
from engineering.mech.features import RetainingGroove
groove = RetainingGroove(id="rg", x=70.0, d=20.0, m=1.6, d2=18.6)
detail = {"center": (5.0, 12.5), "radius": 6.0, "scale": 5.0, "label": "D"}
view = build_view(shaft(groove), "detail", detail=detail)
assert any(entry["feature"] == "rg" for entry in view.omitted)
assert "detail circle D" in next(
entry["reason"] for entry in view.omitted if entry["feature"] == "rg"
)
def test_a_detail_without_a_radius_or_with_a_bad_scale_is_refused():
with pytest.raises(ValueError, match="radius"):
build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "scale": 5.0})
with pytest.raises(ValueError, match="scale"):
build_view(shaft(), "detail", detail={"center": (20.0, 12.5), "radius": 8.0, "scale": 0.0})
def test_a_section_without_a_plane_is_refused_by_name():
with pytest.raises(ValueError, match="plane"):
build_view(shaft(), "section")
def test_an_axial_bore_shows_its_flat_bottom_in_the_section():
bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)
view = build_view(shaft(bore), "section", plane=axis_plane(), style="full")
(hatch_upper, _hatch_lower) = [p for p in view.prims if isinstance(p, HatchArea)]
assert min(y for _, y in hatch_upper.loops[0]) == 0.0
assert 4.0 in {round(y, 6) for _, y in hatch_upper.loops[0]}
+4 -1
View File
@@ -70,7 +70,10 @@ def coincident(view: View) -> dict:
def test_the_engine_declares_three_orthographic_kinds_and_two_projections():
assert VIEW_KINDS == ("front", "side", "top")
# The three orthographic kinds come first; Task 5 appended the two cut kinds
# (tests/test_mech_sections.py pins the full tuple from the other side).
assert VIEW_KINDS[:3] == ("front", "side", "top")
assert VIEW_KINDS == ("front", "side", "top", "section", "detail")
assert PROJECTIONS == ("first", "third")