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.
This commit is contained in:
2026-08-19 03:18:52 -05:00
parent dfd02a4fd8
commit 545eee7217
3 changed files with 624 additions and 0 deletions
+13
View File
@@ -47,3 +47,16 @@ from .records import ( # noqa: F401
strap_layer_paths,
strap_path,
)
from .rounding import ( # noqa: F401
DEFAULT_FN,
EPSILON,
RoundoverTooLarge,
approx,
approx_pt,
arc,
deduplicate,
is_collinear,
path_merge_collinear,
round_corners,
segs,
)
+311
View File
@@ -0,0 +1,311 @@
"""
The BOSL2 operations that ``sb-geom`` and ``sb-join`` call but Shapely has no
equivalent for: corner rounding, and the path cleanup that follows it.
Ported from BOSL2 at commit ``92d697c2856de2fed93a33e858068589cefc2898``, which
is the commit the frozen oracle records. Read from source rather than from
recollection; the call chain is
``round_corners -> _circlecorner -> arc -> segs``.
**Why this is transcribed rather than reimplemented.** A circular arc becomes a
finite number of straight segments, and the segment count determines the
enclosed area. ``SECTION_AREA_MM2`` is compared against the oracle at 1e-3 mm2,
and the extruded solid is these segments -- so the discretisation is not a
quality setting that a smoother modern approach could improve on. It is the
definition of the surface. Any adaptive subdivision or tolerance-based
flattening would produce a better curve and a failed build.
The generators set ``$fn = facets`` with ``facets = 48``, and all 123 oracle
cases run at that default. With ``$fn`` positive, ``segs()`` ignores the radius
entirely, so segment counts depend only on swept angle. That is reproduced here
via the module-level default; it is a parameter rather than a constant because
the generators expose it as one.
``round_corners`` **raises** when the requested roundovers do not fit the path,
matching BOSL2, which asserts rather than clamping. Callers are expected to
derive safe radii up front -- that is exactly what ``sb_corner_radii`` is for.
Silently clamping here would let a build succeed where the reference aborted.
"""
from __future__ import annotations
import math
from typing import List, Optional, Sequence, Tuple
Point = Tuple[float, float]
Path = Sequence[Point]
# BOSL2's private library epsilon (math.scad).
EPSILON = 1e-9
# OpenSCAD facet count set by the generators. Overridable, but every frozen
# oracle case was produced at this value.
DEFAULT_FN = 48
class RoundoverTooLarge(ValueError):
"""
Raised where BOSL2 asserts "Roundovers are too big for the path."
A distinct type because this is a caller error -- radii that were never
derived against the path -- and not a rejected profile.
"""
# ----------------------------------------------------------------------------
# Comparison helpers (comparisons.scad)
# ----------------------------------------------------------------------------
def approx(a: float, b: float, eps: float = EPSILON) -> bool:
return abs(a - b) <= eps
def approx_pt(a: Point, b: Point, eps: float = EPSILON) -> bool:
"""Componentwise, as BOSL2's list branch of ``approx``."""
return abs(a[0] - b[0]) <= eps and abs(a[1] - b[1]) <= eps
def deduplicate(path: Path, closed: bool = False,
eps: float = EPSILON) -> List[Point]:
"""
Drop each point that equals the next one.
Closed paths compare the last point against the first, which is how a
roundover that fully consumes a segment gets collapsed.
"""
pts = list(path)
n = len(pts)
if n == 0:
return []
end = n if closed else n - 1
return [pts[i] for i in range(n)
if i == end or not approx_pt(pts[i], pts[(i + 1) % n], eps)]
def _dist2line(d: Point, n: Point) -> float:
dot = d[0] * n[0] + d[1] * n[1]
return math.hypot(d[0] - dot * n[0], d[1] - dot * n[1])
def is_collinear(pts: Sequence[Point], eps: float = EPSILON) -> bool:
"""
BOSL2's ``is_collinear`` via ``_noncollinear_triple``.
The tolerance is *relative*: the furthest point from the first defines the
chord, and collinearity holds when every point lies within ``eps`` times
that chord length of the line. An absolute tolerance would behave
differently at the scales this library works at.
"""
if len(pts) < 3:
return True
pa = pts[0]
b = max(range(len(pts)), key=lambda i: math.hypot(pts[i][0] - pa[0],
pts[i][1] - pa[1]))
pb = pts[b]
nrm = math.hypot(pb[0] - pa[0], pb[1] - pa[1])
if nrm <= eps:
return True
n = ((pb[0] - pa[0]) / nrm, (pb[1] - pa[1]) / nrm)
distlist = [_dist2line((p[0] - pa[0], p[1] - pa[1]), n) for p in pts]
return max(distlist) < eps * nrm
def path_merge_collinear(path: Path, closed: bool = True,
eps: float = EPSILON) -> List[Point]:
"""
Remove vertices that lie on the line between their neighbours.
Exact butt joints and zero-radius fillets leave collinear vertices. They are
harmless in 2D but leave zero-area triangles the tessellator cannot resolve,
so a section that measures perfectly can still fail to extrude.
"""
pts = deduplicate(path, closed=closed, eps=eps)
n = len(pts)
if n <= 2:
return pts
out: List[Point] = []
if not closed:
out.append(pts[0])
rng = range(1, n - 1)
else:
rng = range(n)
for i in rng:
triple = (pts[(i - 1) % n], pts[i], pts[(i + 1) % n])
if not is_collinear(triple, eps=eps):
out.append(triple[1])
if not closed:
out.append(pts[-1])
return out
# ----------------------------------------------------------------------------
# Segmentation (utility.scad: segs)
# ----------------------------------------------------------------------------
def segs(r: float, angle: Optional[float] = None,
fn: int = DEFAULT_FN, fa: float = 12.0, fs: float = 2.0) -> int:
"""
Number of sides OpenSCAD gives a circle, or an arc of ``angle`` degrees.
The ``2e-15`` subtraction is BOSL2's, guarding an angle that is fractionally
over its true value through rounding. It is reproduced because dropping it
can add a segment at exactly 360-divisible angles.
"""
if angle is not None:
return math.ceil(segs(r, None, fn, fa, fs) * abs(angle) / 360.0 - 2e-15)
if fn > 0:
return fn if fn > 3 else 3
rr = r if math.isfinite(r) else 0.0
return math.ceil(max(5.0, min(360.0 / fa, abs(rr) * 2.0 * math.pi / fs)))
# ----------------------------------------------------------------------------
# Arc through two points about a centre (drawing.scad: arc)
# ----------------------------------------------------------------------------
def _vector_angle3(a: Point, b: Point, c: Point) -> float:
"""Angle at ``b``, degrees, in [0, 180]."""
ux, uy = a[0] - b[0], a[1] - b[1]
vx, vy = c[0] - b[0], c[1] - b[1]
nu, nv = math.hypot(ux, uy), math.hypot(vx, vy)
if nu == 0.0 or nv == 0.0:
return 0.0
cosv = (ux * vx + uy * vy) / (nu * nv)
return math.degrees(math.acos(max(-1.0, min(1.0, cosv))))
def arc(n: int, cp: Point, points: Tuple[Point, Point]) -> List[Point]:
"""
``n`` points along the arc from ``points[0]`` to ``points[1]`` about ``cp``.
Sweep direction follows the sign of the 2D cross product, taking the short
way round -- BOSL2's ``long``/``cw``/``ccw`` flags are never passed by the
call sites this port needs, so the minor arc is always the one drawn.
"""
start, end = points
angle = _vector_angle3(start, cp, end)
v1 = (start[0] - cp[0], start[1] - cp[1])
v2 = (end[0] - cp[0], end[1] - cp[1])
prelim = v1[0] * v2[1] - v1[1] * v2[0]
direction = 1.0 if prelim > 0 else (-1.0 if prelim < 0 else 1.0)
r = math.hypot(v1[0], v1[1])
final_angle = direction * angle
sa = math.degrees(math.atan2(v1[1], v1[0]))
out: List[Point] = []
for i in range(n):
theta = sa + i * final_angle / (n - 1)
out.append((r * math.cos(math.radians(theta)) + cp[0],
r * math.sin(math.radians(theta)) + cp[1]))
return out
def _circlecorner(points: Tuple[Point, Point, Point],
parm: Tuple[float, float], fn: int = DEFAULT_FN) -> List[Point]:
"""
One rounded corner: ``parm`` is ``(d, r)``, the tangent setback and radius.
A straight vertex -- half-angle 90 degrees -- degenerates to the two tangent
points with no arc between them.
"""
prev_p, here, next_p = points
angle = _vector_angle3(prev_p, here, next_p) / 2.0
d, r = parm
pux, puy = prev_p[0] - here[0], prev_p[1] - here[1]
nux, nuy = next_p[0] - here[0], next_p[1] - here[1]
pn = math.hypot(pux, puy)
nn = math.hypot(nux, nuy)
prev_u = (pux / pn, puy / pn)
next_u = (nux / nn, nuy / nn)
start = (here[0] + prev_u[0] * d, here[1] + prev_u[1] * d)
end = (here[0] + next_u[0] * d, here[1] + next_u[1] * d)
if approx(angle, 90.0):
return [start, end]
bx, by = prev_u[0] + next_u[0], prev_u[1] + next_u[1]
bn = math.hypot(bx, by)
scale = r / math.sin(math.radians(angle))
center = (scale * bx / bn + here[0], scale * by / bn + here[1])
n = max(3, math.ceil((90.0 - angle) / 180.0 * segs(r, None, fn)))
return arc(n, center, (start, end))
# ----------------------------------------------------------------------------
# round_corners (rounding.scad), method="circle", measure="radius"
# ----------------------------------------------------------------------------
def round_corners(path: Path, radius, closed: bool = True,
fn: int = DEFAULT_FN) -> List[Point]:
"""
Round each corner of ``path`` to its own radius.
``radius`` is a scalar or one value per vertex. Zero leaves a vertex
untouched, which is how ``sb_fillet_concave`` rounds only reflex corners
while keeping every convex corner bit-exact.
Raises ``RoundoverTooLarge`` when the setbacks overrun an edge, matching
BOSL2's assertion. The message carries the same scale factors BOSL2 reports,
since those say directly how much too large the request was.
"""
pts = list(path)
n = len(pts)
if n < 3:
raise ValueError("Path has length %d. Length must be 3 or more." % n)
parm = [float(radius)] * n if isinstance(radius, (int, float)) \
else [float(x) for x in radius]
if len(parm) != n:
raise ValueError("radius list length %d does not match path length %d"
% (len(parm), n))
dk: List[Tuple[float, ...]] = []
for i in range(n):
bit = (pts[(i - 1) % n], pts[i], pts[(i + 1) % n])
degenerate = approx_pt(bit[0], bit[1]) or approx_pt(bit[1], bit[2])
angle = None if degenerate else _vector_angle3(*bit) / 2.0
if not closed and (i == 0 or i == n - 1):
dk.append((0.0,))
continue
if parm[i] == 0:
dk.append((0.0,))
continue
if angle is None:
raise ValueError("Repeated point in path at index %d with nonzero "
"rounding" % i)
if approx(angle, 0.0):
raise ValueError("Path turns back on itself at index %d with "
"nonzero rounding" % i)
dk.append((parm[i] / math.tan(math.radians(angle)), parm[i]))
lengths = [math.hypot(pts[i % n][0] - pts[(i - 1) % n][0],
pts[i % n][1] - pts[(i - 1) % n][1])
for i in range(n + 1)]
scalefactors: List[float] = []
for i in range(n):
if not (closed or (i != 0 and i != n - 1)):
continue
back = dk[(i - 1) % n][0] + dk[i][0]
fwd = dk[i][0] + dk[(i + 1) % n][0]
scalefactors.append(min(
math.inf if back == 0 else lengths[i] / back,
math.inf if fwd == 0 else lengths[i + 1] / fwd,
))
if scalefactors and min(scalefactors) < 1.0:
raise RoundoverTooLarge(
"Roundovers are too big for the path. If you multiply them by this "
"vector they should fit: %r" % (scalefactors,))
out: List[Point] = []
for i in range(n):
if dk[i][0] == 0:
out.append(pts[i])
else:
bit = (pts[(i - 1) % n], pts[i], pts[(i + 1) % n])
out.extend(_circlecorner(bit, (dk[i][0], dk[i][1]), fn))
return deduplicate(out, closed=False)
+300
View File
@@ -0,0 +1,300 @@
"""
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