Files
autocad-mcp-pro/tests/test_mech_features.py
T
Umut CANandClaude Opus 5 457c53644f fix(task-3): keyway end-view arc, the machined face, and the chamfer's segment bound
Three defects in the feature catalogue, all faithfully transcribed from the
plan text and all wrong in shipped code.

1. Keyway.prims(view="side") emitted the arc the keyway REMOVES. `Arc` is CCW
   start -> end, and the emitted (right wall -> left wall) is the ~31 degree
   cap over the slot opening, not the ~329 degrees of shaft that survives it.
   Measured live on AutoCAD 2026 (Documents.Add scratch doc, closed in a
   finally): ModelSpace.AddArc(center, radius, start_rad, end_rad) -- angles in
   RADIANS, CCW start -> end -- gives ArcLength 8.098 mm for the emitted
   74.534 -> 105.466 deg and 86.150 mm for the reversed 105.466 -> 74.534 deg,
   against a 94.248 mm full circle at r = 15; StartPoint/EndPoint of the
   emitted arc are exactly the notch walls at x = +/-4. Swapped, so the arc now
   runs left wall -> right wall the long way round, and the side view's bbox
   goes from (-4, 11, 4, 15) to (-15, -15, 15, 14.457).

2. Hole and Pocket cut the plate from OPPOSITE faces in the same view frame:
   Hole ran z = 0 -> depth, Pocket z = thickness -> thickness - depth, and
   neither takes a face parameter. A plate carrying both showed the pocket
   milled from one side and the blind hole drilled from the other, silently --
   omission() returns None for both, so nothing reported it. The frame comment
   now names the machined face (v = thickness, the face the front view looks
   at), and Hole drills from it: the blind floor, the counterbore step and the
   through bore all hang down from there, as Pocket already did.

3. Chamfer.validate bounded the axial leg against the whole part, never against
   the segment the chamfer sits on. A shallow lead-in passed validation and was
   drawn straight through a shoulder at a radius that segment does not have
   (size 30 at 10 deg on a 15 mm end segment: radial 5.29 < 10 so the radial
   guard was happy, yet `before` landed at x = 45, two segments away). New
   helper _anchor_segment names the segment a corner actually cuts into -- at a
   step, the adjacent one carrying the larger radius -- and the leg is bounded
   by its length, refused by name with the step it would have crossed.

Tests: the keyway test asserted only primitive counts and two x coordinates, so
it passed with the arc reversed; it now pins the sweep, the endpoints and the
bbox. The hole test exercised only a through hole, which is symmetric; added a
blind-hole-and-pocket test that pins both to the same face, and a counterbore
test. Added three chamfer tests (two refusals, one that must still be
accepted). Each fix was mutation-checked: reverting it fails its test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 19:48:00 +03:00

754 lines
29 KiB
Python

"""Every feature of spec section 5: validation, emitted primitives, dimensions.
Geometry is asserted as emitted primitive tuples - counts, roles, coordinates
to 1e-9 - never as a screenshot. The two features with a real table
(`Keyway` -> DIN 6885 via engineering.keyway, `GearTeeth` -> the involute
generator in engineering.gear) are pinned against those modules rather than
against numbers restated here; the four not-transcribed tables are exercised
through their refusals.
"""
from __future__ import annotations
import math
import pytest
from engineering.gear import generate_full_gear_outline
from engineering.keyway import keyway_dimensions
from engineering.mech.features import (
FEATURE_KINDS,
AxialBore,
BendLine,
CentreHole,
Chamfer,
CornerChamfer,
CornerFillet,
CrossHole,
Fillet,
GearTeeth,
Hole,
HoleCircle,
HolePattern,
Keyway,
ORingGroove,
Pocket,
RetainingGroove,
Slot,
Thread,
Undercut,
feature_from_dict,
mirror_about_x,
)
from engineering.mech.part import PrismaticPart, RevolvedPart, Segment, build_part
from engineering.mech.primitives import Arc, Circle, Line, Poly, Text, bbox
CTX: dict = {"side": "right", "scale": 1.0, "projection": "first"}
def shaft(*features) -> RevolvedPart:
return RevolvedPart(
name="shaft",
segments=(Segment(20.0, 25.0), Segment(40.0, 30.0, 10.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 test_the_catalogue_is_the_nineteen_kinds_spec_section_5_names():
assert FEATURE_KINDS == (
"axial_bore",
"bend_line",
"centre_hole",
"chamfer",
"corner_chamfer",
"corner_fillet",
"cross_hole",
"fillet",
"gear_teeth",
"hole",
"hole_circle",
"hole_pattern",
"keyway",
"oring_groove",
"pocket",
"retaining_groove",
"slot",
"thread",
"undercut",
)
def test_feature_from_dict_round_trips_every_kind_it_builds():
payload = {"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0, "angle": 45.0}
feature = feature_from_dict(payload)
assert isinstance(feature, Chamfer)
assert feature.to_dict() == payload
def test_feature_from_dict_refuses_an_unknown_kind_and_an_unknown_key():
with pytest.raises(ValueError, match="unknown feature"):
feature_from_dict({"kind": "spline", "id": "s"})
with pytest.raises(ValueError, match="unknown key"):
feature_from_dict({"kind": "chamfer", "id": "c", "at": "start", "size": 1.0, "deg": 30})
def test_mirror_about_x_flips_every_primitive_type():
line, arc, circle, poly, text = mirror_about_x(
[
Line((0.0, 5.0), (10.0, 5.0)),
Arc((0.0, 4.0), 2.0, 30.0, 90.0),
Circle((1.0, 3.0), 2.0),
Poly(((0.0, 1.0), (2.0, 3.0)), True),
Text((1.0, 2.0), "A"),
]
)
assert line.p1 == (0.0, -5.0) and line.p2 == (10.0, -5.0)
assert arc.center == (0.0, -4.0)
assert (arc.start_deg, arc.end_deg) == (270.0, 330.0)
assert circle.center == (1.0, -3.0)
assert poly.points == ((0.0, -1.0), (2.0, -3.0))
assert text.at == (1.0, -2.0)
# -- chamfer and fillet ------------------------------------------------------
def test_a_45_degree_chamfer_at_the_start_replaces_the_corner_with_equal_legs():
feature = Chamfer(id="c1", at="start", size=2.0)
part = shaft(feature)
edit = feature.corner_edit(part)
assert edit["corner"] == (0.0, 12.5)
assert edit["before"] == (0.0, 10.5)
assert edit["after"] == (2.0, 12.5)
assert edit["arc"] is None
assert feature.removal_box(part) == (0.0, 10.5, 2.0, 12.5)
def test_a_30_degree_chamfer_at_the_end_cuts_toward_minus_x():
"""The two replacement points sit on the two surfaces the corner joined.
The silhouette runs left to right, so ``before`` is the point left on the
cylinder (x = 75 - size, r unchanged) and ``after`` is the point left on
the end face (x = 75, r reduced by the radial leg). Neither of them is the
corner itself.
"""
feature = Chamfer(id="c2", at="end", size=3.0, angle=30.0)
part = shaft(feature)
edit = feature.corner_edit(part)
radial = 3.0 * math.tan(math.radians(30.0))
assert edit["corner"] == (75.0, 10.0)
assert edit["before"][0] == pytest.approx(72.0)
assert edit["before"][1] == pytest.approx(10.0)
assert edit["after"][0] == pytest.approx(75.0)
assert edit["after"][1] == pytest.approx(10.0 - radial)
assert edit["arc"] is None
def test_a_chamfer_shows_as_an_extra_circle_in_the_end_view():
feature = Chamfer(id="c1", at="end", size=2.0)
part = shaft(feature)
(circle,) = feature.prims(part, "side", CTX)
assert isinstance(circle, Circle)
assert circle.radius == pytest.approx(8.0)
def test_a_chamfer_bigger_than_the_step_is_refused_by_name():
feature = Chamfer(id="c1", at="start", size=40.0)
with pytest.raises(ValueError, match="c1|size"):
feature.validate(shaft(feature))
def test_a_chamfer_whose_axial_leg_outruns_its_segment_is_refused_by_name():
"""The radial guard only bites near 45 deg; a shallow lead-in slips past it.
The end segment of `shaft()` is 15 mm long at r = 10. A 30 mm chamfer at
10 deg takes only 5.29 mm off the radius, so `radial >= radius` is happy,
and the old part-length bound (75 mm) was happy too - yet `before` landed
at x = 45, two segments away, anchored at a radius that segment does not
have.
"""
feature = Chamfer(id="c1", at="end", size=30.0, angle=10.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
message = str(excinfo.value)
assert "30" in message and "15" in message and "segment 2" in message
def test_a_shallow_chamfer_that_fits_inside_its_segment_is_still_accepted():
"""The new bound refuses only what leaves the segment; 14 < 15 mm stays."""
feature = Chamfer(id="c1", at="end", size=14.0, angle=10.0)
part = shaft(feature)
feature.validate(part)
edit = feature.corner_edit(part)
assert edit["before"][0] == pytest.approx(61.0)
assert edit["before"][0] >= 60.0 # inside segment 2, which spans x 60..75
def test_a_chamfer_at_a_step_is_bounded_by_the_segment_it_cuts_into():
"""`shaft()` steps UP at x = 20, so the chamfer cuts into segment 1 (40 mm).
A 45 mm leg outruns that segment even though the part is 75 mm long.
"""
feature = Chamfer(id="c1", at="step:0", size=45.0, angle=5.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
assert "segment 1" in str(excinfo.value) and "40" in str(excinfo.value)
def test_a_fillet_at_a_step_up_emits_a_tangent_arc_in_the_lower_right_quadrant():
feature = Fillet(id="f1", at="step:0", radius=2.0)
part = shaft(feature)
edit = feature.corner_edit(part)
assert edit["corner"] == (20.0, 12.5)
assert edit["before"] == (18.0, 12.5)
assert edit["after"] == (20.0, 14.5)
arc = edit["arc"]
assert isinstance(arc, Arc)
assert arc.center == (18.0, 14.5)
assert (arc.radius, arc.start_deg, arc.end_deg) == (2.0, 270.0, 360.0)
def test_a_fillet_at_a_step_down_puts_the_arc_in_the_lower_left_quadrant():
feature = Fillet(id="f2", at="step:1", radius=1.5)
part = shaft(feature)
edit = feature.corner_edit(part)
assert edit["corner"] == (60.0, 10.0)
assert edit["before"] == (60.0, 11.5)
assert edit["after"] == (61.5, 10.0)
assert edit["arc"].center == (61.5, 11.5)
assert (edit["arc"].start_deg, edit["arc"].end_deg) == (180.0, 270.0)
def test_a_fillet_is_only_defined_at_a_step():
feature = Fillet(id="f3", at="start", radius=1.0)
with pytest.raises(ValueError, match="step"):
feature.validate(shaft(feature))
# -- keyway (DIN 6885 through engineering.keyway) ----------------------------
def test_a_keyway_takes_its_width_and_depth_from_din_6885():
feature = Keyway(id="kw", segment=1, length=25.0, offset=5.0)
part = shaft(feature)
feature.validate(part)
table = keyway_dimensions(30.0)
prims = feature.prims(part, "front", CTX)
assert len(prims) == 3
assert all(isinstance(p, Line) and p.role == "visible" for p in prims)
bottom = 15.0 - table["depth_shaft"]
assert prims[0].p1 == (25.0, 15.0) and prims[0].p2 == (25.0, bottom)
assert prims[1].p1 == (25.0, bottom) and prims[1].p2 == (50.0, bottom)
assert prims[2].p1 == (50.0, bottom) and prims[2].p2 == (50.0, 15.0)
assert feature.removal_box(part) == (25.0, bottom, 50.0, 15.0)
def test_a_keyway_end_view_breaks_the_circle_and_draws_the_notch():
"""The arc is the shaft that SURVIVES the slot, not the cap over its mouth.
`Arc` is CCW start -> end, so the sweep has to be the long way round: it
leaves the left notch wall, goes over the bottom of the shaft and arrives
at the right one. Emitting the two angles the other way round draws a short
chord-like cap straight across the keyway opening - geometrically a legal
arc, which is why only the sweep and the endpoints catch it. Measured on
AutoCAD 2026: the 30.93 deg arc is 8.098 mm long, the 329.07 deg one is
86.150 mm, and the full r=15 circle is 94.248 mm.
"""
feature = Keyway(id="kw", segment=1, length=25.0)
part = shaft(feature)
prims = feature.prims(part, "side", CTX)
arcs = [p for p in prims if isinstance(p, Arc)]
lines = [p for p in prims if isinstance(p, Line)]
assert len(arcs) == 1 and len(lines) == 3
width = keyway_dimensions(30.0)["width"]
assert lines[1].p1[0] == pytest.approx(-width / 2.0)
assert lines[1].p2[0] == pytest.approx(width / 2.0)
arc = arcs[0]
radius = 15.0
half = width / 2.0
top = math.sqrt(radius * radius - half * half)
# start at the LEFT wall, end at the RIGHT one: everything but the opening.
assert arc.radius == pytest.approx(radius)
assert arc.start_deg == pytest.approx(math.degrees(math.atan2(top, -half)))
assert arc.end_deg == pytest.approx(math.degrees(math.atan2(top, half)))
sweep = (arc.end_deg - arc.start_deg) % 360.0
assert sweep == pytest.approx(329.068, abs=1e-3)
assert math.radians(sweep) * radius == pytest.approx(86.150, abs=1e-3)
# and the end view therefore spans the whole shaft, not just the notch:
# full width and full depth, topped by the notch walls because the slot has
# taken the crown of the circle away.
assert bbox(prims) == pytest.approx((-radius, -radius, radius, top))
def test_a_keyway_longer_than_its_segment_is_refused_by_name():
feature = Keyway(id="kw", segment=1, length=50.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
assert "40" in str(excinfo.value)
def test_a_keyway_on_a_segment_that_does_not_exist_is_refused_by_range():
feature = Keyway(id="kw", segment=5, length=10.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
assert "segments 0-2" in str(excinfo.value)
def test_a_keyway_outside_the_din_6885_bore_range_is_refused():
part = RevolvedPart(name="pin", segments=(Segment(20.0, 4.0),))
feature = Keyway(id="kw", segment=0, length=10.0)
with pytest.raises(ValueError, match="DIN 6885"):
feature.validate(part)
# -- thread ------------------------------------------------------------------
def test_an_external_thread_draws_the_iso6410_minor_lines_and_the_limit_line():
feature = Thread(id="th", segment=2, designation="M20", length=12.0)
part = shaft(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
assert len(prims) == 3
assert prims[0].p1 == (60.0, 8.0) and prims[0].p2 == (72.0, 8.0)
assert prims[2].p1 == (72.0, -10.0) and prims[2].p2 == (72.0, 10.0)
def test_a_thread_whose_diameter_does_not_match_its_segment_is_refused_with_both_numbers():
feature = Thread(id="th", segment=1, designation="M20", length=10.0)
with pytest.raises(ValueError) as excinfo:
feature.validate(shaft(feature))
message = str(excinfo.value)
assert "M20" in message and "30" in message
def test_an_internal_thread_enlarges_the_bore_to_the_major_for_the_section_hatch():
feature = Thread(id="th", segment=0, designation="M10", length=20.0, internal=True)
part = RevolvedPart(
name="boss",
segments=(Segment(30.0, 40.0, 8.0),), # drilled to the ISO 6410 minor, 0.8 x 10
features=(feature,),
)
feature.validate(part)
(edit,) = feature.radial_edit(part)
assert edit == {"kind": "inner", "x0": 0.0, "x1": 20.0, "radius": 5.0}
def test_a_thread_designation_outside_iso_261_is_refused_by_the_table():
feature = Thread(id="th", segment=2, designation="M7", length=5.0)
with pytest.raises(ValueError, match="ISO 261"):
feature.validate(shaft(feature))
# -- the four not-transcribed tables, through their features -----------------
def test_an_undercut_without_an_explicit_profile_is_refused_by_din_509():
feature = Undercut(id="uc", at="step:0", form="E")
with pytest.raises(ValueError, match="DIN 509"):
feature.validate(shaft(feature))
def test_an_undercut_draws_exactly_the_profile_it_is_handed():
profile = ((0.0, 0.0), (0.5, 0.4), (2.0, 0.4), (2.5, 0.0))
feature = Undercut(id="uc", at="step:0", form="E", profile=profile)
part = shaft(feature)
feature.validate(part)
(poly,) = feature.prims(part, "front", CTX)
assert isinstance(poly, Poly) and poly.closed is False
assert poly.points[0] == (20.0, 12.5)
assert poly.points[1] == (19.5, 12.1)
assert poly.points[-1] == (17.5, 12.5)
assert feature.removal_box(part) == (17.5, 12.1, 20.0, 12.5)
def test_a_retaining_groove_without_explicit_dimensions_is_refused_by_din_471():
feature = RetainingGroove(id="rg", x=30.0, d=30.0)
with pytest.raises(ValueError, match="DIN 471"):
feature.validate(shaft(feature))
def test_a_retaining_groove_with_explicit_dimensions_cuts_the_profile():
feature = RetainingGroove(id="rg", x=30.0, d=30.0, m=1.6, d2=28.6)
part = shaft(feature)
feature.validate(part)
(edit,) = feature.radial_edit(part)
assert edit["kind"] == "outer"
assert edit["radius"] == pytest.approx(14.3)
assert (edit["x0"], edit["x1"]) == (pytest.approx(29.2), pytest.approx(30.8))
lines = feature.prims(part, "front", CTX)
assert len(lines) == 3
assert lines[1].p1[1] == pytest.approx(14.3)
def test_a_centre_hole_without_explicit_dimensions_is_refused_by_din_332():
feature = CentreHole(id="ch", at="end", form="A", size=2.5)
with pytest.raises(ValueError, match="DIN 332"):
feature.validate(shaft(feature))
def test_a_centre_hole_form_r_is_refused_outright():
feature = CentreHole(id="ch", at="end", form="R", size=2.5, d1=2.5, d2=5.3, t=5.0)
with pytest.raises(ValueError, match="form R"):
feature.validate(shaft(feature))
def test_a_centre_hole_with_explicit_dimensions_draws_a_hidden_60_degree_cone():
feature = CentreHole(id="ch", at="end", form="A", size=2.5, d1=2.5, d2=5.3, t=5.0)
part = shaft(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
assert {p.role for p in prims} == {"hidden"}
cone_depth = (5.3 - 2.5) / 2.0 / math.tan(math.radians(30.0))
assert prims[0].p1 == (75.0, 2.65)
assert prims[0].p2[0] == pytest.approx(75.0 - cone_depth)
assert prims[0].p2[1] == pytest.approx(1.25)
def test_an_oring_groove_without_explicit_dimensions_is_refused_by_iso_3601():
feature = ORingGroove(id="og", x=30.0, cord=3.53)
with pytest.raises(ValueError, match="ISO 3601-2"):
feature.validate(shaft(feature))
def test_an_axial_oring_groove_is_refused_rather_than_drawn_on_the_silhouette():
feature = ORingGroove(id="og", x=30.0, cord=3.53, housing="axial", b=4.7, h=2.7)
with pytest.raises(ValueError, match="face groove"):
feature.validate(shaft(feature))
def test_an_oring_groove_round_trips_because_its_field_is_not_called_kind():
"""``to_dict`` writes the feature kind under ``kind``.
A field of the same name would overwrite the discriminator - the payload
would come back as ``{"kind": "radial", ...}`` and ``feature_from_dict``
would refuse it - so the housing kind is carried by ``housing``.
"""
feature = ORingGroove(id="og", x=30.0, cord=3.53, b=4.7, h=2.7)
payload = feature.to_dict()
assert payload["kind"] == "oring_groove"
assert payload["housing"] == "radial"
assert feature_from_dict(payload) == feature
# -- holes, bores, bolt circles, teeth ---------------------------------------
def test_a_cross_hole_draws_two_hidden_lines_through_the_silhouette():
feature = CrossHole(id="xh", x=30.0, diameter=6.0)
part = shaft(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
assert len(prims) == 2
assert {p.role for p in prims} == {"hidden"}
assert prims[0].p1 == (27.0, -15.0) and prims[0].p2 == (27.0, 15.0)
assert prims[1].p1 == (33.0, -15.0) and prims[1].p2 == (33.0, 15.0)
def test_an_inclined_cross_hole_is_refused_rather_than_drawn_as_if_radial():
feature = CrossHole(id="xh", x=30.0, diameter=6.0, angle=45.0)
with pytest.raises(ValueError, match="angle"):
feature.validate(shaft(feature))
def test_an_axial_bore_edits_the_radial_profile_and_is_not_flagged_as_omitted():
feature = AxialBore(id="ab", at="start", diameter=8.0, depth=12.0)
part = shaft(feature)
feature.validate(part)
(edit,) = feature.radial_edit(part)
assert edit == {"kind": "inner", "x0": 0.0, "x1": 12.0, "radius": 4.0}
assert feature.removal_box(part) is None
def test_two_axial_bores_from_the_same_end_are_refused_with_both_names():
a = AxialBore(id="ab1", at="start", diameter=8.0, depth=12.0)
b = AxialBore(id="ab2", at="start", diameter=6.0, depth=20.0)
part = shaft(a, b)
with pytest.raises(ValueError) as excinfo:
a.validate(part)
assert "ab1" in str(excinfo.value) and "ab2" in str(excinfo.value)
def test_a_bolt_circle_draws_its_holes_and_its_pitch_circle_in_the_end_view():
feature = HoleCircle(id="bc", x=10.0, pcd=36.0, count=6, diameter=5.0)
part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
feature.validate(part)
prims = feature.prims(part, "side", CTX)
holes = [p for p in prims if isinstance(p, Circle) and p.role == "visible"]
pitch = [p for p in prims if isinstance(p, Circle) and p.role == "center"]
assert len(holes) == 6 and len(pitch) == 1
assert pitch[0].radius == pytest.approx(18.0)
assert holes[0].center == (pytest.approx(18.0), pytest.approx(0.0))
assert holes[1].center[0] == pytest.approx(18.0 * math.cos(math.radians(60.0)))
def test_a_bolt_circle_that_does_not_fit_the_flange_is_refused_by_name():
feature = HoleCircle(id="bc", x=10.0, pcd=58.0, count=6, diameter=5.0)
part = RevolvedPart(name="flange", segments=(Segment(20.0, 60.0),), features=(feature,))
with pytest.raises(ValueError, match="bc|pcd"):
feature.validate(part)
def test_gear_teeth_reuse_the_repository_involute_generator():
feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
part = RevolvedPart(name="pinion", segments=(Segment(15.0, 44.0),), features=(feature,))
feature.validate(part)
(poly, pitch) = feature.prims(part, "side", CTX)[:2]
expected = generate_full_gear_outline(2.0, 20, 20.0, 0.0, "RH", (0.0, 0.0))
assert poly.points == tuple(expected)
assert poly.closed is True
assert pitch.radius == pytest.approx(20.0)
def test_gear_teeth_whose_tip_diameter_disagrees_with_the_segment_are_refused():
feature = GearTeeth(id="g1", segment=0, module=2.0, teeth=20)
part = RevolvedPart(name="pinion", segments=(Segment(15.0, 40.0),), features=(feature,))
with pytest.raises(ValueError) as excinfo:
feature.validate(part)
assert "44" in str(excinfo.value) and "40" in str(excinfo.value)
# -- prismatic features ------------------------------------------------------
def test_a_hole_draws_a_circle_in_true_shape_and_hidden_lines_in_the_side_view():
feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0)
part = plate(feature)
feature.validate(part)
front = feature.prims(part, "front", CTX)
circle = next(p for p in front if isinstance(p, Circle))
assert circle.center == (20.0, 25.0) and circle.radius == 5.0
assert sum(1 for p in front if isinstance(p, Line) and p.role == "center") == 2
side = feature.prims(part, "side", CTX)
assert {p.role for p in side} == {"hidden"}
# a through hole runs the whole thickness, drawn from the machined face down
assert side[0].p1 == (20.0, 8.0) and side[0].p2 == (20.0, 0.0)
top = feature.prims(part, "top", CTX)
assert top[0].p1 == (15.0, 8.0)
def test_a_blind_hole_and_a_pocket_are_cut_from_the_same_face():
"""Both enter at v = thickness - the face the front view looks at.
Nothing downstream can catch a disagreement here: two blind features cut
from opposite faces of the same plate are each a legal drawing on their
own, `omission()` returns None for both, and the validator sees only
hidden lines inside the material. So it has to be pinned at the source.
"""
hole = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, depth=5.0)
pocket = Pocket(
id="p1",
outline=((40.0, 10.0), (70.0, 10.0), (70.0, 40.0), (40.0, 40.0)),
depth=5.0,
)
part = plate(hole, pocket)
hole.validate(part)
pocket.validate(part)
for view in ("side", "top"):
entry = {round(p.p1[1], 9) for p in hole.prims(part, view, CTX)} | {
round(p.p2[1], 9) for p in hole.prims(part, view, CTX)
}
pocket_entry = {round(p.p1[1], 9) for p in pocket.prims(part, view, CTX)} | {
round(p.p2[1], 9) for p in pocket.prims(part, view, CTX)
}
# the same two v values: the face at 8.0 and a floor 5 mm below it
assert entry == {8.0, 3.0}
assert pocket_entry == {8.0, 3.0}
(left, right, floor) = hole.prims(part, "side", CTX)
assert left.p1 == (22.0, 8.0) and left.p2 == (22.0, 3.0)
assert right.p1 == (28.0, 8.0) and right.p2 == (28.0, 3.0)
assert floor.p1 == (22.0, 3.0) and floor.p2 == (28.0, 3.0)
def test_a_counterbore_is_stepped_down_from_the_machined_face_too():
feature = Hole(id="h1", x=20.0, y=25.0, diameter=6.0, cbore_d=12.0, cbore_depth=3.0)
part = plate(feature)
feature.validate(part)
# the side view runs u = part Y, so the hole sits at u = 25 and the
# counterbore walls at 25 +/- 6.
side = feature.prims(part, "side", CTX)
cbore = [p for p in side if {19.0, 31.0} & {p.p1[0], p.p2[0]}]
assert len(cbore) == 3
assert {round(p.p1[1], 9) for p in cbore} | {round(p.p2[1], 9) for p in cbore} == {8.0, 5.0}
# and the bore itself still runs the full thickness from that same face
bore = [p for p in side if {22.0, 28.0} & {p.p1[0], p.p2[0]}]
assert [(p.p1, p.p2) for p in bore] == [
((22.0, 8.0), (22.0, 0.0)),
((28.0, 8.0), (28.0, 0.0)),
]
def test_a_hole_outside_the_outline_is_refused_by_name():
feature = Hole(id="h1", x=200.0, y=25.0, diameter=10.0)
with pytest.raises(ValueError, match="outline"):
feature.validate(plate(feature))
def test_a_tapped_hole_draws_the_minor_circle_and_a_three_quarter_major_arc():
feature = Hole(id="h1", x=20.0, y=25.0, diameter=10.0, thread="M10")
part = plate(feature)
feature.validate(part)
front = feature.prims(part, "front", CTX)
circles = [p for p in front if isinstance(p, Circle)]
arcs = [p for p in front if isinstance(p, Arc)]
assert circles[0].radius == pytest.approx(4.0)
assert len(arcs) == 1 and arcs[0].radius == pytest.approx(5.0)
def test_a_slot_is_an_obround_of_two_lines_and_two_semicircles():
feature = Slot(id="s1", x=40.0, y=25.0, length=30.0, width=10.0)
part = plate(feature)
feature.validate(part)
prims = feature.prims(part, "front", CTX)
lines = [p for p in prims if isinstance(p, Line) and p.role == "visible"]
arcs = [p for p in prims if isinstance(p, Arc)]
assert len(lines) == 2 and len(arcs) == 2
assert lines[0].p1 == (pytest.approx(30.0), pytest.approx(30.0))
assert arcs[0].center == (pytest.approx(50.0), pytest.approx(25.0))
assert feature.removal_box(part) == (25.0, 20.0, 55.0, 30.0)
def test_a_slot_narrower_than_it_is_long_is_required():
feature = Slot(id="s1", x=40.0, y=25.0, length=5.0, width=10.0)
with pytest.raises(ValueError, match="length"):
feature.validate(plate(feature))
def test_a_pocket_can_declare_itself_unhatched_in_a_longitudinal_section():
feature = Pocket(
id="p1",
outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)),
depth=4.0,
no_section_hatch=True,
)
part = plate(feature)
feature.validate(part)
(poly,) = feature.prims(part, "front", CTX)
assert poly.closed is True and poly.role == "visible"
assert feature.no_section_hatch is True
assert feature.removal_box(part) == (10.0, 10.0, 30.0, 30.0)
def test_a_pocket_deeper_than_the_plate_is_refused():
feature = Pocket(
id="p1",
outline=((10.0, 10.0), (30.0, 10.0), (30.0, 30.0)),
depth=20.0,
)
with pytest.raises(ValueError, match="thickness"):
feature.validate(plate(feature))
def test_a_corner_fillet_replaces_a_vertex_with_two_tangent_points_and_an_arc():
feature = CornerFillet(id="cf", vertex=1, radius=10.0)
part = plate(feature)
feature.validate(part)
edit = feature.outline_edit(part)
assert edit["vertex"] == 1
assert edit["before"] == (pytest.approx(70.0), pytest.approx(0.0))
assert edit["after"] == (pytest.approx(80.0), pytest.approx(10.0))
assert edit["arc"].center == (pytest.approx(70.0), pytest.approx(10.0))
assert edit["arc"].radius == pytest.approx(10.0)
def test_a_corner_chamfer_replaces_a_vertex_with_two_points_and_no_arc():
feature = CornerChamfer(id="cc", vertex=1, size=6.0)
part = plate(feature)
feature.validate(part)
edit = feature.outline_edit(part)
assert edit["before"] == (pytest.approx(74.0), pytest.approx(0.0))
assert edit["after"] == (pytest.approx(80.0), pytest.approx(6.0))
assert edit["arc"] is None
def test_a_corner_radius_larger_than_its_edges_is_refused():
feature = CornerFillet(id="cf", vertex=1, radius=60.0)
with pytest.raises(ValueError, match="edge"):
feature.validate(plate(feature))
def test_a_bend_line_is_representation_only_and_says_so():
feature = BendLine(id="b1", p1=(0.0, 25.0), p2=(80.0, 25.0), angle=90.0, inside_radius=2.0)
part = plate(feature)
feature.validate(part)
line, text = feature.prims(part, "front", CTX)
assert isinstance(line, Line) and line.role == "phantom"
assert isinstance(text, Text)
assert "R2" in text.text and "90" in text.text
assert feature.dims(part, "front", CTX) == ()
def test_a_hole_pattern_expands_before_it_reaches_the_part():
spec = {
"kind": "prismatic",
"name": "plate",
"thickness": 8.0,
"outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
"features": [
{
"kind": "hole_pattern",
"id": "hp",
"pattern": "grid",
"base": {"x": 10.0, "y": 10.0, "diameter": 6.0},
"count": 3,
"spacing": 20.0,
"count_y": 2,
"spacing_y": 25.0,
}
],
}
part = build_part(spec)
assert len(part.features) == 6
assert all(isinstance(f, Hole) for f in part.features)
assert [f.id for f in part.features][:3] == ["hp_0_0", "hp_1_0", "hp_2_0"]
assert part.features[2].x == pytest.approx(50.0)
assert part.features[3].y == pytest.approx(35.0)
def test_a_polar_hole_pattern_places_its_holes_on_the_pitch_circle():
pattern = HolePattern(
id="hp",
pattern="polar",
base={"x": 40.0, "y": 25.0, "diameter": 5.0},
count=4,
pcd=30.0,
)
holes = pattern.expand()
assert len(holes) == 4
assert holes[0].x == pytest.approx(55.0) and holes[0].y == pytest.approx(25.0)
assert holes[1].x == pytest.approx(40.0) and holes[1].y == pytest.approx(40.0)
def test_a_linear_pattern_without_a_spacing_is_refused():
pattern = HolePattern(
id="hp",
pattern="linear",
base={"x": 10.0, "y": 10.0, "diameter": 6.0},
count=3,
)
with pytest.raises(ValueError, match="spacing"):
pattern.expand()
def test_every_feature_declares_its_dimension_intents_against_its_own_id():
features = (
Chamfer(id="c1", at="start", size=2.0),
Keyway(id="kw", segment=1, length=25.0),
Thread(id="th", segment=2, designation="M20", length=12.0),
CrossHole(id="xh", x=30.0, diameter=6.0),
)
part = shaft(*features)
for feature in features:
for intent in feature.dims(part, "front", CTX):
assert intent.feature == feature.id