test: reference cases for variable, chord-length and tapered fillets (#776)

Four analytic reference cases for variable, to-zero, chord-length and taper-to-fit fillets, with the formulas in tools/oracle-analytic.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 12:35:16 -04:00
1 parent 7b0a1b296c
commit 8cdefd4cd5
13 files changed
+1010 -7

No files matched your search

+4 -3
View File
@@ -2,15 +2,16 @@
Closed-form expected values for the cases in [`tests/oracle/`](../../tests/oracle/) whose shape
has one: prisms, cylinders, cones and frustums, spheres and caps, tori and solids of revolution
(Pappus), constant-radius fillets on straight edges, shells, patterns of these, and folded sheet
metal (flats plus bends as annular sectors). Each script is plain Python 3 (standard library only)
with one function per case. Running a script from the repository root rewrites the
(Pappus), fillets on straight edges (a constant radius, or one varying along the edge), shells,
patterns of these, and folded sheet metal (flats plus bends as annular sectors). Each script is
plain Python 3 (standard library only) with one function per case. Running a script from the repository root rewrites the
`expected.json` of its cases with `"source": "analytic"` and a `method` string giving the formulas:
```sh
python3 tools/oracle-analytic/cases_basic.py # 01-29: boxes, holes, revolves, sketches
python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sweeps, patterns
python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal
python3 tools/oracle-analytic/cases_fillets.py # 77-80: variable, chord-length and tapered fillets
```
Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Closed-form expected values for the fillets whose radius varies along a straight edge
(cases 77-80 of tests/oracle: variable, closing at a corner, chord length, taper to fit).
Standalone, Python 3 stdlib only (it reuses the helpers of cases_features.py). Run from the
worktree root:
python3 tools/oracle-analytic/cases_fillets.py
Each case rounds one straight edge where two faces meet at a right angle: the edge along x at
y = 0, z = H, between the front face (y = 0) and the top face (z = H). Every section square to
the edge is a quarter-circle round of radius r(x), so with the spandrel of radius r (the r x r
corner minus the quarter disc, area (1 - pi/4) r^2, centroid r (10 - 3 pi) / (3 (4 - pi)) from
both faces):
V removed = int (1 - pi/4) r^2 dx
moments = int (x, off(r), H - off(r)) (1 - pi/4) r^2 dx
area = - 2 int r dx (a strip r wide off each face)
- (1 - pi/4) (r(0)^2 + r(L)^2) (the spandrel off each end face)
+ the round's own area, int int |P_x x P_t| dt dx over
P(x, t) = (x, r (1 - cos t), H - r (1 - sin t)), t in [0, pi/2]
r is a polynomial on each piece of the edge, so the volume and moments are integrated exactly
by Gauss-Legendre per piece; the round's area (a square root) to round-off with 24 x 24 points.
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cases_features import GL_W, GL_X, MP, PI, box, extrude, extrude_area, poly, region, spandrel_area, spandrel_offset, write # noqa: E402
K = 1 - PI / 4
def removed(radius, slope, pieces, H):
"""What rounding the edge (x from 0 to L, corner at y = 0, z = H) with radius(x) (and its
slope(x)) takes away: its mass properties and the change of area. `pieces` are the x intervals
on which r is one polynomial."""
v = mx = my = mz = strips = blend = 0.0
for a, b in pieces:
h = (b - a) / 2
for xg, wg in zip(GL_X, GL_W):
x = (a + b) / 2 + h * xg
w = wg * h
r, s = radius(x), slope(x)
sec = spandrel_area(r)
v += w * sec
mx += w * x * sec
my += w * spandrel_offset(r) * sec
mz += w * (H - spandrel_offset(r)) * sec
strips += w * 2 * r
# The round: |P_x x P_t| with P_x = (1, s (1 - cos t), -s (1 - sin t)),
# P_t = (0, r sin t, r cos t).
for tg, tw in zip(GL_X, GL_W):
t = PI / 4 * (1 + tg)
px = (1.0, s * (1 - math.cos(t)), -s * (1 - math.sin(t)))
pt = (0.0, r * math.sin(t), r * math.cos(t))
c = (px[1] * pt[2] - px[2] * pt[1], px[2] * pt[0] - px[0] * pt[2], px[0] * pt[1] - px[1] * pt[0])
blend += w * tw * PI / 4 * math.sqrt(sum(q * q for q in c))
ends = spandrel_area(radius(pieces[0][0])) + spandrel_area(radius(pieces[-1][1]))
return MP(v, [mx, my, mz]), blend - strips - ends
def linear(a, b, length):
"""r running straight from a (x = 0) to b (x = length)."""
return (lambda x: a + (b - a) * x / length), (lambda x: (b - a) / length)
def c77():
r, dr = linear(2.0, 5.0, 40.0)
cut, da = removed(r, dr, [(0.0, 40.0)], 20.0)
write("77-fillet-variable-linear",
"box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius running straight from 2 (x=0) to 5 (x=40): "
"sections are quarter rounds, V = abc - (1 - pi/4) L (a^2 + ab + b^2) / 3; moments and the round's area "
"integrated along the edge (tools/oracle-analytic/cases_fillets.py)",
1, box(0, 0, 0, 40, 30, 20) - cut, 5200 + da, [(0, 0, 0), (40, 30, 20)], faces=7)
def c78():
r, dr = linear(4.0, 0.0, 40.0)
cut, da = removed(r, dr, [(0.0, 40.0)], 20.0)
write("78-fillet-variable-to-zero",
"as 77 with the radius running from 4 (x=0) down to 0 (x=40), where the round closes to the corner: "
"V = abc - (1 - pi/4) L a^2 / 3; the round is three-sided and the corner at x=40 stays",
1, box(0, 0, 0, 40, 30, 20) - cut, 5200 + da, [(0, 0, 0), (40, 30, 20)], faces=7)
def c79():
rc = 4.0 / (2 * math.cos(PI / 4))
cut, da = removed(lambda x: rc, lambda x: 0.0, [(0.0, 40.0)], 20.0)
write("79-fillet-chord-length",
"box 40x30x20, top front edge rounded 4 wide across (chord c): r = c / (2 cos 45 deg) = 2 sqrt 2; "
"V = abc - (1 - pi/4) r^2 L, A = 2(ab + bc + ca) - 2 r L + (pi/2) r L - 2 (1 - pi/4) r^2",
1, box(0, 0, 0, 40, 30, 20) - cut, 5200 + da, [(0, 0, 0), (40, 30, 20)], faces=7)
# The taper law (crates/kernel/src/blend_variable.rs `taper`): a clamped cubic B-spline in
# u = x / L on 8 equal spans, its control radii min(R, 0.9 room) at the Greville abscissae.
SPANS, SHARE = 8, 0.9
def taper(radius, room, length):
knots = [0.0] * 4 + [k / SPANS for k in range(1, SPANS)] + [1.0] * 4
n = SPANS + 3
greville = [sum(knots[j + 1:j + 4]) / 3 for j in range(n)]
ctrl = [min(radius, SHARE * room(u)) for u in greville]
def at(u, deriv):
# de Boor on the span holding u; the derivative from the degree-2 differences.
k = min(max(i for i in range(3, n) if knots[i] <= u), n - 1)
if deriv:
q = [3 * (ctrl[i] - ctrl[i - 1]) / (knots[i + 3] - knots[i]) for i in range(k - 2, k + 1)]
d = q[:]
for rr in range(1, 3):
for j in range(2, rr - 1, -1):
i = j + k - 2
al = (u - knots[i]) / (knots[i + 3 - rr] - knots[i])
d[j] = (1 - al) * d[j - 1] + al * d[j]
return d[2] / length
d = [ctrl[j + k - 3] for j in range(4)]
for rr in range(1, 4):
for j in range(3, rr - 1, -1):
i = j + k - 3
al = (u - knots[i]) / (knots[i + 4 - rr] - knots[i])
d[j] = (1 - al) * d[j - 1] + al * d[j]
return d[3]
pieces = [(length * s / SPANS, length * (s + 1) / SPANS) for s in range(SPANS)]
return (lambda x: at(x / length, False)), (lambda x: at(x / length, True)), pieces, ctrl
def c80():
# Plate: top face narrowing from 20 (x=0) to 2 (x=40), 15 high. The room for the round at u:
# the top face's width and the front face's height, times tan 45 deg.
r, dr, pieces, ctrl = taper(3.0, lambda u: min(20 - 18 * u, 15.0), 40.0)
reg = region([poly([(0, 0), (40, 0), (40, 2), (0, 20)])])
cut, da = removed(r, dr, pieces, 15.0)
write("80-fillet-taper-to-fit",
"plate (top 20 -> 2 wide over x 0..40, 15 high) with its top front edge filleted at 3, fit: taper. The radius "
"is the taper law: a clamped cubic B-spline in u = x/40 on 8 equal spans, control radii min(3, 0.9 room) at the "
"Greville abscissae, room = min(20 - 18u, 15) tan 45 deg (top face width, front face height): "
+ ", ".join(f"{c:.4f}" for c in ctrl) + ". Sections are quarter rounds; V, moments and areas integrated along the edge",
1, extrude(reg, 0, 15) - cut, extrude_area(reg, 15) + da, [(0, 0, 0), (40, 20, 15)], faces=7)
CASES = [c77, c78, c79, c80]
if __name__ == "__main__":
for fn in CASES:
fn()