Shapely has no corner rounding, so the round_corners -> _circlecorner -> arc -> segs chain is transcribed from BOSL2 at the pinned commit 92d697c2, read from source rather than recalled. Also deduplicate, path_merge_collinear, is_collinear and approx, which the cleanup path depends on. Segment counts are contract, not a quality setting. An arc becomes straight segments and the count sets the enclosed area, compared against the oracle at 1e-3 mm2 -- and the extruded solid is those segments, so this is the definition of the surface. Both generators set $fn = facets with facets = 48 and no oracle case overrides it, so segmentation depends on swept angle alone. A right angle gives 12 points. round_corners raises where BOSL2 asserts, rather than clamping: silently fitting a roundover the reference refused would diverge without any visible failure. sb_corner_radii exists to derive safe radii up front. 33 tests. The exact-fit boundary raises rather than passing, because tan(45) is under 1 in both languages -- a test asserting the tidy behaviour would have looked right and been wrong. Mutation run found a real gap: nothing exercised the three-point floor on blunt corners until a 170-degree case was added. Seven mutations now caught. Oracle acceptance still skips; 236 unchanged.
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""
|
|
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
|