""" Unit tests for the junction, bore and ring-fit strategies. Two things are checked here that the primitive suites cannot reach: that the solvers converge on the quantity they claim to solve, and that the structural claims in the reference's own comments actually hold. Where a comment says a unit square and a 200 mm square must fit to the same result, that is a testable assertion and it is tested. The rejection paths matter as much as the acceptance paths. Ten of the oracle's 123 cases are rejections, and a port that produces good geometry while quietly dropping the constraints that made it trustworthy is a failed port. """ from __future__ import annotations import math import pytest from mechcomp.geom.join import ( bore_from_members, bore_valid, cavity_region, centering_shift, end_face, face_line, far_face_line, fillet_concave, fillet_junctions, fit_ring, hull_cap, ring_fit_scale, ring_max_corner_r, ring_members, ring_shell, ring_web, scale_about_centroid, section, sleeve_butt, sleeve_shell, strap_region, fillet_pair, ) from mechcomp.geom.primitives import ( SB_FACE_BOTH_OUT, SB_FACE_PLUS_IN, sb_dist, sb_path_gap, sb_signed_area, ) from mechcomp.geom.records import (Geo, Member, cavity_path, sleeve_path, strap_path) from mechcomp.geom.region import area, is_region_simple, nparts, pointlist_bounds def geo(**kw) -> Geo: base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.2, wall_inside=1.0, wall_outside=1.6, wall_edge=1.2, min_wall=0.8) base.update(kw) return Geo(**base) def ngon(n: int, r: float = 20.0, start: float = 90.0): return [(r * math.cos(math.radians(start + i * 360.0 / n)), r * math.sin(math.radians(start + i * 360.0 / n))) for i in range(n)] TRI = ngon(3) QUAD = ngon(4) # ---------------------------------------------------------------------------- # Face lines # ---------------------------------------------------------------------------- def test_face_lines_sit_at_the_declared_reaches(): g = geo() m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN) up, _ = face_line(m, g, 1) dn, _ = face_line(m, g, -1) assert up[1] == pytest.approx(g.reach_plus(SB_FACE_PLUS_IN)) assert dn[1] == pytest.approx(-g.reach_minus(SB_FACE_PLUS_IN)) def test_face_line_direction_is_the_member_axis(): g = geo() m = Member(0.0, 0.0, 37.0) assert face_line(m, g, 1)[1] == pytest.approx(m.axis) def test_far_face_line_picks_the_farther_surface(): """The point of this is not needing to know which way either member faces.""" g = geo() m = Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT) from_below = far_face_line(m, g, (0.0, -100.0)) from_above = far_face_line(m, g, (0.0, 100.0)) assert from_below[0][1] > 0 assert from_above[0][1] < 0 # ---------------------------------------------------------------------------- # Junctions # ---------------------------------------------------------------------------- def test_sleeve_butt_runs_through_to_the_far_surface(): """ The butted sleeve must reach past its neighbour's centreline and stop on the far face, not at the near one. That full-width overlap is what carries load. The arriving member sits at 0 degrees, so its trailing end -- the end sleeve_butt cuts -- faces the origin and its leading end faces away. """ g = geo() target = Member(0.0, 0.0, 90.0, SB_FACE_BOTH_OUT) arriving = Member(20.0, 0.0, 0.0, SB_FACE_BOTH_OUT) butted = sleeve_butt(arriving, g, target) far_face = -g.reach_minus(SB_FACE_BOTH_OUT) assert min(p[0] for p in butted) == pytest.approx(far_face) assert min(p[0] for p in butted) < 0.0 def test_sleeve_butt_leaves_the_free_end_alone(): """Only the trailing end is cut; the leading end keeps its edge wall.""" g = geo() target = Member(0.0, 0.0, 90.0) arriving = Member(20.0, 0.0, 0.0) plain = sleeve_path(arriving, g) butted = sleeve_butt(arriving, g, target) assert max(p[0] for p in butted) == pytest.approx(max(p[0] for p in plain)) def test_end_face_spans_the_full_sleeve_thickness(): g = geo() m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN) a, b = end_face(m, g, 1) expected = g.reach_plus(SB_FACE_PLUS_IN) + g.reach_minus(SB_FACE_PLUS_IN) assert sb_dist(a, b) == pytest.approx(expected) def test_end_face_ends_are_opposite(): g = geo() m = Member(0.0, 0.0, 0.0) lead = end_face(m, g, 1) trail = end_face(m, g, -1) assert lead[0][0] > 0 and trail[0][0] < 0 def test_hull_cap_needs_three_points(): assert hull_cap([[(0.0, 0.0), (1.0, 0.0)]]) == [] def test_hull_cap_plugs_the_gap_between_end_faces(): g = geo() faces = [end_face(Member(0.0, 0.0, a, SB_FACE_BOTH_OUT), g, 1) for a in (0.0, 120.0, 240.0)] cap = hull_cap(faces) assert len(cap) >= 3 assert area([cap]) > 0 # ---------------------------------------------------------------------------- # Fillets -- cosmetic, never structural # ---------------------------------------------------------------------------- def test_fillet_concave_leaves_a_convex_path_bit_exact(): square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] assert fillet_concave(square, 2.0) == square def test_fillet_concave_rounds_only_the_reflex_corner(): ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0), (4.0, 4.0), (4.0, 10.0), (0.0, 10.0)] rounded = fillet_concave(ell, 1.0) assert len(rounded) > len(ell) for v in (0.0, 0.0), (10.0, 0.0), (10.0, 4.0), (0.0, 10.0): assert v in rounded def test_fillet_pair_with_no_radius_returns_both_untouched(): a = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)] b = [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)] assert fillet_pair(a, b, 0.0) == [a, b] def test_fillet_pair_leaves_non_merging_solids_alone(): """Nothing sane to round, so the pair comes back rather than guessed at.""" a = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)] b = [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)] assert len(fillet_pair(a, b, 1.0)) == 2 def test_fillet_pair_rounds_a_merged_junction(): a = [(0.0, 0.0), (10.0, 0.0), (10.0, 3.0), (0.0, 3.0)] b = [(0.0, 0.0), (3.0, 0.0), (3.0, 10.0), (0.0, 10.0)] merged = fillet_pair(a, b, 1.0) assert len(merged) == 1 assert len(merged[0]) > 6 # ---------------------------------------------------------------------------- # Bore # ---------------------------------------------------------------------------- def test_bore_needs_every_member_to_have_an_interior_face(): g = geo() ms = ring_members(TRI, g) ms[0] = Member(ms[0].cx, ms[0].cy, ms[0].angle, SB_FACE_BOTH_OUT) assert bore_from_members(ms, g) == [] def test_bore_has_one_vertex_per_member(): g = geo() for poly in (TRI, QUAD, ngon(5)): ms = ring_members(poly, g) assert len(bore_from_members(ms, g)) == len(poly) def test_bore_is_inset_from_the_centreline_polygon(): """The bore is bounded by inside-wall surfaces, so it sits inside.""" g = geo() ms = ring_members(TRI, g) bore = bore_from_members(ms, g) assert abs(sb_signed_area(bore)) < abs(sb_signed_area(TRI)) def test_bore_valid_rejects_a_degenerate_interior(): """ A collapsed interior turns itself inside out rather than vanishing, so area alone proves nothing -- the centre must lie inside every inside wall. """ g = geo() ms = ring_members(TRI, g) assert not bore_valid([(0.0, 0.0), (1e-3, 0.0), (0.0, 1e-3)], ms, g) def test_bore_valid_accepts_a_real_interior(): g = geo() fitted = fit_ring(TRI, geo(), 1.2) ms = ring_members(fitted, g) assert bore_valid(bore_from_members(ms, g), ms, g) # ---------------------------------------------------------------------------- # Ring members and the fit solver # ---------------------------------------------------------------------------- def test_ring_has_one_member_per_edge_all_facing_inward(): g = geo() ms = ring_members(TRI, g) assert len(ms) == 3 assert all(m.face != SB_FACE_BOTH_OUT for m in ms) def test_ring_members_are_centred_on_their_edges(): """Centring is what keeps the profile mirror-symmetric.""" g = geo() ms = ring_members(QUAD, g) for i, m in enumerate(ms): a, b = QUAD[i], QUAD[(i + 1) % 4] assert (m.cx, m.cy) == pytest.approx(((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)) def test_fit_ring_hits_the_requested_web(): for poly in (TRI, QUAD): fitted = fit_ring(poly, geo(), 1.2) assert fitted is not None assert ring_web(fitted, geo()) == pytest.approx(1.2, abs=1e-6) def test_fit_is_shape_only_not_size(): """ The reference states this outright: a unit polygon and a 200 mm polygon must fit to the same result, because the outline carries shape and the solver supplies scale. """ small = fit_ring(ngon(3, 1.0), geo(), 1.2) large = fit_ring(ngon(3, 200.0), geo(), 1.2) assert ring_web(small, geo()) == pytest.approx(ring_web(large, geo()), abs=1e-6) assert abs(sb_signed_area(small)) == pytest.approx(abs(sb_signed_area(large)), rel=1e-6) def test_fit_returns_none_when_the_web_is_unreachable(): """An infeasible request must be reported, not approximated.""" assert fit_ring(TRI, geo(), 1e6) is None def test_a_larger_web_needs_a_larger_polygon(): a = fit_ring(TRI, geo(), 1.0) b = fit_ring(TRI, geo(), 3.0) assert abs(sb_signed_area(b)) > abs(sb_signed_area(a)) def test_scale_about_centroid_preserves_shape(): scaled = scale_about_centroid(TRI, 3.0) assert abs(sb_signed_area(scaled)) == pytest.approx( 9.0 * abs(sb_signed_area(TRI))) # ---------------------------------------------------------------------------- # Ring envelope # ---------------------------------------------------------------------------- def test_ring_shell_encloses_the_centreline_polygon(): g = geo() shell = ring_shell(TRI, g, 0.0) assert abs(sb_signed_area(shell)) > abs(sb_signed_area(TRI)) def test_ring_shell_corner_radius_removes_material(): g = geo() sharp = ring_shell(TRI, g, 0.0) rounded = ring_shell(TRI, g, 2.0) assert abs(sb_signed_area(rounded)) < abs(sb_signed_area(sharp)) def test_max_corner_r_leaves_the_minimum_wall_intact(): """ Corner rounding eats material exactly where a cavity approaches the corner, so the derived radius has to be checked against min_wall, not assumed safe. """ g = geo() fitted = fit_ring(TRI, g, 1.2) r = ring_max_corner_r(fitted, g) shell = ring_shell(fitted, g, r) cav = [cavity_path(m, g) for m in ring_members(fitted, g)] assert min(sb_path_gap(shell, c) for c in cav) >= g.min_wall - 1e-4 def test_exceeding_max_corner_r_breaches_the_minimum_wall(): """The derived limit is a real boundary, not a conservative guess.""" g = geo() fitted = fit_ring(TRI, g, 1.2) r = ring_max_corner_r(fitted, g) shell = ring_shell(fitted, g, r * 1.5) cav = [cavity_path(m, g) for m in ring_members(fitted, g)] assert min(sb_path_gap(shell, c) for c in cav) < g.min_wall # ---------------------------------------------------------------------------- # Assembly # ---------------------------------------------------------------------------- def _ring_section(poly, web=1.2, g=None): g = g or geo() fitted = fit_ring(poly, g, web) ms = ring_members(fitted, g) bore = bore_from_members(ms, g) r = ring_max_corner_r(fitted, g) shell = [ring_shell(fitted, g, r)] return section(shell, ms, g, bore), ms, g def test_a_fitted_ring_builds_one_connected_solid(): sec, ms, g = _ring_section(TRI) assert nparts(sec) == 1 def test_channel_count_matches_the_member_count(): for poly in (TRI, QUAD): sec, ms, g = _ring_section(poly) assert nparts(cavity_region(ms, g)) == len(poly) def test_the_finished_section_is_tessellatable(): """The manifold precondition, checked on a real profile rather than a fixture.""" sec, _, _ = _ring_section(TRI) assert is_region_simple(sec) def test_cavities_are_removed_from_the_solid(): sec, ms, g = _ring_section(TRI) shell_area = area(sec) + area(cavity_region(ms, g)) assert area(sec) < shell_area def test_no_member_pla_intrudes_into_another_channel(): """ Every sleeve is unioned before any cavity is cut, which is what guarantees this. Each strap must sit in clear space. """ sec, ms, g = _ring_section(TRI) for m in ms: strap = strap_path(m, g) for path in sec: assert sb_path_gap(strap, path) > 0.0 def test_bore_is_absent_when_not_supplied(): """Without a bore the interior stays solid, so the section is heavier.""" g = geo() fitted = fit_ring(TRI, g, 1.2) ms = ring_members(fitted, g) shell = [ring_shell(fitted, g, 0.0)] assert area(section(shell, ms, g)) > \ area(section(shell, ms, g, bore_from_members(ms, g))) def test_centering_shift_puts_the_envelope_on_the_origin(): g = geo() shell = [ring_shell(fit_ring(TRI, g, 1.2), g, 0.0)] dx, dy = centering_shift(shell) moved = [[(p[0] + dx, p[1] + dy) for p in path] for path in shell] lo, hi = pointlist_bounds([p for path in moved for p in path]) assert lo[0] + hi[0] == pytest.approx(0.0, abs=1e-9) assert lo[1] + hi[1] == pytest.approx(0.0, abs=1e-9) def test_sleeve_shell_merges_overlapping_sleeves(): g = geo() a = sleeve_path(Member(0.0, 0.0, 0.0), g) b = sleeve_path(Member(0.0, 0.0, 90.0), g) assert nparts(sleeve_shell([a, b])) == 1 def test_fillet_junctions_with_no_radius_is_a_no_op(): g = geo() paths = [sleeve_path(Member(0.0, 0.0, 0.0), g), sleeve_path(Member(0.0, 0.0, 90.0), g)] shell = sleeve_shell(paths) assert fillet_junctions(shell, [(0, 1)], paths, 0.0) == shell def test_fillet_junctions_adds_material_at_the_junction(): g = geo() paths = [sleeve_path(Member(0.0, 0.0, 0.0), g), sleeve_path(Member(0.0, 0.0, 90.0), g)] shell = sleeve_shell(paths) filleted = fillet_junctions(shell, [(0, 1)], paths, 1.0) assert area(filleted) > area(shell) # ---------------------------------------------------------------------------- # Cases found by mutation testing # ---------------------------------------------------------------------------- THIN_TRI = [(0.0, 40.0), (-3.0, -5.0), (3.0, -5.0)] def test_bore_valid_rejects_an_inverted_interior_with_real_area(): """ A ring too small for its straps produces a bore that has turned itself inside out: at R = 2 its area is over 1 mm2, far past the 0.01 guard, yet its centre lies on the wrong side of every inside wall. Area alone would accept this. The interior-side test is what rejects it. """ g = geo() ms = ring_members(ngon(3, 2.0), g) bore = bore_from_members(ms, g) assert abs(sb_signed_area(bore)) > 0.01 assert not bore_valid(bore, ms, g) def test_fit_never_settles_on_the_spurious_lower_branch(): """ A thin triangle measures a web of 1.385 at relative scale 0.425 -- above the 1.2 target and below 1. There the shortest edge is under one strap width, so that member overhangs both of its own corners and the corner-setback model no longer describes the geometry. The search starts at 1 for exactly this reason. Starting at 0 finds the lower root, and the result looks like a converged solve. """ g = geo() fitted = fit_ring(THIN_TRI, g, 1.2) assert fitted is not None n = len(fitted) shortest = min(sb_dist(fitted[i], fitted[(i + 1) % n]) for i in range(n)) assert shortest >= g.width - 1e-6 def test_section_cuts_the_bore_when_one_is_supplied(): """ The bore is unioned with the cavities before the cut, so a supplied bore must actually open the interior rather than being carried along unused. """ g = geo() fitted = fit_ring(TRI, g, 1.2) ms = ring_members(fitted, g) bore = bore_from_members(ms, g) shell = [ring_shell(fitted, g, 0.0)] with_bore = section(shell, ms, g, bore) without = section(shell, ms, g) assert area(without) - area(with_bore) == pytest.approx( abs(sb_signed_area(bore)), rel=1e-6)