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:
co-authored by
Claude Opus 5
parent
b2f858212f
commit
72bd17d024
@@ -68,14 +68,22 @@ from .primitives import (
|
|||||||
translate,
|
translate,
|
||||||
)
|
)
|
||||||
from .views import (
|
from .views import (
|
||||||
|
CIRCLE_SEGMENTS,
|
||||||
PROJECTIONS,
|
PROJECTIONS,
|
||||||
|
SECTION_PLANE,
|
||||||
|
SECTION_STYLES,
|
||||||
VIEW_KINDS,
|
VIEW_KINDS,
|
||||||
Band,
|
Band,
|
||||||
View,
|
View,
|
||||||
build_view,
|
build_view,
|
||||||
|
cut_loops,
|
||||||
|
detail_marker_prims,
|
||||||
|
detail_view,
|
||||||
layout_origin,
|
layout_origin,
|
||||||
|
normalise_plane,
|
||||||
outline_prims,
|
outline_prims,
|
||||||
radial_profile,
|
radial_profile,
|
||||||
|
section_view,
|
||||||
silhouette_prims,
|
silhouette_prims,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +92,7 @@ __all__ = [
|
|||||||
"AxialBore",
|
"AxialBore",
|
||||||
"Band",
|
"Band",
|
||||||
"BendLine",
|
"BendLine",
|
||||||
|
"CIRCLE_SEGMENTS",
|
||||||
"CentreHole",
|
"CentreHole",
|
||||||
"Chamfer",
|
"Chamfer",
|
||||||
"Circle",
|
"Circle",
|
||||||
@@ -114,6 +123,8 @@ __all__ = [
|
|||||||
"RetainingGroove",
|
"RetainingGroove",
|
||||||
"RevolvedPart",
|
"RevolvedPart",
|
||||||
"Role",
|
"Role",
|
||||||
|
"SECTION_PLANE",
|
||||||
|
"SECTION_STYLES",
|
||||||
"Segment",
|
"Segment",
|
||||||
"Slot",
|
"Slot",
|
||||||
"Text",
|
"Text",
|
||||||
@@ -124,11 +135,15 @@ __all__ = [
|
|||||||
"bbox",
|
"bbox",
|
||||||
"build_part",
|
"build_part",
|
||||||
"build_view",
|
"build_view",
|
||||||
|
"cut_loops",
|
||||||
|
"detail_marker_prims",
|
||||||
|
"detail_view",
|
||||||
"feature_from_dict",
|
"feature_from_dict",
|
||||||
"inner_radius_at",
|
"inner_radius_at",
|
||||||
"layer_for",
|
"layer_for",
|
||||||
"layout_origin",
|
"layout_origin",
|
||||||
"mirror_about_x",
|
"mirror_about_x",
|
||||||
|
"normalise_plane",
|
||||||
"outer_radius_at",
|
"outer_radius_at",
|
||||||
"outline_bbox",
|
"outline_bbox",
|
||||||
"outline_prims",
|
"outline_prims",
|
||||||
@@ -140,6 +155,7 @@ __all__ = [
|
|||||||
"radial_profile",
|
"radial_profile",
|
||||||
"rotate",
|
"rotate",
|
||||||
"scale",
|
"scale",
|
||||||
|
"section_view",
|
||||||
"segment_bounds",
|
"segment_bounds",
|
||||||
"silhouette_prims",
|
"silhouette_prims",
|
||||||
"translate",
|
"translate",
|
||||||
|
|||||||
+441
-2
@@ -24,10 +24,13 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
from engineering.mech.features import Pocket as _PocketType
|
||||||
from engineering.mech.features import mirror_about_x
|
from engineering.mech.features import mirror_about_x
|
||||||
from engineering.mech.part import (
|
from engineering.mech.part import (
|
||||||
PrismaticPart,
|
PrismaticPart,
|
||||||
RevolvedPart,
|
RevolvedPart,
|
||||||
|
inner_radius_at,
|
||||||
|
outer_radius_at,
|
||||||
outline_bbox,
|
outline_bbox,
|
||||||
part_length,
|
part_length,
|
||||||
part_max_diameter,
|
part_max_diameter,
|
||||||
@@ -44,7 +47,9 @@ from engineering.mech.primitives import (
|
|||||||
Pt,
|
Pt,
|
||||||
Text,
|
Text,
|
||||||
bbox,
|
bbox,
|
||||||
|
translate,
|
||||||
)
|
)
|
||||||
|
from engineering.mech.primitives import scale as scale_prims
|
||||||
|
|
||||||
_EPS = 1e-9
|
_EPS = 1e-9
|
||||||
_SNAP = 6 # decimals used to match a corner vertex to a corner edit
|
_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.
|
#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices.
|
||||||
_TOL = 1e-6
|
_TOL = 1e-6
|
||||||
|
|
||||||
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top")
|
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail")
|
||||||
PROJECTIONS: tuple[str, ...] = ("first", "third")
|
PROJECTIONS: tuple[str, ...] = ("first", "third")
|
||||||
SIDES: tuple[str, ...] = ("right", "left")
|
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).
|
#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun).
|
||||||
AXIS_OVERRUN = 0.10
|
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)
|
@dataclass(frozen=True)
|
||||||
class View:
|
class View:
|
||||||
@@ -543,7 +568,10 @@ def build_view(
|
|||||||
where = str(side or "right").strip().lower()
|
where = str(side or "right").strip().lower()
|
||||||
if where not in SIDES:
|
if where not in SIDES:
|
||||||
raise ValueError(f"build_view: side {side!r} is not one of {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)}
|
ctx = {"side": where, "scale": float(scale), "projection": str(projection)}
|
||||||
prims: list[Prim] = []
|
prims: list[Prim] = []
|
||||||
@@ -648,3 +676,414 @@ def layout_origin(
|
|||||||
f"layout_origin: kind {kind!r} has no projection position; expected "
|
f"layout_origin: kind {kind!r} has no projection position; expected "
|
||||||
"'side', 'top', 'section' or 'detail'."
|
"'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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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]}
|
||||||
@@ -70,7 +70,10 @@ def coincident(view: View) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def test_the_engine_declares_three_orthographic_kinds_and_two_projections():
|
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")
|
assert PROJECTIONS == ("first", "third")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user