Files
Umut CANandClaude Opus 5 72bd17d024 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
2026-09-22 21:40:29 +03:00

465 lines
18 KiB
Python

"""The orthographic view engine: silhouette, hidden lines, centre lines, layout.
Everything is asserted as emitted primitive tuples - counts, roles, coordinates
to 1e-9. The radial profile is the single calculation both the silhouette and
(from Task 5) the section cut face come out of, so it is pinned first.
"""
from __future__ import annotations
from collections import Counter
import pytest
from engineering.mech.features import (
AxialBore,
Chamfer,
CornerFillet,
CrossHole,
Fillet,
Hole,
Keyway,
ORingGroove,
RetainingGroove,
)
from engineering.mech.part import PrismaticPart, RevolvedPart, Segment
from engineering.mech.primitives import Arc, Circle, Line
from engineering.mech.views import (
PROJECTIONS,
VIEW_KINDS,
Band,
View,
build_view,
layout_origin,
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 roles(view: View, role: str):
return [p for p in view.prims if getattr(p, "role", None) == role]
def coincident(view: View) -> dict:
"""Geometries drawn more than once - exactly what ``_check_duplicate_entities`` flags."""
counts: Counter = Counter()
for prim in view.prims:
if isinstance(prim, Line):
counts[tuple(sorted((prim.p1, prim.p2)))] += 1
elif isinstance(prim, Circle):
counts[(prim.center, round(prim.radius, 9))] += 1
return {key: n for key, n in counts.items() if n > 1}
def test_the_engine_declares_three_orthographic_kinds_and_two_projections():
# 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")
# -- the radial profile ------------------------------------------------------
def test_a_plain_shaft_has_one_band_per_segment():
bands = radial_profile(shaft())
assert bands == (
Band(0.0, 20.0, 0.0, 12.5, 12.5),
Band(20.0, 60.0, 0.0, 15.0, 15.0),
Band(60.0, 75.0, 0.0, 10.0, 10.0),
)
def test_a_taper_is_carried_as_two_end_radii():
part = RevolvedPart(name="cone", segments=(Segment(20.0, 30.0, 0.0, 10.0),))
(band,) = radial_profile(part)
assert band.r_out0 == pytest.approx(15.0, abs=1e-6)
assert band.r_out1 == pytest.approx(5.0, abs=1e-6)
def test_a_ring_groove_cuts_a_band_out_of_the_outer_profile():
groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
bands = radial_profile(shaft(groove))
# the cut band is the one INSIDE the 30 mm segment whose outside is pulled
# back; segments 0 (d25) and 2 (d20) are simply thinner and are not cuts.
cut = [b for b in bands if 20.0 <= b.x0 and b.x1 <= 60.0 and b.r_out0 < 15.0 - EPS]
assert len(cut) == 1
assert cut[0].x0 == pytest.approx(29.2)
assert cut[0].x1 == pytest.approx(30.8)
assert cut[0].r_out0 == pytest.approx(14.3)
def test_an_axial_bore_raises_the_inner_profile_over_its_depth():
bore = AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)
bands = radial_profile(shaft(bore))
bored = [b for b in bands if b.r_in > 0.0]
assert [(b.x0, b.x1) for b in bored] == [(0.0, 20.0), (20.0, 30.0)]
assert {b.r_in for b in bored} == {4.0}
# -- the revolved front view -------------------------------------------------
def test_a_plain_shaft_front_view_is_a_mirrored_silhouette_on_an_axis():
view = build_view(shaft(), "front")
assert view.kind == "front"
axis = roles(view, "center")
assert len(axis) == 1
assert axis[0].p1[1] == 0.0 and axis[0].p2[1] == 0.0
assert axis[0].p1[0] < 0.0 and axis[0].p2[0] > 75.0
visible = roles(view, "visible")
# the +Y half-outline is 7 lines (left face, 3 cylinders, 2 steps, right face)
# and it is mirrored about the axis
assert len(visible) == 14
ys = sorted({round(line.p1[1], 6) for line in visible})
assert ys == [-15.0, -12.5, -10.0, 0.0, 10.0, 12.5, 15.0]
assert view.bbox == (pytest.approx(-7.5), -15.0, pytest.approx(82.5), 15.0)
def test_a_bore_reaches_the_front_view_as_hidden_lines():
part = RevolvedPart(name="sleeve", segments=(Segment(30.0, 40.0, 20.0),))
view = build_view(part, "front")
hidden = roles(view, "hidden")
assert len(hidden) == 2
assert {round(line.p1[1], 6) for line in hidden} == {10.0, -10.0}
assert hidden[0].p1[0] == 0.0 and hidden[0].p2[0] == 30.0
def test_a_chamfer_replaces_the_silhouette_corner_instead_of_being_drawn_over_it():
chamfer = Chamfer(id="c1", at="start", size=2.0)
view = build_view(shaft(chamfer), "front")
upper = [line for line in roles(view, "visible") if line.p1[1] > 0 or line.p2[1] > 0]
starts = {(round(line.p1[0], 6), round(line.p1[1], 6)) for line in upper}
assert (0.0, 10.5) in starts
assert (0.0, 12.5) not in starts
assert any(line.p1 == (0.0, 10.5) and line.p2 == (2.0, 12.5) for line in upper)
def test_a_fillet_puts_a_tangent_arc_into_the_silhouette():
fillet = Fillet(id="f1", at="step:0", radius=2.0)
view = build_view(shaft(fillet), "front")
arcs = [p for p in view.prims if isinstance(p, Arc)]
assert len(arcs) == 2 # the fillet and its mirror
upper = next(a for a in arcs if a.center[1] > 0)
assert upper.center == (18.0, 14.5)
assert (upper.radius, upper.start_deg, upper.end_deg) == (2.0, 270.0, 360.0)
def test_an_axisymmetric_feature_is_mirrored_and_a_keyway_is_not():
groove = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
keyway = Keyway(id="kw", segment=1, length=20.0, offset=5.0)
view = build_view(shaft(groove, keyway), "front")
groove_lines = [
line for line in view.prims if isinstance(line, Line) and abs(abs(line.p1[1]) - 14.3) < 1e-6
]
assert {line.p1[1] > 0 for line in groove_lines} == {True, False}
floor = 15.0 - 4.0 # DIN 6885 depth_shaft for a 30 mm shaft
keyway_lines = [
line for line in view.prims if isinstance(line, Line) and abs(line.p1[1] - floor) < 1e-6
]
assert keyway_lines and all(line.p1[1] > 0 for line in keyway_lines)
@pytest.mark.parametrize(
"groove",
[
RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6),
ORingGroove(id="or", x=40.0, cord=3.0, b=4.1, h=2.3),
],
ids=["retaining", "oring"],
)
def test_a_groove_is_the_notch_in_the_silhouette_and_is_not_drawn_a_second_time(groove):
"""The spec's rule is `grooves cut a notch in the silhouette`. The notch is
the radial profile's; the feature does not redraw it. Two coincident LINEs
are what ``drawing_critique(focus=None)`` reports as a duplicate, and
premium rule 7 needs that report empty before ``drawing_finalize``."""
view = build_view(shaft(groove), "front")
assert coincident(view) == {}
# the notch is still there, mirrored, once per side
floor = {round(abs(line.p1[1]), 6) for line in view.prims if isinstance(line, Line)}
assert (14.3 in floor) or (12.7 in floor)
assert view.omitted == ()
def test_an_axial_bore_end_circle_is_drawn_once():
"""``_end_circles`` reads the bore off the same radial profile the feature
edited, so the feature's own circle would be the second copy of it."""
view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "side")
assert coincident(view) == {}
assert len([c for c in view.prims if isinstance(c, Circle) and abs(c.radius - 4.0) < EPS]) == 1
def test_a_blind_bore_keeps_the_bottom_line_the_profile_cannot_draw():
"""Suppression is `the profile already drew this`, not `this feature edits
the profile` - the blind bottom is not on the radial profile at all."""
view = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=30.0)), "front")
bottoms = [
line
for line in roles(view, "hidden")
if isinstance(line, Line) and abs(line.p1[0] - 30.0) < EPS and abs(line.p2[0] - 30.0) < EPS
]
assert len(bottoms) == 1
assert {round(bottoms[0].p1[1], 6), round(bottoms[0].p2[1], 6)} == {-4.0, 4.0}
def test_a_groove_straddling_a_step_leaves_one_closed_silhouette():
"""The feature's own prims read a single surface radius for a span that has
two, so drawing them as well as the profile's notch put a groove floor
through solid material and left the +Y path with four odd-degree nodes.
An open path has exactly two: its two ends on the axis."""
part = shaft(RetainingGroove(id="rg", x=20.0, d=30.0, m=1.6, d2=28.6))
upper = [
line
for line in roles(build_view(part, "front"), "visible")
if isinstance(line, Line) and line.p1[1] >= 0.0 and line.p2[1] >= 0.0
]
degree: Counter = Counter()
for line in upper:
degree[(round(line.p1[0], 6), round(line.p1[1], 6))] += 1
degree[(round(line.p2[0], 6), round(line.p2[1], 6))] += 1
assert sorted(node for node, n in degree.items() if n % 2) == [(0.0, 0.0), (75.0, 0.0)]
# nothing is drawn at the groove floor radius outside the band the profile cut
assert not [
line
for line in upper
if abs(line.p1[1] - 14.3) < EPS and abs(line.p2[1] - 14.3) < EPS and line.p1[0] < 20.0
]
# -- the revolved end view ---------------------------------------------------
def test_the_end_view_hides_the_smaller_diameter_and_draws_the_step_face_continuous():
"""Ground truth by ray cast along the axis of the same solid of revolution
(viewer at +X, probe just inside each edge):
edge r=10 at x=75 -> first surface at x=75 -> VISIBLE
edge r=15 at x=60 -> first surface at x=60 -> VISIBLE
edge r=12.5 at x=20 -> first surface at x=60 -> HIDDEN
The annular step face at x=60 faces the observer and nothing at projected
radius 15-eps stands in front of it, so the 30 mm circle is continuous. The
25 mm circle is the one that is genuinely covered, and it is still *drawn* -
dashed, not dropped.
"""
view = build_view(shaft(), "side", side="right")
circles = [p for p in view.prims if isinstance(p, Circle)]
radii = {(round(c.radius, 6), c.role) for c in circles}
assert radii == {(10.0, "visible"), (15.0, "visible"), (12.5, "hidden")}
assert len([p for p in view.prims if getattr(p, "role", None) == "center"]) == 2
def test_looking_from_the_left_reverses_which_circle_is_visible():
"""From -X the near end is the 25 mm face; the 20 mm end is behind the 30 mm body."""
view = build_view(shaft(), "side", side="left")
circles = [p for p in view.prims if isinstance(p, Circle)]
radii = {(round(c.radius, 6), c.role) for c in circles}
assert radii == {(12.5, "visible"), (15.0, "visible"), (10.0, "hidden")}
def test_a_feature_circle_at_the_far_end_is_hidden_behind_the_larger_body():
"""A chamfer at x=0 seen from +X sits behind the 30 mm band, so its circle
is dashed; seen from -X nothing is in front of it and it is continuous."""
part = shaft(Chamfer(id="c1", at="start", size=2.0))
right = {
(round(c.radius, 6), c.role)
for c in build_view(part, "side", side="right").prims
if isinstance(c, Circle)
}
left = {
(round(c.radius, 6), c.role)
for c in build_view(part, "side", side="left").prims
if isinstance(c, Circle)
}
assert (10.5, "hidden") in right
assert (10.5, "visible") in left
def test_a_bore_that_widens_behind_a_narrower_one_is_hidden():
"""Looking into the narrow end you see the small bore; the wider bore behind
it is covered by the wall of the narrow one. From the other end, both are
seen down the widening hole."""
part = RevolvedPart(
name="stepped-bore",
segments=(Segment(20.0, 40.0, 10.0), Segment(20.0, 40.0, 20.0)),
)
right = {
(round(c.radius, 6), c.role)
for c in build_view(part, "side", side="right").prims
if isinstance(c, Circle)
}
left = {
(round(c.radius, 6), c.role)
for c in build_view(part, "side", side="left").prims
if isinstance(c, Circle)
}
assert (10.0, "visible") in right and (5.0, "visible") in right
assert (5.0, "visible") in left and (10.0, "hidden") in left
def test_a_revolved_top_view_is_refused_because_it_repeats_the_front():
with pytest.raises(ValueError, match="identical to its front view"):
build_view(shaft(), "top")
# -- the prismatic views -----------------------------------------------------
def test_a_prismatic_front_view_is_the_closed_outline():
view = build_view(plate(), "front")
lines = roles(view, "visible")
assert len(lines) == 4
assert view.bbox == (0.0, 0.0, 80.0, 50.0)
def test_a_corner_fillet_replaces_an_outline_vertex_with_an_arc():
view = build_view(plate(CornerFillet(id="cf", vertex=1, radius=10.0)), "front")
arcs = [p for p in view.prims if isinstance(p, Arc)]
# the substituted arc carries role "visible" too, so the straight edges are
# counted by type: 4 outline edges remain, two of them shortened.
lines = [p for p in roles(view, "visible") if isinstance(p, Line)]
assert len(arcs) == 1
assert arcs[0].center == (pytest.approx(70.0), pytest.approx(10.0))
assert len(lines) == 4
assert not any(abs(line.p1[0] - 80.0) < EPS and abs(line.p1[1]) < EPS for line in lines)
def test_a_prismatic_side_view_is_the_thickness_rectangle_with_a_hidden_line_per_hole():
# y=10, deliberately OFF the y=25 mirror axis of the 0..50 plate: a hole on
# the axis cannot tell a right-hand view from a left-hand one.
hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0)
view = build_view(plate(hole), "side")
visible = roles(view, "visible")
hidden = roles(view, "hidden")
assert len(visible) == 4
assert view.bbox == (0.0, 0.0, 50.0, 8.0)
assert len(hidden) == 2
assert {round(line.p1[0], 6) for line in hidden} == {5.0, 15.0}
def test_a_prismatic_left_side_view_is_the_right_view_mirrored():
"""An observer at -X sees screen-right = -Y, so the hole at y=10 in a plate
spanning y 0..50 lands at u = 50-15 .. 50-5. Accepting ``side='left'`` and
returning the right-hand view puts every feature on the wrong side of the
sheet with nothing in the drawing to reveal it."""
hole = Hole(id="h1", x=20.0, y=10.0, diameter=10.0)
part = plate(hole)
right = {
round(line.p1[0], 6) for line in roles(build_view(part, "side", side="right"), "hidden")
}
left = {round(line.p1[0], 6) for line in roles(build_view(part, "side", side="left"), "hidden")}
assert right == {5.0, 15.0}
assert left == {35.0, 45.0}
# the thickness rectangle is its own mirror, so only the features move
assert build_view(part, "side", side="left").bbox == (0.0, 0.0, 50.0, 8.0)
def test_a_prismatic_top_view_is_always_seen_from_above():
"""``side`` names the end an *end* view is taken from; the top view is not
one, so it must not move with it."""
part = plate(Hole(id="h1", x=20.0, y=10.0, diameter=10.0))
from_right = {
round(line.p1[0], 6) for line in roles(build_view(part, "top", side="right"), "hidden")
}
from_left = {
round(line.p1[0], 6) for line in roles(build_view(part, "top", side="left"), "hidden")
}
assert from_right == from_left == {15.0, 25.0}
def test_a_prismatic_top_view_projects_the_other_axis():
hole = Hole(id="h1", x=20.0, y=25.0, diameter=10.0)
view = build_view(plate(hole), "top")
assert view.bbox == (0.0, 0.0, 80.0, 8.0)
hidden = roles(view, "hidden")
assert {round(line.p1[0], 6) for line in hidden} == {15.0, 25.0}
# -- the honesty rule --------------------------------------------------------
def test_a_feature_that_projects_into_the_view_is_not_reported_as_omitted():
side = build_view(shaft(Chamfer(id="c1", at="start", size=2.0)), "side")
assert side.omitted == ()
front = build_view(shaft(CrossHole(id="xh", x=30.0, diameter=6.0)), "front")
assert front.omitted == ()
bore = build_view(shaft(AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)), "front")
assert bore.omitted == ()
def test_a_bend_line_is_omitted_from_a_side_view_with_a_reason():
from engineering.mech.features import BendLine
bend = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0)
view = build_view(plate(bend), "side")
assert len(view.omitted) == 1
assert view.omitted[0]["feature"] == "b1"
assert "side" in view.omitted[0]["reason"]
def test_the_view_collects_the_dimension_intents_of_its_own_features():
keyway = Keyway(id="kw", segment=1, length=20.0)
view = build_view(shaft(keyway), "front")
assert [intent.feature for intent in view.dims] == ["kw"]
def test_an_unknown_view_kind_is_refused_by_name():
with pytest.raises(ValueError, match="front"):
build_view(shaft(), "isometric")
# -- projection layout -------------------------------------------------------
def test_first_angle_puts_the_right_hand_view_on_the_left_and_the_top_below():
front = build_view(shaft(), "front")
end = build_view(shaft(), "side")
left = layout_origin(front, "side", side="right", projection="first", child=end)
width = end.bbox[2] - end.bbox[0]
height = end.bbox[3] - end.bbox[1]
assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width)
assert left[1] == pytest.approx((front.bbox[1] + front.bbox[3]) / 2.0 - height / 2.0)
below = layout_origin(front, "top", projection="first", child=end)
assert below[1] == pytest.approx(front.bbox[1] - 20.0 - height)
def test_third_angle_flips_both_placements():
front = build_view(shaft(), "front")
end = build_view(shaft(), "side")
right = layout_origin(front, "side", side="right", projection="third", child=end)
assert right[0] == pytest.approx(front.bbox[2] + 20.0)
above = layout_origin(front, "top", projection="third", child=end)
assert above[1] == pytest.approx(front.bbox[3] + 20.0)
def test_without_a_child_the_layout_assumes_the_parent_size():
front = build_view(shaft(), "front")
width = front.bbox[2] - front.bbox[0]
left = layout_origin(front, "side", side="right", projection="first")
assert left[0] == pytest.approx(front.bbox[0] - 20.0 - width)
def test_an_unknown_projection_is_refused():
front = build_view(shaft(), "front")
with pytest.raises(ValueError, match="first"):
layout_origin(front, "side", projection="second")