""" Unit tests for the PROFILE record and the three N-generic arrangements. Several expected values here are taken directly from the committed oracle -- the solved spoke radius, the solved fin core side, the setback, the junction web, the ring corner limit and the edge vector. Those quantities do not depend on the junction fillet radius, so they can be pinned before the generator supplies its parameter defaults. The arrangements are genuinely N-generic in the reference, so each is exercised at both N=3 and N=4 rather than only at the member count that happens to be under development. """ from __future__ import annotations import math import pytest from mechcomp.geom.arrangements import ( fin_bore_side, fin_members, fin_profile, fin_web, regular_polygon, ring_polygon_profile, spoke_members, spoke_profile, spoke_web, ) from mechcomp.geom.core import Centred, Profile, centred, centred_members, \ profile_failed from mechcomp.geom.primitives import SB_FACE_BOTH_OUT, sb_dist, sb_signed_area from mechcomp.geom.records import Geo from mechcomp.geom.region import area, is_region_simple, nparts, pointlist_bounds from mechcomp.geom.report import ( echo_num, echo_vec, first_failure, metrics, universal_checks, ) def geo(**kw) -> Geo: """The oracle's default geometry, read from a recorded report.""" base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.25, wall_inside=1.2, wall_outside=1.2, wall_edge=1.2, min_wall=1.2) base.update(kw) return Geo(**base) def unit_ngon(n, start=90.0): return [(math.cos(math.radians(start + i * 360.0 / n)), math.sin(math.radians(start + i * 360.0 / n))) for i in range(n)] def built(p: Profile, g: Geo, n: int): c = centred(p, g) m = metrics(c.section, c.shell, c.members, g) fail = first_failure(list(p.checks) + universal_checks(c.section, m, n, g)) return c, m, fail # ---------------------------------------------------------------------------- # PROFILE record # ---------------------------------------------------------------------------- def test_a_built_profile_is_ok(): p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y") assert p.ok assert len(p.members) == 3 def test_profile_failed_carries_the_message_as_a_check(): """ OpenSCAD has no exceptions, so failure travels as an ordinary failing check and stays subject to the same first-failure ordering as everything else. """ p = profile_failed("Y: cannot open a 99 mm web.") assert not p.ok assert p.members == [] and p.shell == [] assert first_failure(p.checks) == "Y: cannot open a 99 mm web." def test_failure_is_signalled_by_having_no_members(): assert not Profile(members=[], shell=[]).ok # ---------------------------------------------------------------------------- # Centring # ---------------------------------------------------------------------------- def test_centred_puts_the_envelope_on_the_origin(): g = geo() c = centred(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g) lo, hi = pointlist_bounds([p for path in c.shell 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_centred_keeps_everything_registered(): """ Section, straps, cavities and shell take the same shift, so a strap still sits inside its own channel afterwards. """ g = geo() c = centred(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g) assert area(c.straps) < area(c.cavity) < area(c.shell) def test_centred_carries_shifted_members(): """ Measuring a shifted shell against unshifted members reports every cavity as escaping the envelope -- a leak of the whole cavity area from geometry that is perfectly fine. The members travel with the regions to remove the chance. """ g = geo() p = spoke_profile(3, 90.0, 1.2, 1.5, g, "Y") c = centred(p, g) assert metrics(c.section, c.shell, c.members, g).leak == pytest.approx(0.0, abs=1e-9) stale = metrics(c.section, c.shell, p.members, g) assert stale.leak > 1.0 def test_centred_members_apply_the_same_shift(): g = geo() p = spoke_profile(3, 90.0, 1.2, 1.5, g, "Y") c = centred(p, g) for a, b in zip(p.members, centred_members(p, c.shift)): assert b.cx == pytest.approx(a.cx + c.shift[0]) assert b.angle == a.angle and b.face == a.face def test_section_is_cleaned_before_it_is_measured(): """ Cleaning removes collinear and coincident vertices left by exact butt joints. Measuring first would report on geometry that is not what gets extruded. """ g = geo() c = centred(fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin"), g) for path in c.section: assert len(path) >= 3 assert is_region_simple(c.section) # ---------------------------------------------------------------------------- # Ring arrangement # ---------------------------------------------------------------------------- def test_ring_rejects_a_degenerate_outline(): p = ring_polygon_profile([(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)], geo(), 1.2, 0.0, "Equilateral Triangle") assert not p.ok assert "degenerate" in first_failure(p.checks) def test_ring_rejects_an_unreachable_web(): p = ring_polygon_profile(unit_ngon(3), geo(), 1e6, 0.0, "Equilateral Triangle") assert not p.ok assert "no polygon size gives" in first_failure(p.checks) def test_ring_matches_the_oracles_solved_geometry(): """ Equilateral Triangle at the oracle's defaults. These four values are recorded in the committed fixture set and none of them depends on the junction fillet radius. """ g = geo() p = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "Equilateral Triangle") assert p.ok assert echo_num(p.info["RING_CORNER_R_MAX_MM"]) == 2.87663 assert echo_num(p.info["RING_CORNER_WEB_MM"]) == 1.2 assert echo_vec(p.info["RING_EDGES_MM"]) == "[20.5209, 20.5209, 20.5209]" def test_ring_scale_depends_on_the_seed_size_not_the_shape(): """ RING_SCALE is relative to the outline supplied, so it is a property of the caller's seed. The solved edge length is the size-independent quantity. """ g = geo() small = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "t") large = ring_polygon_profile([(200 * x, 200 * y) for x, y in unit_ngon(3)], g, 1.2, 0.0, "t") assert small.info["RING_SCALE"] != pytest.approx(large.info["RING_SCALE"]) assert echo_vec(small.info["RING_EDGES_MM"]) == echo_vec(large.info["RING_EDGES_MM"]) def test_ring_rejects_a_corner_radius_beyond_the_envelope_limit(): g = geo() p = ring_polygon_profile(unit_ngon(3), g, 1.2, 50.0, "Equilateral Triangle") fail = first_failure(p.checks) assert fail is not None and "corner radius" in fail def test_ring_builds_at_n3_and_n4(): g = geo() for n in (3, 4): p = ring_polygon_profile(unit_ngon(n), g, 1.2, 0.0, "ring") assert p.ok c, m, fail = built(p, g, n) assert fail is None assert m.parts == 1 and m.slots == n def test_ring_has_an_enclosed_bore(): g = geo() p = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "ring") assert len(p.bore) == 3 c, m, _ = built(p, g, 3) assert len(c.section) > 1 # ---------------------------------------------------------------------------- # Spoke arrangement # ---------------------------------------------------------------------------- def test_spokes_face_open_air_on_both_sides(): """ Design rule 4. Forcing an interior direction on a spoke is what made earlier revisions chiral under asymmetric wall settings. """ for m in spoke_members(3, 9.0, 90.0): assert m.face == SB_FACE_BOTH_OUT assert m.inside_dir is None def test_spoke_profile_has_no_bore(): p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y") assert p.bore == [] assert p.info["NOTE"] == "inside wall unused: no enclosed bore" def test_spoke_radius_matches_the_oracle(): """Y at the oracle's defaults records SPOKE_RADIUS_MM = 9.1713.""" p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y") assert echo_num(p.info["SPOKE_RADIUS_MM"]) == 9.1713 assert echo_num(p.info["SPOKE_WEB_MM"]) == 1.2 def test_spoke_envelope_matches_the_oracle(): g = geo() c, m, _ = built(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g, 3) assert echo_num(m.size_x) == 33.8488 assert echo_num(m.size_y) == 29.3139 def test_spoke_solve_hits_the_requested_web(): g = geo() for web in (1.0, 1.2, 2.0): p = spoke_profile(3, 90.0, web, 1.5, g, "Y") assert spoke_web(3, p.info["SPOKE_RADIUS_MM"], 90.0, g) == \ pytest.approx(web, abs=1e-6) def test_spoke_rejects_an_unreachable_web(): p = spoke_profile(3, 90.0, 1e6, 1.5, geo(), "Y") assert not p.ok assert "cannot open a" in first_failure(p.checks) def test_spoke_requires_a_positive_fillet(): """ A hull-plugged centre with no fillet meets the spokes along an exactly tangent boundary: a valid outline that cannot be tessellated. """ p = spoke_profile(3, 90.0, 1.2, 0.0, geo(), "Y") fail = first_failure(p.checks) assert fail is not None and "greater than zero" in fail def test_spokes_build_at_n3_and_n4(): g = geo() for n in (3, 4): c, m, fail = built(spoke_profile(n, 90.0, 1.2, 1.5, g, "spoke"), g, n) assert fail is None assert m.parts == 1 and m.slots == n # ---------------------------------------------------------------------------- # Fin arrangement # ---------------------------------------------------------------------------- def test_regular_polygon_has_the_requested_side_length(): for n in (3, 4, 5): path = regular_polygon(n, 10.0) for i in range(n): assert sb_dist(path[i], path[(i + 1) % n]) == pytest.approx(10.0) def test_fins_lie_tangentially_not_radially(): """ Each member lies along a side of the core polygon, slid so it stops short of the corner behind and overhangs the corner ahead. Sliding is what makes the fin. """ g = geo() plain = fin_members(3, 13.4, 0.0, 0.0, g) slid = fin_members(3, 13.4, 6.25, 0.0, g) for a, b in zip(plain, slid): assert a.angle == pytest.approx(b.angle) assert sb_dist((a.cx, a.cy), (b.cx, b.cy)) == pytest.approx(6.25) def test_fin_geometry_matches_the_oracle(): """Three-Fin at the oracle's defaults. None of these depends on the fillet.""" g = geo() p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") assert p.ok assert echo_num(p.info["FIN_CORE_SIDE_MM"]) == 13.4028 assert echo_num(p.info["FIN_BORE_SIDE_MM"]) == 7.5 assert echo_num(p.info["FIN_SETBACK_MM"]) == 3.77783 assert echo_num(p.info["FIN_JUNCTION_WEB_MM"]) == 2.29919 assert echo_num(p.info["FIN_PROJECTION_MM"]) == 6.25 def test_fin_core_is_solved_for_whichever_constraint_binds(): """ The core size satisfies the junction web AND the bore, whichever demands more. Raising the bore requirement past the web's must grow the core. """ g = geo() small_bore = fin_profile(3, 6.25, 1.2, 1.0, 0.0, 1.5, g, "f") large_bore = fin_profile(3, 6.25, 1.2, 20.0, 0.0, 1.5, g, "f") assert large_bore.info["FIN_CORE_SIDE_MM"] > small_bore.info["FIN_CORE_SIDE_MM"] def test_fin_bore_side_shrinks_with_a_thicker_inside_wall(): assert fin_bore_side(3, 13.4028, geo(wall_inside=2.4)) < \ fin_bore_side(3, 13.4028, geo(wall_inside=1.2)) def test_fin_rejects_an_unreachable_web(): p = fin_profile(3, 6.25, 1e6, 7.5, 0.0, 1.5, geo(), "Three-Fin") assert not p.ok assert "cannot open a" in first_failure(p.checks) def test_fin_rejects_an_unreachable_bore(): p = fin_profile(3, 6.25, 1.2, 1e6, 0.0, 1.5, geo(), "Three-Fin") assert not p.ok assert "cannot reach a" in first_failure(p.checks) def test_fin_requires_a_positive_setback(): """ A non-positive setback means the members overlap instead of stepping cyclically, so the arrangement is no longer what it claims to be. """ g = geo() p = fin_profile(3, 0.0, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") fail = first_failure(p.checks) if fail is not None: assert "setback" in fail or "bore" in fail def test_fin_requires_a_positive_fillet(): p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 0.0, geo(), "Three-Fin") fail = first_failure(p.checks) assert fail is not None and "greater than zero" in fail def test_fins_build_at_n3_and_n4(): g = geo() for n in (3, 4): c, m, fail = built(fin_profile(n, 6.25, 1.2, 7.5, 0.0, 1.5, g, "fin"), g, n) assert fail is None assert m.parts == 1 and m.slots == n def test_fin_has_an_enclosed_bore(): g = geo() p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") assert len(p.bore) == 3 def test_cleaning_actually_removes_the_collinear_vertices(): """ Found by mutation testing: asserting the section is simple and has three or more points per path passes with or without cleaning, so neither shows that cleaning did anything. A Three-Fin section carries 17 collinear vertices before cleaning -- exact butt joints leave them -- and none afterwards. They are harmless in 2D and leave zero-area triangles the tessellator cannot resolve, so a section that measures perfectly still fails to extrude. """ from mechcomp.geom.join import section as cut_section from mechcomp.geom.rounding import is_collinear g = geo() p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") def collinear(rgn): return sum(1 for path in rgn for i in range(len(path)) if is_collinear((path[(i - 1) % len(path)], path[i], path[(i + 1) % len(path)]))) raw = cut_section(p.shell, p.members, g, p.bore) assert collinear(raw) > 0 assert collinear(centred(p, g).section) == 0