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
317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""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]}
|