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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user