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)