Files
solidcraft/tools/kernel-ladder/ladder_lib.py
T
jyvikoandClaude Opus 5.5 1ad9660452 Ladder 06: pipe threads, the buttress's steep flank, the tip options and the square refusal
G 1/2 external and Rp 1/2 internal (ISO 228-1 / ISO 7-1, 55 deg) by Pappus turn by turn; R 1/2
and 1/2-14 NPT external and Rc 1/2 internal tapers (1 in 16 from the standard's diameter at the
free end, crests cut by the major or minor cone) by the screw sweep's Jacobian, integrated exactly
(ladder_lib.sweep_removed); an S20x4 run down to a shoulder with its steep flank toward the end and
toward the start, which moves where the groove stops; tip: start, and a Tr20x4 chamfered at its own
30 deg and 2.5 mm; refusals for a Tr chamfer without a depth and for the square form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:08:31 -04:00

244 lines
9.4 KiB
Python

"""Helpers for the kernel ladder's cases (tests/kernel-cases/ladder): command builders, plain
Python 3 quadrature (standard library only) and the case writers. See cases.py."""
import json
import math
import os
PI = math.pi
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tests", "kernel-cases", "ladder")
CASES = [] # (tier, name)
TIERS = {}
def C(cmd, **p):
return {"command": cmd, "params": p}
def box(corner, l, w, h, **kw):
return C("solid.box", corner=corner, length=l, width=w, height=h, **kw)
def cyl(base, r, h, **kw):
return C("solid.cylinder", base=base, radius=r, height=h, **kw)
def sketch(plane, *items, name=None):
p = {"plane": plane}
if name:
p["name"] = name
return [C("sketch.create", **p), *items, C("sketch.finish")]
def line(points, ids=None, closed=False):
p = {"points": points}
if ids:
p["ids"] = ids
if closed:
p["closed"] = True
return C("sketch.line", **p)
def plane(origin, x, y):
return {"origin": origin, "x_dir": x, "y_dir": y}
def vplane(origin):
"""A vertical sketch plane: sketch x = world x, sketch y = world z (normal -Y)."""
return plane(origin, [1, 0, 0], [0, 0, 1])
def flat(*cmds):
out = []
for c in cmds:
out.extend(c if isinstance(c, list) else [c])
return out
def leggauss(n):
"""Gauss-Legendre nodes and weights on [-1, 1] (Newton on the Legendre polynomial)."""
xs, ws = [], []
for i in range(1, n + 1):
x = math.cos(math.pi * (i - 0.25) / (n + 0.5))
for _ in range(100):
p0, p1 = 1.0, x
for k in range(2, n + 1):
p0, p1 = p1, ((2 * k - 1) * x * p1 - (k - 1) * p0) / k
dp = n * (x * p1 - p0) / (x * x - 1)
dx = p1 / dp
x -= dx
if abs(dx) < 1e-16:
break
xs.append(x)
ws.append(2 / ((1 - x * x) * dp * dp))
return xs, ws
_GL = {}
def quad(f, a, b, n=64):
if n not in _GL:
_GL[n] = leggauss(n)
x, w = _GL[n]
return sum(wi * f(a + (b - a) * (xi + 1) / 2) for xi, wi in zip(x, w)) * (b - a) / 2
def quad_split(f, a, b, pieces=200, n=32):
h = (b - a) / pieces
return sum(quad(f, a + i * h, a + (i + 1) * h, n) for i in range(pieces))
def bez(P, t):
c = ((1 - t) ** 3, 3 * t * (1 - t) ** 2, 3 * t * t * (1 - t), t ** 3)
return tuple(sum(ci * p[k] for ci, p in zip(c, P)) for k in range(2))
def dbez(P, t):
c = (3 * (1 - t) ** 2, 6 * t * (1 - t), 3 * t * t)
d = [tuple(P[i + 1][k] - P[i][k] for k in range(2)) for i in range(3)]
return tuple(sum(ci * q[k] for ci, q in zip(c, d)) for k in range(2))
def norm(v):
return math.hypot(*v)
def bez_len(P):
return quad_split(lambda t: norm(dbez(P, t)), 0, 1, 50)
def bez_min_radius(P):
best = 1e18
for i in range(2001):
t = i / 2000
d = dbez(P, t)
dd = tuple(6 * (1 - t) * (P[2][k] - 2 * P[1][k] + P[0][k]) + 6 * t * (P[3][k] - 2 * P[2][k] + P[1][k]) for k in range(2))
kap = abs(d[0] * dd[1] - d[1] * dd[0]) / norm(d) ** 3
if kap > 0:
best = min(best, 1 / kap)
return best
def bez_area_under(P, base):
"""Area between a Bezier arch (ends on y = base) and the line y = base: -int (y-base) dx."""
return abs(quad(lambda t: (bez(P, t)[1] - base) * dbez(P, t)[0], 0, 1))
SPANDREL = 1 - PI / 4 # area factor of a fillet's removed section, times r^2
XBAR = (10 - 3 * PI) / (12 - 3 * PI) # spandrel centroid from the corner, times r
def case(tier, name, what, commands, expected, method, source="analytic", base=None, sources=None):
d = os.path.join(ROOT, tier, name)
os.makedirs(d, exist_ok=True)
recipe = {}
if base:
recipe["base"] = base
recipe["commands"] = commands
exp = {"sources": sources or [], "what": what, "source": source, "method": method}
for k, v in expected.items():
if isinstance(v, float):
v = round(v, 9)
exp[k] = v
with open(os.path.join(d, "recipe.json"), "w") as f:
json.dump(recipe, f, indent=1)
f.write("\n")
with open(os.path.join(d, "expected.json"), "w") as f:
json.dump(exp, f, indent=1)
f.write("\n")
CASES.append((tier, name))
def missing(tier, name, what, why, method):
d = os.path.join(ROOT, tier, name)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, "recipe.json"), "w") as f:
json.dump({"commands": []}, f, indent=1)
f.write("\n")
with open(os.path.join(d, "expected.json"), "w") as f:
json.dump({"sources": [], "what": what, "source": "invariants", "method": method, "missing": why}, f, indent=1)
f.write("\n")
CASES.append((tier, name))
def iso_material_fraction_ext(d, P):
"""ISO 68-1 basic external profile: material fraction m(r) per pitch, r in [r_root, d/2]."""
H = math.sqrt(3) / 2 * P
r1 = d / 2 - 5 * H / 8
t = math.tan(PI / 6)
return r1, (lambda r: 1.0 if r <= r1 else 1 - (P / 4 + 2 * (r - r1) * t) / P)
def iso_groove_fraction_int(d, P):
"""ISO 68-1 basic internal profile: fraction of a pitch the groove takes at radius r <= d/2."""
t = math.tan(PI / 6)
return lambda r: (P / 8 + 2 * (d / 2 - r) * t) / P
def cyclic_spline(P):
"""The periodic C2 cubic through the points P (2D, not repeated at the end), chord-length knots
(crates/sketch/src/periodic.rs): per span (h, f) with f(s) -> ((x, x'), (y, y')), s in [0, h]."""
n = len(P)
h = [math.dist(P[i], P[(i + 1) % n]) for i in range(n)]
def moments(y):
# h[i-1] M[i-1] + 2 (h[i-1] + h[i]) M[i] + h[i] M[i+1] = 6 (slope after - slope before)
A = [[0.0] * n for _ in range(n)]
b = [0.0] * n
for i in range(n):
ip, im = (i + 1) % n, (i - 1) % n
A[i][im] += h[im]
A[i][i] += 2 * (h[im] + h[i])
A[i][ip] += h[i]
b[i] = 6 * ((y[ip] - y[i]) / h[i] - (y[i] - y[im]) / h[im])
for c in range(n):
for r in range(n):
if r != c:
f = A[r][c] / A[c][c]
A[r] = [a - f * q for a, q in zip(A[r], A[c])]
b[r] -= f * b[c]
return [b[i] / A[i][i] for i in range(n)]
M = [moments([p[k] for p in P]) for k in range(2)]
def span(i):
j = (i + 1) % n
def f(s):
a, c = (h[i] - s) / h[i], s / h[i]
return tuple((a * P[i][k] + c * P[j][k] + ((a ** 3 - a) * M[k][i] + (c ** 3 - c) * M[k][j]) * h[i] ** 2 / 6,
(P[j][k] - P[i][k]) / h[i] + (-(3 * a * a - 1) * M[k][i] + (3 * c * c - 1) * M[k][j]) * h[i] / 6) for k in range(2))
return h[i], f
return [span(i) for i in range(n)]
def clip_half(poly, a, b, c):
"""The part of a convex polygon [(x, y)] where a x + b y + c <= 0 (Sutherland-Hodgman)."""
out = []
n = len(poly)
for i in range(n):
p, q = poly[i], poly[(i + 1) % n]
fp, fq = a * p[0] + b * p[1] + c, a * q[0] + b * q[1] + c
if fp <= 0:
out.append(p)
if (fp < 0 < fq) or (fq < 0 < fp):
s = fp / (fp - fq)
out.append((p[0] + s * (q[0] - p[0]), p[1] + s * (q[1] - p[1])))
return out
def moment_r(poly):
"""int int r dA over a polygon in the (r, z) half-plane: what a ring of that section holds, over 2 pi."""
m = 0.0
for i in range(len(poly)):
(x0, y0), (x1, y1) = poly[i], poly[(i + 1) % len(poly)]
m += (x0 + x1) * (x0 * y1 - x1 * y0)
return abs(m) / 6
def sweep_removed(section, z0, lead, turns, rate, regions, panels_per_turn=400):
"""What a screw sweep removes from an axisymmetric solid: `section` [(r, dz)] (convex, about the
axial position z0), carried `turns` turns rising `lead` a turn and moving `rate` mm a turn off
the axis (a taper); the solid is the union of disjoint convex `regions`, each a list of
half-planes (a, b, c): a r + b z + c <= 0. A point (rho, zeta) of the section at turn t is at
r = rho + rate t, z = z0 + zeta + lead t, so the volume element is 2 pi r dA dt (the map's
Jacobian), and V = 2 pi int_0^turns (int int_{section_t within the solid} r dA) dt, exact while
successive turns don't overlap (the groove is narrower than the lead). Gauss-Legendre on
panels: the integrand is piecewise polynomial in t."""
def at(t):
placed = [(r + rate * t, z0 + dz + lead * t) for r, dz in section]
total = 0.0
for hp in regions:
p = placed
for a, b, c in hp:
p = clip_half(p, a, b, c)
if len(p) < 3:
break
if len(p) >= 3:
total += moment_r(p)
return total
pieces = max(1, int(math.ceil(turns * panels_per_turn)))
return 2 * PI * quad_split(at, 0.0, turns, pieces, 6)
def groove_section(P, flat, crest, flank_lo, flank_hi, r_root, r_far, internal=False):
"""A thread groove's axial section [(r, dz)] between r_root (its root) and r_far (past the crest,
out of the material), its root flat centred on dz = 0: the tooth is `flat` wide at the crest
radius and both walls are straight flanks (flank_lo toward -z, flank_hi toward +z, degrees
from the radial)."""
tl, th = math.tan(math.radians(flank_lo)), math.tan(math.radians(flank_hi))
w_root = P - flat - abs(crest - r_root) * (tl + th)
def ext(r):
d = abs(r - r_root)
return w_root / 2 + d * tl, w_root / 2 + d * th
(l0, h0), (l1, h1) = ext(r_root), ext(r_far)
return [(r_root, -l0), (r_far, -l1), (r_far, h1), (r_root, h0)]