""" Unit tests for the BOSL2 rounding port. The expected values here come from the pinned BOSL2 source read directly, not from this implementation. Where a number looks arbitrary -- 12 points on a right angle, a relative collinearity tolerance -- it is arbitrary, and that is the point: it is what produced the frozen oracle, and a tidier choice would be a different surface. Geometric sanity is checked separately from segmentation, so a failure says which of the two broke. """ from __future__ import annotations import math import pytest from mechcomp.geom.rounding import ( DEFAULT_FN, EPSILON, RoundoverTooLarge, approx, approx_pt, arc, deduplicate, is_collinear, path_merge_collinear, round_corners, segs, ) SQUARE10 = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] # ---------------------------------------------------------------------------- # segs -- $fn overrides everything # ---------------------------------------------------------------------------- def test_segs_ignores_radius_when_fn_is_set(): """With $fn positive the adaptive $fa/$fs path is never taken.""" assert segs(1.0) == DEFAULT_FN assert segs(1000.0) == DEFAULT_FN assert segs(0.001) == DEFAULT_FN def test_segs_floor_of_three(): assert segs(5.0, None, 2) == 3 assert segs(5.0, None, 12) == 12 def test_segs_for_an_arc_scales_with_swept_angle(): assert segs(5.0, 360.0) == 48 assert segs(5.0, 180.0) == 24 assert segs(5.0, 90.0) == 12 def test_segs_epsilon_guard_prevents_an_extra_segment(): """ The 2e-15 subtraction stops a fractionally-over angle rounding up. Without it, an angle a hair above an exact divisor gains a whole segment. """ assert segs(5.0, 90.0 + 1e-14) == 12 def test_segs_adaptive_path_when_fn_is_zero(): assert segs(10.0, None, 0, 12.0, 2.0) == 30 # ---------------------------------------------------------------------------- # arc # ---------------------------------------------------------------------------- def test_arc_returns_exactly_n_points_on_the_circle(): cp = (0.0, 0.0) pts = arc(12, cp, ((1.0, 0.0), (0.0, 1.0))) assert len(pts) == 12 for p in pts: assert math.hypot(*p) == pytest.approx(1.0) def test_arc_endpoints_are_the_requested_points(): pts = arc(7, (0.0, 0.0), ((1.0, 0.0), (0.0, 1.0))) assert pts[0] == pytest.approx((1.0, 0.0)) assert pts[-1] == pytest.approx((0.0, 1.0), abs=1e-12) def test_arc_takes_the_short_way_round(): """A quarter turn, not the three-quarter turn the other way.""" pts = arc(5, (0.0, 0.0), ((1.0, 0.0), (0.0, 1.0))) assert all(p[0] >= -1e-12 and p[1] >= -1e-12 for p in pts) def test_arc_direction_follows_the_cross_product_sign(): cw = arc(5, (0.0, 0.0), ((1.0, 0.0), (0.0, -1.0))) assert cw[-1] == pytest.approx((0.0, -1.0), abs=1e-12) assert all(p[1] <= 1e-12 for p in cw) # ---------------------------------------------------------------------------- # Comparison and cleanup helpers # ---------------------------------------------------------------------------- def test_approx_uses_the_library_epsilon(): assert approx(1.0, 1.0 + EPSILON / 2) assert not approx(1.0, 1.0 + EPSILON * 10) def test_deduplicate_open_keeps_the_last_point(): pts = [(0.0, 0.0), (0.0, 0.0), (1.0, 0.0), (1.0, 0.0)] assert deduplicate(pts, closed=False) == [(0.0, 0.0), (1.0, 0.0)] def test_deduplicate_closed_compares_last_against_first(): """A closed path whose final point repeats its first loses the duplicate.""" pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)] assert deduplicate(pts, closed=True) == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)] def test_collinearity_tolerance_is_relative_not_absolute(): """ A 1e-6 deviation over a 1 mm chord is not collinear; the same deviation over a 1e6 mm chord is. An absolute epsilon would call both the same. """ assert not is_collinear([(0.0, 0.0), (0.5, 1e-6), (1.0, 0.0)]) assert is_collinear([(0.0, 0.0), (5e5, 1e-6), (1e6, 0.0)]) def test_path_merge_collinear_drops_the_midpoint(): pts = [(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] merged = path_merge_collinear(pts, closed=True) assert (5.0, 0.0) not in merged assert len(merged) == 4 def test_path_merge_collinear_keeps_a_genuine_corner(): assert len(path_merge_collinear(SQUARE10, closed=True)) == 4 # ---------------------------------------------------------------------------- # round_corners -- segmentation # ---------------------------------------------------------------------------- def test_right_angle_corner_yields_twelve_points(): """ Half-angle 45, so n = max(3, ceil((90-45)/180 * 48)) = 12. This is the single number the whole area comparison rests on. """ rounded = round_corners(SQUARE10, 2.0) assert len(rounded) == 4 * 12 def test_segment_count_is_independent_of_radius(): """$fn segmentation depends on swept angle only. Halving r changes nothing.""" assert len(round_corners(SQUARE10, 2.0)) == len(round_corners(SQUARE10, 1.0)) def test_zero_radius_leaves_the_vertex_untouched(): assert round_corners(SQUARE10, [0.0, 0.0, 0.0, 0.0]) == SQUARE10 def test_mixed_radii_round_only_the_named_corners(): rounded = round_corners(SQUARE10, [2.0, 0.0, 0.0, 0.0]) assert len(rounded) == 12 + 3 for v in SQUARE10[1:]: assert v in rounded assert (0.0, 0.0) not in rounded def test_facet_count_is_a_parameter_not_a_constant(): assert len(round_corners(SQUARE10, 2.0, fn=96)) == 4 * 24 # ---------------------------------------------------------------------------- # round_corners -- geometry # ---------------------------------------------------------------------------- def test_rounded_square_stays_inside_the_original(): rounded = round_corners(SQUARE10, 2.0) for x, y in rounded: assert -1e-9 <= x <= 10.0 + 1e-9 assert -1e-9 <= y <= 10.0 + 1e-9 def test_rounding_removes_area_and_the_loss_is_bounded(): """ A square loses one square minus one inscribed circle across its four corners: 4r^2 - pi*r^2. The polygonal arc removes slightly more, so the measured loss sits just above the exact figure. """ def shoelace(path): n = len(path) return abs(sum(path[i][0] * path[(i + 1) % n][1] - path[(i + 1) % n][0] * path[i][1] for i in range(n))) / 2.0 r = 2.0 lost = shoelace(SQUARE10) - shoelace(round_corners(SQUARE10, r)) exact = 4 * r * r - math.pi * r * r assert exact < lost < exact * 1.05 def test_tangent_points_sit_on_the_original_edges(): """The roundover must start and end on the edges it replaces, not inside.""" rounded = round_corners(SQUARE10, 2.0) on_edge = [p for p in rounded if approx(p[0], 0.0) or approx(p[0], 10.0) or approx(p[1], 0.0) or approx(p[1], 10.0)] assert len(on_edge) == 8 def test_rounding_is_mirror_symmetric(): """ Chirality was a real failure in earlier revisions -- the morphological closing it replaced quietly made symmetric profiles handed. """ rounded = round_corners(SQUARE10, 2.0) xs = sorted(round(p[0], 9) for p in rounded) mirrored = sorted(round(10.0 - p[0], 9) for p in rounded) assert xs == mirrored def test_corner_segment_count_follows_the_half_angle(): """ A 60-degree corner has half-angle 30, so n = max(3, ceil(60/180 * 48)) = 16. An equilateral triangle has three of them. """ tri = [(0.0, 0.0), (10.0, 0.0), (5.0, 10.0 * math.sqrt(3) / 2)] assert len(round_corners(tri, 0.5)) == 3 * 16 def test_a_sharper_corner_gets_more_segments_than_a_blunter_one(): """Segment count rises as the corner closes up, since the arc sweeps more.""" sharp = [(0.0, 0.0), (10.0, 0.0), (5.0, 1.0)] blunt = [(0.0, 0.0), (10.0, 0.0), (5.0, 20.0)] assert len(round_corners(sharp, 0.05)) > len(round_corners(blunt, 0.05)) # ---------------------------------------------------------------------------- # round_corners -- failure behaviour # ---------------------------------------------------------------------------- def test_roundover_too_large_raises_rather_than_clamping(): """ BOSL2 asserts here. Clamping instead would let a build succeed where the reference aborted, which is a silent divergence from the oracle. """ with pytest.raises(RoundoverTooLarge): round_corners(SQUARE10, 20.0) def test_the_error_reports_the_scale_factors(): with pytest.raises(RoundoverTooLarge) as exc: round_corners(SQUARE10, 20.0) assert "multiply them by this vector" in str(exc.value) def test_the_exact_fit_boundary_falls_just_short_in_floating_point(): """ r = 5 on a 10 mm square is the exact-fit case on paper: each edge carries two setbacks of 5/tan(45). But tan(45) is 0.9999999999999999, not 1, so the setbacks total fractionally over the edge and the scale factor lands at 0.9999999999999998. BOSL2 asserts here, and so must this port -- OpenSCAD converts degrees to radians the same way and gets the same last bit. Asserting that the exact-fit case *passes* would look reasonable and would quietly diverge from the reference. A hair under fits. """ with pytest.raises(RoundoverTooLarge): round_corners(SQUARE10, 5.0) round_corners(SQUARE10, 4.999999999) def test_repeated_point_with_nonzero_rounding_is_rejected(): with pytest.raises(ValueError, match="Repeated point"): round_corners([(0.0, 0.0), (0.0, 0.0), (10.0, 0.0), (5.0, 5.0)], 1.0) def test_path_too_short_is_rejected(): with pytest.raises(ValueError, match="Length must be 3 or more"): round_corners([(0.0, 0.0), (1.0, 1.0)], 1.0) def test_radius_list_length_must_match(): with pytest.raises(ValueError, match="does not match path length"): round_corners(SQUARE10, [1.0, 1.0]) def test_a_very_blunt_corner_still_gets_the_three_point_floor(): """ Half-angle 85 gives ceil(5/180 * 48) = 2, below BOSL2's max(3, ...) floor. Without the floor an arc would degenerate to a single chord, which reads as a slightly wrong area rather than as an error -- so it needs pinning. """ a = math.radians(10.0) path = [(-10.0, 0.0), (0.0, 0.0), (10.0 * math.cos(a), 10.0 * math.sin(a)), (0.0, -20.0)] rounded = round_corners(path, [0.0, 0.05, 0.0, 0.0]) assert len(rounded) == 3 + 3