Files
TheRON 545eee7217 geom: port BOSL2 round_corners and path cleanup
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.
2026-08-19 03:18:52 -05:00

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