Public reference tests: analytic and FreeCAD expected values (#310)
The oracle no longer uses Fusion measurements. tests/oracle/<case>/ now holds the 76 recipes (our own format, with recorded measurements and tool-specific fields removed) and an expected.json per case: - 55 analytic cases: closed-form values from tools/oracle-analytic/ (stdlib Python, formulas in each case's "method"). - 21 FreeCAD cases: the same recipe built with FreeCAD's public scripting API by tools/freecad-oracle/ (an external program, not a dependency), with a reference.stl for 20. solvecraft-cli oracle compares body count, volume, area, centre of mass, bbox, face counts where the geometry fixes them, and, against reference.stl, the sampled Hausdorff distance and the principal moments (spatial.rs from #301). cargo xtask oracle reads tests/oracle, writes docs/oracle.md with the source of each value, and supports known_failure marks for tracked bugs. CI runs it on every PR; a manual workflow regenerates the FreeCAD values. step-corpus, seam normalisation and the through-all emulation, which only existed to compare with Fusion, are removed. 75/76 pass; 75-sm-bracket-holes is a known failure (#296). Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT Co-authored-by: bherbruck <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
204 files changed
+27106
-932
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Analytic reference values
|
||||
|
||||
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
|
||||
`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
|
||||
```
|
||||
|
||||
Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp
|
||||
inner corner, a sheet-metal corner relief, where a flange height is measured), `method` or
|
||||
`note` says which convention is used. Never change a value to match SolidCraft's output: if they
|
||||
disagree, find out which one is wrong.
|
||||
@@ -0,0 +1,560 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Closed-form expected values for the basic oracle cases.
|
||||
|
||||
Run from the worktree root: python3 tools/oracle-analytic/cases_basic.py [case ...]
|
||||
Writes tests/oracle/<case>/expected.json with "source": "analytic".
|
||||
|
||||
Every value is derived from first principles (prisms, cylinders, cones/frustums, spheres and
|
||||
caps, tori via Pappus, Green's theorem over line/arc profiles) and composed by adding or
|
||||
subtracting mass properties. Nothing here is measured from a CAD kernel.
|
||||
Stdlib only.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
PI = math.pi
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tests", "oracle")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
# Mass-property composition
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
class MP:
|
||||
"""Volume and first moments; add pieces with sign +1 (material) or -1 (removed)."""
|
||||
|
||||
def __init__(self):
|
||||
self.v = 0.0
|
||||
self.m = [0.0, 0.0, 0.0]
|
||||
|
||||
def add(self, v, c, sign=1.0):
|
||||
self.v += sign * v
|
||||
for i in range(3):
|
||||
self.m[i] += sign * v * c[i]
|
||||
return self
|
||||
|
||||
def com(self):
|
||||
return [self.m[i] / self.v for i in range(3)]
|
||||
|
||||
|
||||
def box_mp(lo, hi):
|
||||
v = (hi[0] - lo[0]) * (hi[1] - lo[1]) * (hi[2] - lo[2])
|
||||
return v, [(lo[i] + hi[i]) / 2 for i in range(3)]
|
||||
|
||||
|
||||
def box_area(a, b, c):
|
||||
return 2 * (a * b + b * c + c * a)
|
||||
|
||||
|
||||
def frustum_v(h, r1, r2):
|
||||
"""Circular frustum, radii r1 (z=0) and r2 (z=h)."""
|
||||
return PI * h / 3 * (r1 * r1 + r1 * r2 + r2 * r2)
|
||||
|
||||
|
||||
def frustum_zc(h, r1, r2):
|
||||
"""Centroid height of a circular frustum above the r1 end."""
|
||||
return h * (r1 * r1 + 2 * r1 * r2 + 3 * r2 * r2) / (4 * (r1 * r1 + r1 * r2 + r2 * r2))
|
||||
|
||||
|
||||
def frustum_lat(h, r1, r2):
|
||||
return PI * (r1 + r2) * math.hypot(h, r1 - r2)
|
||||
|
||||
|
||||
def sq_frustum(h, a0, a1):
|
||||
"""Square frustum, side a0 at z=0, a1 at z=h: (V, z-centroid)."""
|
||||
A0, A1 = a0 * a0, a1 * a1
|
||||
s = math.sqrt(A0 * A1)
|
||||
v = h / 3 * (A0 + A1 + s)
|
||||
zc = h * (A0 + 2 * s + 3 * A1) / (4 * (A0 + s + A1))
|
||||
return v, zc
|
||||
|
||||
|
||||
def cap_v(R, h):
|
||||
return PI * h * h * (3 * R - h) / 3
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
# 2D region integrals by Green's theorem (lines exact; arcs by 48-point Gauss-Legendre, which
|
||||
# integrates these low-degree trigonometric polynomials to machine precision).
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
def _gauss_legendre(n):
|
||||
xs, ws = [], []
|
||||
for i in range(1, n + 1):
|
||||
x = math.cos(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 = _gauss_legendre(48)
|
||||
|
||||
|
||||
def seg_eval(seg, t):
|
||||
"""Point and derivative on a segment at t in [0, 1]."""
|
||||
if seg[0] == "line":
|
||||
(x0, y0), (x1, y1) = seg[1], seg[2]
|
||||
return x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, x1 - x0, y1 - y0
|
||||
_, (cx, cy), r, a0, a1 = seg
|
||||
a = a0 + (a1 - a0) * t
|
||||
da = a1 - a0
|
||||
return cx + r * math.cos(a), cy + r * math.sin(a), -r * math.sin(a) * da, r * math.cos(a) * da
|
||||
|
||||
|
||||
def seg_int(seg, f):
|
||||
"""Integral over t of f(x, y, dx/dt, dy/dt)."""
|
||||
s = 0.0
|
||||
for x, w in zip(*GL):
|
||||
t = (x + 1) / 2
|
||||
s += w / 2 * f(*seg_eval(seg, t))
|
||||
return s
|
||||
|
||||
|
||||
def region(segs):
|
||||
"""Area A, moments Sx = int x dA, Sy = int y dA, Sxy = int x*y dA of a CCW-bounded region."""
|
||||
A = sum(seg_int(s, lambda x, y, dx, dy: x * dy) for s in segs)
|
||||
Sx = sum(seg_int(s, lambda x, y, dx, dy: x * x / 2 * dy) for s in segs)
|
||||
Sy = sum(seg_int(s, lambda x, y, dx, dy: x * y * dy) for s in segs)
|
||||
Sxy = sum(seg_int(s, lambda x, y, dx, dy: x * x * y / 2 * dy) for s in segs)
|
||||
return A, Sx, Sy, Sxy
|
||||
|
||||
|
||||
def seg_len(seg):
|
||||
return seg_int(seg, lambda x, y, dx, dy: math.hypot(dx, dy))
|
||||
|
||||
|
||||
def seg_rev_area(seg):
|
||||
"""Area swept by a profile segment (x = radius) turned 360 deg about the y axis."""
|
||||
return seg_int(seg, lambda x, y, dx, dy: 2 * PI * x * math.hypot(dx, dy))
|
||||
|
||||
|
||||
def arc3(p0, pm, p1):
|
||||
"""Circle through three points, as an arc segment from p0 through pm to p1."""
|
||||
(ax, ay), (bx, by), (cx, cy) = p0, pm, p1
|
||||
d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
|
||||
ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
|
||||
uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
|
||||
r = math.hypot(ax - ux, ay - uy)
|
||||
t0 = math.atan2(ay - uy, ax - ux)
|
||||
tm = math.atan2(by - uy, bx - ux)
|
||||
t1 = math.atan2(cy - uy, cx - ux)
|
||||
# choose the sweep direction that passes through the mid point
|
||||
ccw = (t1 - t0) % (2 * PI)
|
||||
if (tm - t0) % (2 * PI) < ccw:
|
||||
a1 = t0 + ccw
|
||||
else:
|
||||
a1 = t0 - ((t0 - t1) % (2 * PI))
|
||||
return ("arc", (ux, uy), r, t0, a1)
|
||||
|
||||
|
||||
def circle_seg(c, r):
|
||||
return ("arc", c, r, 0.0, 2 * PI)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
# Output
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
def result(method, bodies, V, A, com, lo, hi, faces=None, note=None, **extra):
|
||||
d = {
|
||||
"source": "analytic",
|
||||
"method": method,
|
||||
"body_count": bodies,
|
||||
"volume_mm3": V,
|
||||
"area_mm2": A,
|
||||
"center_of_mass": [c + 0.0 for c in com],
|
||||
"bbox": {"min": [float(x) + 0.0 for x in lo], "max": [float(x) + 0.0 for x in hi]},
|
||||
}
|
||||
if faces is not None:
|
||||
d["faces"] = faces
|
||||
if note:
|
||||
d["note"] = note
|
||||
d.update(extra)
|
||||
return d
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
# Cases
|
||||
# ----------------------------------------------------------------------------------------------
|
||||
def c01_box():
|
||||
a, b, c = 40.0, 30.0, 20.0
|
||||
return result("box 40 x 30 x 20 from the origin: V = abc, A = 2(ab + bc + ca)", 1, a * b * c, box_area(a, b, c),
|
||||
[a / 2, b / 2, c / 2], [0, 0, 0], [a, b, c], 6)
|
||||
|
||||
|
||||
def c02_cylinder():
|
||||
r, h = 15.0, 50.0
|
||||
return result("cylinder r=15 h=50 on XY: V = pi r^2 h, A = 2 pi r^2 + 2 pi r h", 1, PI * r * r * h,
|
||||
2 * PI * r * r + 2 * PI * r * h, [0, 0, h / 2], [-r, -r, 0], [r, r, h], 3)
|
||||
|
||||
|
||||
def c03_box_through_hole():
|
||||
a, b, c, r = 60.0, 40.0, 20.0, 6.0
|
||||
V = a * b * c - PI * r * r * c
|
||||
A = box_area(a, b, c) - 2 * PI * r * r + 2 * PI * r * c
|
||||
return result("box 60x40x20 minus centred through cylinder r=6: V = abc - pi r^2 c, A = 2(ab+bc+ca) - 2 pi r^2 + 2 pi r c; "
|
||||
"hole is centred so the centroid stays at the box centre", 1, V, A, [30, 20, 10], [0, 0, 0], [a, b, c], 7)
|
||||
|
||||
|
||||
def _vase_profile():
|
||||
# sketch on XZ with y_dir = -Z: world (x, z) = (sx, -sy)
|
||||
w = lambda p: (p[0], -p[1])
|
||||
P = [w(p) for p in [(0, 0), (20, 0), (25, -30)]]
|
||||
arc = arc3(w((25, -30)), w((23, -46)), w((15, -60)))
|
||||
Q = [w(p) for p in [(15, -60), (18, -80), (0, -80)]]
|
||||
segs = [("line", P[0], P[1]), ("line", P[1], P[2]), arc, ("line", Q[0], Q[1]), ("line", Q[1], Q[2]), ("line", Q[2], P[0])]
|
||||
return segs, arc
|
||||
|
||||
|
||||
def c04_revolve_vase():
|
||||
segs, arc = _vase_profile()
|
||||
A2, Sx, Sy, Sxy = region(segs)
|
||||
V = 2 * PI * Sx
|
||||
zc = Sxy / Sx
|
||||
area = sum(seg_rev_area(s) for s in segs)
|
||||
# largest radius: the line/arc endpoints, or the arc's x-extreme if it lies within the sweep
|
||||
_, (cx, cz), r, a0, a1 = arc
|
||||
rmax = max(25.0, 20.0, 15.0, 18.0)
|
||||
lo_a, hi_a = min(a0, a1), max(a0, a1)
|
||||
for k in range(-2, 3):
|
||||
if lo_a <= 2 * PI * k <= hi_a:
|
||||
rmax = max(rmax, cx + r)
|
||||
method = ("revolve of a line/3-point-arc profile (XZ, x = radius, z = -sketch y) 360 deg about Z. Pappus/Green: "
|
||||
"V = 2 pi * int x dA, z_c = int x z dA / int x dA, A = sum over profile edges of 2 pi int x ds "
|
||||
"(bottom disk r20, frustum r20->25, torus zone of the arc (centre (%.6f, %.6f), R=%.6f), frustum r15->18, top disk r18)"
|
||||
% (cx, cz, r))
|
||||
return result(method, 1, V, area, [0, 0, zc], [-rmax, -rmax, 0], [rmax, rmax, 80], 5)
|
||||
|
||||
|
||||
def c05_taper_extrude():
|
||||
h, a0, t = 30.0, 40.0, math.radians(10)
|
||||
a1 = a0 + 2 * h * math.tan(t) # positive taper offsets the CCW outer loop outward (recipe.rs -> extrude_tapered)
|
||||
V, zc = sq_frustum(h, a0, a1)
|
||||
side = (a0 + a1) / 2 * h / math.cos(t)
|
||||
A = a0 * a0 + a1 * a1 + 4 * side
|
||||
return result("square frustum: base 40 at z=0, top a1 = 40 + 2*30*tan10 at z=30 (positive taper grows outward); "
|
||||
"V = h/3 (A0 + A1 + sqrt(A0 A1)), z_c = h (A0 + 2 sqrt(A0A1) + 3 A1) / (4 (A0 + sqrt(A0A1) + A1)), "
|
||||
"sides = 4 trapezoids (a0+a1)/2 * h/cos(10)", 1, V, A, [0, 0, zc], [-a1 / 2, -a1 / 2, 0], [a1 / 2, a1 / 2, h], 6)
|
||||
|
||||
|
||||
def c06_symmetric_extrude():
|
||||
a, b, c = 50.0, 30.0, 20.0
|
||||
return result("50 (x) by 20 (z) rectangle on XZ extruded +-15 along Y: box 50x30x20 centred at origin", 1, a * b * c,
|
||||
box_area(a, b, c), [0, 0, 0], [-25, -15, -10], [25, 15, 10], 6)
|
||||
|
||||
|
||||
def c07_fillet_all_edges():
|
||||
r = 3.0
|
||||
a, b, c = 40 - 2 * r, 30 - 2 * r, 20 - 2 * r # inner box swept by a ball of radius r
|
||||
V = a * b * c + 2 * r * (a * b + b * c + c * a) + PI * r * r * (a + b + c) + 4 / 3 * PI * r ** 3
|
||||
A = 2 * (a * b + b * c + c * a) + 2 * PI * r * (a + b + c) + 4 * PI * r * r
|
||||
return result("box 40x30x20 with every edge rounded r=3 = inner box (34x24x14) grown by a ball r (Steiner): "
|
||||
"V = abc + 2r(ab+bc+ca) + pi r^2 (a+b+c) + 4/3 pi r^3, A = 2(ab+bc+ca) + 2 pi r (a+b+c) + 4 pi r^2; "
|
||||
"faces 6 planes + 12 quarter cylinders + 8 sphere octants", 1, V, A, [20, 15, 10], [0, 0, 0], [40, 30, 20], 26)
|
||||
|
||||
|
||||
def c08_chamfer():
|
||||
d = 2.0
|
||||
mp = MP().add(*box_mp([0, 0, 0], [40, 30, 18]))
|
||||
# top slab z in [18, 20]: at depth u below the top the section is (36+2u) x (26+2u)
|
||||
# V = int_0^2 (36+2u)(26+2u) du ; z-moment = int (20-u)(...) du (polynomial, exact)
|
||||
f = lambda u: (36 + 2 * u) * (26 + 2 * u)
|
||||
# integrate quadratic exactly (Simpson)
|
||||
Vs = d / 6 * (f(0) + 4 * f(1) + f(2))
|
||||
g = lambda u: (20 - u) * f(u) # cubic: Simpson is exact
|
||||
Ms = d / 6 * (g(0) + 4 * g(1) + g(2))
|
||||
mp.add(Vs, [20, 15, Ms / Vs])
|
||||
s = d * math.sqrt(2)
|
||||
A = 40 * 30 + 2 * (40 + 30) * 18 + 36 * 26 + 2 * (40 + 36) / 2 * s + 2 * (30 + 26) / 2 * s
|
||||
return result("box 40x30x20 with the 4 top edges chamfered 2: lower box 40x30x18 + top slab with section (36+2u)x(26+2u) at depth u "
|
||||
"(V, moment by exact polynomial integration); A = bottom + sides(h=18) + top 36x26 + 4 trapezoid bevels of slant 2 sqrt2",
|
||||
1, mp.v, A, mp.com(), [0, 0, 0], [40, 30, 20], 10)
|
||||
|
||||
|
||||
def c09_shell():
|
||||
t = 2.0
|
||||
mp = MP().add(*box_mp([0, 0, 0], [50, 40, 30])).add(*box_mp([t, t, t], [50 - t, 40 - t, 30]), -1)
|
||||
ia, ib, ih = 50 - 2 * t, 40 - 2 * t, 30 - t
|
||||
A = 50 * 40 + 2 * (50 + 40) * 30 + (50 * 40 - ia * ib) + ia * ib + 2 * (ia + ib) * ih
|
||||
return result("50x40x30 box shelled inward 2 with the top open: box minus cavity [2,48]x[2,38]x[2,30]; "
|
||||
"A = outer bottom + outer sides + rim + cavity floor + cavity walls", 1, mp.v, A, mp.com(), [0, 0, 0], [50, 40, 30], 11)
|
||||
|
||||
|
||||
def c10_rect_pattern_holes():
|
||||
r, t = 3.0, 5.0
|
||||
n = 12
|
||||
mp = MP().add(*box_mp([0, 0, 0], [90, 70, t]))
|
||||
for i in range(4):
|
||||
for j in range(3):
|
||||
mp.add(PI * r * r * t, [15 + 20 * i, 15 + 20 * j, t / 2], -1)
|
||||
A = box_area(90, 70, t) + n * (2 * PI * r * t - 2 * PI * r * r)
|
||||
return result("plate 90x70x5 minus 12 through cylinders r=3 at (15+20i, 15+20j), i<4, j<3: V = abc - 12 pi r^2 t, "
|
||||
"A = 2(ab+bc+ca) + 12(2 pi r t - 2 pi r^2)", 1, mp.v, A, mp.com(), [0, 0, 0], [90, 70, t], 18)
|
||||
|
||||
|
||||
def c11_circular_pattern_flange():
|
||||
R, ri, rh, h = 50.0, 15.0, 5.0, 10.0
|
||||
V = PI * (R * R - ri * ri) * h - 6 * PI * rh * rh * h
|
||||
A = 2 * PI * (R * R - ri * ri) + 2 * PI * (R + ri) * h + 6 * (2 * PI * rh * h - 2 * PI * rh * rh)
|
||||
return result("annulus R50/r15 h10 minus 6 through cylinders r=5 on a 40 radius: V = pi(R^2-r^2)h - 6 pi rh^2 h, "
|
||||
"A = 2 pi(R^2-r^2) + 2 pi(R+r)h + 6(2 pi rh h - 2 pi rh^2); 6-fold symmetry keeps the centroid on the axis",
|
||||
1, V, A, [0, 0, h / 2], [-R, -R, 0], [R, R, h], 10)
|
||||
|
||||
|
||||
def c12_mirror_feature():
|
||||
r, hb = 6.0, 15.0
|
||||
mp = MP().add(*box_mp([-30, 0, 0], [30, 30, 10]))
|
||||
for x in (15, -15):
|
||||
mp.add(PI * r * r * hb, [x, 15, 10 + hb / 2])
|
||||
A = box_area(60, 30, 10) + 2 * (2 * PI * r * hb)
|
||||
return result("block [-30,30]x[0,30]x[0,10] + two bosses r=6 h=15 at (+-15,15) on top (mirror across YZ): "
|
||||
"V = abc + 2 pi r^2 h, A = 2(ab+bc+ca) + 2 * 2 pi r h", 1, mp.v, A, mp.com(), [-30, 0, 0], [30, 30, 25], 10)
|
||||
|
||||
|
||||
def c13_combine_join():
|
||||
r = 12.0
|
||||
mp = MP().add(*box_mp([0, 0, 0], [40, 40, 20]))
|
||||
mp.add(PI * r * r / 2 * 20, [40 + 4 * r / (3 * PI), 20, 10]) # half cylinder x>40, z 0..20
|
||||
mp.add(PI * r * r * 20, [40, 20, 30]) # full cylinder z 20..40
|
||||
A = (1600 + PI * r * r / 2) + (1600 - PI * r * r / 2) + PI * r * r + 3 * 800 + (800 - 2 * r * 20) + PI * r * 40 + PI * r * 20
|
||||
return result("box [0,40]^2x[0,20] union cylinder r=12 at (40,20) z 0..40: V = box + half-cylinder (x>40, z<20, centroid "
|
||||
"40 + 4r/3pi) + full cylinder z 20..40; A = bottom (1600 + pi r^2/2) + box top (1600 - pi r^2/2) + cyl top + "
|
||||
"3 full sides + x=40 side minus 24x20 + cyl side (pi r 40 outside half + pi r 20 upper inside half)",
|
||||
1, mp.v, A, mp.com(), [0, 0, 0], [52, 40, 40], 9)
|
||||
|
||||
|
||||
def c14_combine_cut():
|
||||
r = 15.0
|
||||
mp = MP().add(*box_mp([-25, -25, -20], [25, 25, 0]))
|
||||
mp.add(2 / 3 * PI * r ** 3, [0, 0, -3 * r / 8], -1)
|
||||
A = box_area(50, 50, 20) - PI * r * r + 2 * PI * r * r
|
||||
return result("box [-25,25]^2x[-20,0] minus sphere r=15 at the origin (lower hemisphere removed): V = abc - 2/3 pi r^3, "
|
||||
"hemisphere centroid z = -3r/8; A = 2(ab+bc+ca) - pi r^2 + 2 pi r^2", 1, mp.v, A, mp.com(),
|
||||
[-25, -25, -20], [25, 25, 0], 7)
|
||||
|
||||
|
||||
def c15_combine_intersect():
|
||||
R, a = 25.0, 20.0
|
||||
h = R - a
|
||||
rc = math.sqrt(R * R - a * a)
|
||||
V = 4 / 3 * PI * R ** 3 - 6 * cap_v(R, h)
|
||||
A = 4 * PI * R * R - 6 * 2 * PI * R * h + 6 * PI * rc * rc
|
||||
return result("cube [-20,20]^3 intersect sphere R=25: sphere minus 6 disjoint caps of height h=5 (caps cannot overlap: "
|
||||
"two would need x^2+y^2 > 800 > R^2); V = 4/3 pi R^3 - 6 pi h^2 (3R-h)/3, A = 4 pi R^2 - 6 * 2 pi R h + 6 pi (R^2-20^2)",
|
||||
1, V, A, [0, 0, 0], [-a, -a, -a], [a, a, a], 7)
|
||||
|
||||
|
||||
def c16_sweep():
|
||||
r, R, L1, L2 = 3.0, 20.0, 30.0, 30.0
|
||||
th = PI / 2
|
||||
ar = PI * r * r
|
||||
mp = MP()
|
||||
mp.add(ar * L1, [0, 0, L1 / 2]) # vertical leg z 0..30 along the Z axis
|
||||
# quarter torus: bend centre C=(20, 30) in XZ, from direction (-1,0) to (0,1) about C
|
||||
Vt = ar * R * th
|
||||
c_eff = (R * R + r * r / 4) / R # int rho^2 dA / int rho dA over the section disk
|
||||
rho = c_eff * math.sin(th / 2) / (th / 2) # sector centroid distance from the bend axis
|
||||
bis = (-math.sqrt(0.5), math.sqrt(0.5))
|
||||
mp.add(Vt, [20 + rho * bis[0], 0, 30 + rho * bis[1]])
|
||||
mp.add(ar * L2, [20 + L2 / 2, 0, 50])
|
||||
A = 2 * PI * r * (L1 + R * th + L2) + 2 * ar
|
||||
return result("circle r=3 swept along line 30 (+Z) + tangent R20 quarter arc + line 30 (+X): two cylinders + quarter torus "
|
||||
"(Pappus: V = pi r^2 R theta, A = 2 pi r R theta; torus centroid at rho = (R^2 + r^2/4)/R * sin(theta/2)/(theta/2) "
|
||||
"from the bend axis along the bisector) + 2 end disks", 1, mp.v, A, mp.com(), [-r, -r, 0], [50, r, 50 + r], 5)
|
||||
|
||||
|
||||
def c18_hole_features():
|
||||
T = 15.0
|
||||
mp = MP().add(*box_mp([0, 0, 0], [60, 40, T]))
|
||||
A = box_area(60, 40, T)
|
||||
# counterbore at (15,20): r=7 depth 5, then r=4 through
|
||||
mp.add(PI * 49 * 5, [15, 20, T - 2.5], -1)
|
||||
mp.add(PI * 16 * 10, [15, 20, 5], -1)
|
||||
A += -PI * 49 + 2 * PI * 7 * 5 + PI * (49 - 16) + 2 * PI * 4 * 10 - PI * 16
|
||||
# countersink at (35,20): 90 deg cone r=6 at the top to r=3 at depth 3, then r=3 through
|
||||
hc = (6 - 3) / math.tan(math.radians(45))
|
||||
mp.add(frustum_v(hc, 3, 6), [35, 20, T - hc + frustum_zc(hc, 3, 6)], -1)
|
||||
mp.add(PI * 9 * (T - hc), [35, 20, (T - hc) / 2], -1)
|
||||
A += -PI * 36 + frustum_lat(hc, 3, 6) + 2 * PI * 3 * (T - hc) - PI * 9
|
||||
# blind drilled hole at (50,20): r=3 to depth 10 (shoulder), 118 deg point below
|
||||
tip = 3 / math.tan(math.radians(59))
|
||||
zs = T - 10
|
||||
mp.add(PI * 9 * 10, [50, 20, zs + 5], -1)
|
||||
mp.add(PI * 9 * tip / 3, [50, 20, zs - tip / 4], -1)
|
||||
A += -PI * 9 + 2 * PI * 3 * 10 + PI * 3 * math.hypot(3, tip)
|
||||
return result("plate 60x40x15 minus: counterbore (r7 x 5 deep + r4 through), countersink (90 deg cone frustum r6->r3 over 3 mm "
|
||||
"+ r3 through), blind r3 hole 10 deep to the shoulder with a 118 deg cone point (height 3/tan59, centroid 1/4 up "
|
||||
"from the apex); areas: removed top/bottom disks, cylinder walls 2 pi r h, cb floor annulus, cone laterals pi r s",
|
||||
1, mp.v, A, mp.com(), [0, 0, 0], [60, 40, T], 13)
|
||||
|
||||
|
||||
def c19_l_bracket():
|
||||
# solved: l1 (0,0)-(60,0), l2 up 8, l3 left to x=8 (l5 is 8 long and l6 is vertical at x=0), l4 up to y=40, l5 to (0,40)
|
||||
h = 20.0
|
||||
mp = MP().add(*box_mp([0, 0, 0], [60, 8, h])).add(*box_mp([0, 8, 0], [8, 40, h]))
|
||||
a2 = 60 * 8 + 8 * 32
|
||||
A = 2 * a2 + (60 + 8 + 52 + 32 + 8 + 40) * h
|
||||
return result("solved L: (0,0)-(60,0)-(60,8)-(8,8)-(8,40)-(0,40); area 60x8 + 8x32, extruded 20: V = A2 h, "
|
||||
"A = 2 A2 + perimeter*h, centroid of the two rectangles", 1, mp.v, A, mp.com(), [0, 0, 0], [60, 40, h], 8)
|
||||
|
||||
|
||||
def c20_slot():
|
||||
r, L, h = 6.0, 40.0, 5.0
|
||||
a2 = 2 * r * L + PI * r * r
|
||||
per = 2 * L + 2 * PI * r
|
||||
return result("obround: arc centres (0,0) and (40,0), R6, tangent horizontal lines y=+-6: A2 = 2rL + pi r^2, "
|
||||
"V = A2 h, A = 2 A2 + perimeter (2L + 2 pi r) h", 1, a2 * h, 2 * a2 + per * h, [L / 2, 0, h / 2],
|
||||
[-r, -r, 0], [L + r, r, h], 6)
|
||||
|
||||
|
||||
def c21_draft():
|
||||
h, a0, t = 30.0, 40.0, math.radians(5)
|
||||
a1 = a0 + 2 * h * math.tan(t) # pull = -Z (bottom normal); faces lean in along the pull, so the top widens
|
||||
V, zc = sq_frustum(h, a0, a1)
|
||||
A = a0 * a0 + a1 * a1 + 4 * (a0 + a1) / 2 * h / math.cos(t)
|
||||
return result("40x40x30 box, 5 deg draft hinged on the bottom (z=0), pull -Z: square frustum 40 -> 40 + 2*30*tan5; "
|
||||
"V = h/3 (A0 + A1 + sqrt(A0A1)), sides 4 trapezoids of slant h/cos5", 1, V, A, [0, 0, zc],
|
||||
[-a1 / 2, -a1 / 2, 0], [a1 / 2, a1 / 2, h], 6)
|
||||
|
||||
|
||||
def c22_split_body():
|
||||
r, h = 20.0, 60.0
|
||||
t = math.radians(30)
|
||||
V = PI * r * r * h
|
||||
A = 2 * PI * r * r + 2 * PI * r * h + 2 * PI * r * (r / math.cos(t))
|
||||
return result("cylinder r20 z -30..30 split by the plane through the X axis tilted 30 deg (z = y tan30, stays within |z|<=11.6 "
|
||||
"inside the cylinder): 2 congruent halves (point symmetry), V total = pi r^2 h, A total = cylinder + 2 ellipses "
|
||||
"pi r (r/cos30)", 2, V, A, [0, 0, 0], [-r, -r, -h / 2], [r, r, h / 2], 6)
|
||||
|
||||
|
||||
def c24_user_parameters():
|
||||
w, d, h, rh = 60.0, 40.0, 15.0, 5.0
|
||||
V = (w * d - PI * rh * rh) * h
|
||||
A = 2 * (w * d - PI * rh * rh) + 2 * (w + d) * h + 2 * PI * rh * h
|
||||
return result("width 60 x depth 40 x height 15 block from the origin with a centred d10 through hole: V = (wd - pi r^2) h, "
|
||||
"A = 2(wd - pi r^2) + 2(w+d)h + 2 pi r h", 1, V, A, [w / 2, d / 2, h / 2], [0, 0, 0], [w, d, h], 7)
|
||||
|
||||
|
||||
def c25_sketch_triangle():
|
||||
# P = t (cos60, sin60), |P - (50,0)| = 45 -> t^2 - 50 t + 475 = 0; the drawn apex (|P| ~ 41) picks t = 25 + sqrt(150)
|
||||
tt = 25 + math.sqrt(150)
|
||||
P = (tt * 0.5, tt * math.sqrt(3) / 2)
|
||||
h = 5.0
|
||||
a2 = 0.5 * 50 * P[1]
|
||||
per = 50 + 45 + tt
|
||||
return result("solved triangle (0,0), (50,0), P = t(cos60, sin60) with t = 25 + sqrt(150) from |P-(50,0)| = 45 "
|
||||
"(the root nearest the drawn apex); V = (1/2 * 50 * Py) * 5, A = 2 A2 + perimeter*5, centroid = vertex mean",
|
||||
1, a2 * h, 2 * a2 + per * h, [(50 + P[0]) / 3, P[1] / 3, h / 2], [0, 0, 0], [50, P[1], h], 5)
|
||||
|
||||
|
||||
def c26_sketch_belt():
|
||||
R, r, d, rh, h = 20.0, 10.0, 50.0, 4.0, 5.0
|
||||
al = math.asin((R - r) / d)
|
||||
nt = (math.sin(al), math.cos(al))
|
||||
nb = (math.sin(al), -math.cos(al))
|
||||
b1, b2 = (R * nb[0], R * nb[1]), (d + r * nb[0], r * nb[1])
|
||||
t2, t1 = (d + r * nt[0], r * nt[1]), (R * nt[0], R * nt[1])
|
||||
segs = [("line", b1, b2), ("arc", (d, 0), r, -(PI / 2 - al), PI / 2 - al), ("line", t2, t1),
|
||||
("arc", (0, 0), R, PI / 2 - al, 3 * PI / 2 + al)]
|
||||
A2, Sx, Sy, _ = region(segs)
|
||||
per_out = sum(seg_len(s) for s in segs)
|
||||
A2n = A2 - PI * rh * rh
|
||||
Sxn = Sx - PI * rh * rh * d
|
||||
V = A2n * h
|
||||
A = 2 * A2n + (per_out + 2 * PI * rh) * h
|
||||
return result("belt: convex hull of circles R20 @ (0,0) and r10 @ (50,0) (external tangents, sin a = (R-r)/d) minus the "
|
||||
"d8 hole at (50,0), extruded 5 as one body; hull area = (R+r) sqrt(d^2-(R-r)^2) + R^2(pi+2a)/2 + r^2(pi-2a)/2; "
|
||||
"moments by Green's theorem over the line/arc boundary", 1, V, A, [Sxn / A2n, 0, h / 2],
|
||||
[-R, -R, 0], [d + r, R, h], 7)
|
||||
|
||||
|
||||
def c27_sketch_trapezoid():
|
||||
a, b, H, rh, t = 60.0, 30.0, 25.0, 3.0, 5.0
|
||||
At = (a + b) / 2 * H
|
||||
yt = H * (a + 2 * b) / (3 * (a + b))
|
||||
A2 = At - 2 * PI * rh * rh
|
||||
yc = (At * yt - 2 * PI * rh * rh * 8) / A2
|
||||
per = a + b + 2 * math.hypot((a - b) / 2, H) + 2 * 2 * PI * rh
|
||||
return result("solved trapezoid bottom (-30,0)-(30,0), top (-15,25)-(15,25) minus two d6 holes at (+-18, 8) "
|
||||
"(symmetric about the axis, 36 apart, 8 above l1); trapezoid centroid y = H(a+2b)/(3(a+b)); "
|
||||
"V = A2 t, A = 2 A2 + perimeter t", 1, A2 * t, 2 * A2 + per * t, [0, yc, t / 2], [-30, 0, 0], [30, H, t], 8)
|
||||
|
||||
|
||||
def c29_two_sided_partial_revolve():
|
||||
# block: x -20..20, z 0..20 (XZ sketch, y_dir = -Z), extruded +10 along +Y and 25 along -Y
|
||||
mp = MP().add(*box_mp([-20, -25, 0], [20, 10, 20]))
|
||||
ri, ro, z0, z1 = 4.0, 12.0, 12.0, 20.0
|
||||
th = 1.5 * PI # sweeps from +X towards the sketch normal (+Y): theta in [0, 270 deg]
|
||||
# removed cross-section S = annular sector theta in [0, th] minus the part above y = 10 (outside the block)
|
||||
Asec = th / 2 * (ro * ro - ri * ri)
|
||||
Mx_sec = (ro ** 3 - ri ** 3) / 3 * math.sin(th)
|
||||
My_sec = (ro ** 3 - ri ** 3) / 3 * (1 - math.cos(th))
|
||||
dy = 10.0
|
||||
seg_a = ro * ro * math.acos(dy / ro) - dy * math.sqrt(ro * ro - dy * dy)
|
||||
seg_my = 2 / 3 * (ro * ro - dy * dy) ** 1.5
|
||||
S = Asec - seg_a
|
||||
Mx, My = Mx_sec, My_sec - seg_my
|
||||
hz = z1 - z0
|
||||
mp.add(S * hz, [Mx / S, My / S, (z0 + z1) / 2], -1)
|
||||
half_w = math.sqrt(ro * ro - dy * dy)
|
||||
phi = math.asin(dy / ro)
|
||||
outer_ang = th - (PI - 2 * phi)
|
||||
A = box_area(40, 35, 20) - S + S - 2 * half_w * hz + ri * th * hz + ro * outer_ang * hz + 2 * (ro - ri) * hz
|
||||
return result("block [-20,20]x[-25,10]x[0,20] minus the 270 deg revolve (from +X towards +Y) of the 8x8 square r 4..12, z 12..20; "
|
||||
"the removed section is the annular sector minus the circular segment y > 10 (R^2 acos(d/R) - d sqrt(R^2-d^2), "
|
||||
"y-moment 2/3 (R^2-d^2)^1.5) that lies outside the block; sector moments (R^3-r^3)/3 (sin, 1-cos); "
|
||||
"A = box - top opening + floor - front-face notch 2 sqrt(44) x 8 + inner wall r th h + outer wall (th - pi + 2 asin(5/6)) R h + 2 end caps",
|
||||
1, mp.v, A, mp.com(), [-20, -25, 0], [20, 10, 20], 12)
|
||||
|
||||
|
||||
CASES = {
|
||||
"01-box": c01_box,
|
||||
"02-cylinder": c02_cylinder,
|
||||
"03-box-through-hole": c03_box_through_hole,
|
||||
"04-revolve-vase": c04_revolve_vase,
|
||||
"05-taper-extrude": c05_taper_extrude,
|
||||
"06-symmetric-extrude": c06_symmetric_extrude,
|
||||
"07-fillet-all-edges": c07_fillet_all_edges,
|
||||
"08-chamfer": c08_chamfer,
|
||||
"09-shell": c09_shell,
|
||||
"10-rect-pattern-holes": c10_rect_pattern_holes,
|
||||
"11-circular-pattern-flange": c11_circular_pattern_flange,
|
||||
"12-mirror-feature": c12_mirror_feature,
|
||||
"13-combine-join": c13_combine_join,
|
||||
"14-combine-cut": c14_combine_cut,
|
||||
"15-combine-intersect": c15_combine_intersect,
|
||||
"16-sweep": c16_sweep,
|
||||
"18-hole-features": c18_hole_features,
|
||||
"19-l-bracket-constrained": c19_l_bracket,
|
||||
"20-slot-constrained": c20_slot,
|
||||
"21-draft": c21_draft,
|
||||
"22-split-body": c22_split_body,
|
||||
"24-user-parameters": c24_user_parameters,
|
||||
"25-sketch-triangle": c25_sketch_triangle,
|
||||
"26-sketch-belt": c26_sketch_belt,
|
||||
"27-sketch-trapezoid": c27_sketch_trapezoid,
|
||||
"29-two-sided-and-partial-revolve": c29_two_sided_partial_revolve,
|
||||
}
|
||||
|
||||
|
||||
def main(argv):
|
||||
names = argv[1:] or list(CASES)
|
||||
for n in names:
|
||||
d = CASES[n]()
|
||||
path = os.path.join(ROOT, n, "expected.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"{n}: V={d['volume_mm3']!r} A={d['area_mm2']!r} com={d['center_of_mass']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Closed-form expected values for the feature cases of tests/oracle (analytic oracle).
|
||||
|
||||
Standalone, Python 3 stdlib only. Run from the worktree root:
|
||||
|
||||
python3 tools/oracle-analytic/cases_features.py
|
||||
|
||||
Every number is derived from the recipe's dimensions with elementary geometry:
|
||||
prisms, cylinders, cones, spheres, tori, solids of revolution (Pappus / Green's theorem on
|
||||
the (r, z) profile), constant fillets as swept spandrels, tubes, screw sweeps. Planar
|
||||
regions bounded by lines and circular arcs are integrated exactly with Green's theorem
|
||||
(Gauss-Legendre on each segment, exact to round-off for lines and short arc pieces).
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
PI = math.pi
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
OUT = os.path.join(ROOT, "tests", "oracle")
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Planar regions (lines + circular arcs), Green's theorem.
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gauss_legendre(n):
|
||||
xs, ws = [], []
|
||||
for i in range(1, n + 1):
|
||||
x = math.cos(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_X, GL_W = _gauss_legendre(24)
|
||||
|
||||
|
||||
def L(p, q):
|
||||
"""Line segment p -> q."""
|
||||
return ("L", tuple(p), tuple(q))
|
||||
|
||||
|
||||
def A(c, r, a0, a1):
|
||||
"""Arc about centre c, radius r, from angle a0 to a1 (radians; a1 < a0 is clockwise)."""
|
||||
return ("A", tuple(c), r, a0, a1)
|
||||
|
||||
|
||||
def _pieces(seg):
|
||||
"""Parametric pieces (point(t), derivative(t)) over t in [0, 1]."""
|
||||
if seg[0] == "L":
|
||||
(x0, y0), (x1, y1) = seg[1], seg[2]
|
||||
yield (lambda t: (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t), lambda t: (x1 - x0, y1 - y0))
|
||||
else:
|
||||
(cx, cy), r, a0, a1 = seg[1], seg[2], seg[3], seg[4]
|
||||
n = max(1, int(math.ceil(abs(a1 - a0) / (PI / 36))))
|
||||
for k in range(n):
|
||||
b0 = a0 + (a1 - a0) * k / n
|
||||
b1 = a0 + (a1 - a0) * (k + 1) / n
|
||||
db = b1 - b0
|
||||
yield (
|
||||
(lambda b0, db: lambda t: (cx + r * math.cos(b0 + db * t), cy + r * math.sin(b0 + db * t)))(b0, db),
|
||||
(lambda b0, db: lambda t: (-r * db * math.sin(b0 + db * t), r * db * math.cos(b0 + db * t)))(b0, db),
|
||||
)
|
||||
|
||||
|
||||
def region(loops):
|
||||
"""Integrals of a region whose boundary loops are CCW (outer) / CW (holes).
|
||||
|
||||
Returns dict: A, Sx = int x dA, Sy = int y dA, Sxy = int x y dA, P = perimeter,
|
||||
Px = int x ds over the boundary.
|
||||
"""
|
||||
acc = dict(A=0.0, Sx=0.0, Sy=0.0, Sxy=0.0, P=0.0, Px=0.0)
|
||||
for loop in loops:
|
||||
for seg in loop:
|
||||
for pt, dpt in _pieces(seg):
|
||||
for xg, wg in zip(GL_X, GL_W):
|
||||
t = 0.5 * (xg + 1)
|
||||
w = 0.5 * wg
|
||||
x, y = pt(t)
|
||||
dx, dy = dpt(t)
|
||||
ds = math.hypot(dx, dy)
|
||||
acc["A"] += w * x * dy
|
||||
acc["Sx"] += w * x * x / 2 * dy
|
||||
acc["Sy"] += w * x * y * dy
|
||||
acc["Sxy"] += w * x * x * y / 2 * dy
|
||||
acc["P"] += w * ds
|
||||
acc["Px"] += w * x * ds
|
||||
return acc
|
||||
|
||||
|
||||
def poly(pts):
|
||||
return [L(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))]
|
||||
|
||||
|
||||
def reverse(loop):
|
||||
out = []
|
||||
for seg in reversed(loop):
|
||||
out.append(L(seg[2], seg[1]) if seg[0] == "L" else A(seg[1], seg[2], seg[4], seg[3]))
|
||||
return out
|
||||
|
||||
|
||||
def circle_loop(c, r, ccw=True):
|
||||
return [A(c, r, 0.0, 2 * PI if ccw else -2 * PI)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Mass properties: (V, [Mx, My, Mz]) with composition.
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MP:
|
||||
def __init__(self, v=0.0, m=(0.0, 0.0, 0.0)):
|
||||
self.v = v
|
||||
self.m = list(m)
|
||||
|
||||
@staticmethod
|
||||
def at(v, c):
|
||||
return MP(v, [v * c[0], v * c[1], v * c[2]])
|
||||
|
||||
def __add__(self, o):
|
||||
return MP(self.v + o.v, [a + b for a, b in zip(self.m, o.m)])
|
||||
|
||||
def __sub__(self, o):
|
||||
return MP(self.v - o.v, [a - b for a, b in zip(self.m, o.m)])
|
||||
|
||||
def com(self):
|
||||
return [x / self.v for x in self.m]
|
||||
|
||||
|
||||
def box(x0, y0, z0, x1, y1, z1):
|
||||
v = (x1 - x0) * (y1 - y0) * (z1 - z0)
|
||||
return MP.at(v, [(x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2])
|
||||
|
||||
|
||||
def cyl(cx, cy, r, z0, z1):
|
||||
return MP.at(PI * r * r * (z1 - z0), [cx, cy, (z0 + z1) / 2])
|
||||
|
||||
|
||||
def extrude(reg, z0, h):
|
||||
"""Prism of a planar region (sketch x, y = world x, y) from z0 to z0 + h."""
|
||||
return MP(reg["A"] * h, [reg["Sx"] * h, reg["Sy"] * h, reg["A"] * h * (z0 + h / 2)])
|
||||
|
||||
|
||||
def extrude_area(reg, h):
|
||||
return 2 * reg["A"] + reg["P"] * h
|
||||
|
||||
|
||||
def revolve(reg, cx=0.0, cy=0.0):
|
||||
"""Full revolution about the vertical axis through (cx, cy) of an (r, z) region (r >= 0)."""
|
||||
v = 2 * PI * reg["Sx"]
|
||||
return MP(v, [v * cx, v * cy, 2 * PI * reg["Sxy"]])
|
||||
|
||||
|
||||
def revolve_area(reg):
|
||||
"""Area of the surface swept by the whole boundary (segments on the axis add nothing)."""
|
||||
return 2 * PI * reg["Px"]
|
||||
|
||||
|
||||
# Spandrel of a fillet radius R: the square corner R x R minus the quarter disc.
|
||||
def spandrel_area(R):
|
||||
return R * R * (1 - PI / 4)
|
||||
|
||||
|
||||
def spandrel_offset(R):
|
||||
"""Distance of the spandrel's centroid from each of the two corner faces."""
|
||||
return R * (10 - 3 * PI) / (3 * (4 - PI))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Output
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def write(case, method, bodies, mp, area, bbox, faces=None, **extra):
|
||||
d = {
|
||||
"source": "analytic",
|
||||
"method": method,
|
||||
"body_count": bodies,
|
||||
"volume_mm3": mp.v,
|
||||
"area_mm2": area,
|
||||
"center_of_mass": mp.com(),
|
||||
"bbox": {"min": list(map(float, bbox[0])), "max": list(map(float, bbox[1]))},
|
||||
}
|
||||
if faces is not None:
|
||||
d["faces"] = faces
|
||||
d.update(extra)
|
||||
path = os.path.join(OUT, case, "expected.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f, indent=2)
|
||||
f.write("\n")
|
||||
print(f"{case}: V={mp.v:.6f} A={area:.6f} com={[round(x, 6) for x in mp.com()]}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Cases
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def c32():
|
||||
# Plate 60x60x8 centred; boss r10 z 8..23 with R3 concave base fillet and R2 convex top fillet.
|
||||
# The boss with both fillets is one solid of revolution: its (r, z) profile.
|
||||
prof = region([[
|
||||
L((0, 8), (13, 8)),
|
||||
A((13, 11), 3, -PI / 2, -PI), # concave base fillet, (13,8) -> (10,11)
|
||||
L((10, 11), (10, 21)),
|
||||
A((8, 21), 2, 0.0, PI / 2), # convex top fillet, (10,21) -> (8,23)
|
||||
L((8, 23), (0, 23)),
|
||||
L((0, 23), (0, 8)),
|
||||
]])
|
||||
mp = box(-30, -30, 0, 30, 30, 8) + revolve(prof)
|
||||
# Revolved boundary minus its bottom disc (r 0..13 at z=8, internal); plate top loses that disc.
|
||||
area = (3600 + 4 * 60 * 8 + 3600 - PI * 13 ** 2) + (revolve_area(prof) - PI * 13 ** 2)
|
||||
write("32-boss-plate-fillets",
|
||||
"plate 60x60x8 + boss (r10, z 8..23) with R3 concave base and R2 convex top fillets taken as one "
|
||||
"solid of revolution: V, z-moment and lateral area from Pappus/Green on its (r,z) profile "
|
||||
"(quarter-circle fillet arcs); plate top loses the r13 disc",
|
||||
1, mp, area, [(-30, -30, 0), (30, 30, 23)], faces=10)
|
||||
|
||||
|
||||
def c33():
|
||||
# Obround R8 (centres x=0, x=40), height 10. Top loop R2 fillet, bottom loop 1 mm chamfer.
|
||||
R, Lst, H = 8.0, 40.0, 10.0
|
||||
rf, ch = 2.0, 1.0
|
||||
base = MP.at((2 * R * Lst + PI * R * R) * H, [Lst / 2, 0, H / 2])
|
||||
|
||||
def path_len(inset): # perimeter of the obround inset by `inset`
|
||||
return 2 * Lst + 2 * PI * (R - inset)
|
||||
|
||||
# Removed fillet spandrel: area R^2(1-pi/4), centroid inset d from the wall and d below the top.
|
||||
d = spandrel_offset(rf)
|
||||
fil = MP.at(spandrel_area(rf) * path_len(d), [Lst / 2, 0, H - d])
|
||||
# Removed chamfer triangle: area c^2/2, centroid inset c/3, height c/3.
|
||||
cha = MP.at(ch * ch / 2 * path_len(ch / 3), [Lst / 2, 0, ch / 3])
|
||||
mp = base - fil - cha
|
||||
top = 2 * (R - rf) * Lst + PI * (R - rf) ** 2
|
||||
bot = 2 * (R - ch) * Lst + PI * (R - ch) ** 2
|
||||
wall = (H - rf - ch) * path_len(0)
|
||||
# fillet surface: quarter arc length pi*rf/2, its centroid inset rf - 2rf/pi from the wall
|
||||
fsurf = PI * rf / 2 * path_len(rf - 2 * rf / PI)
|
||||
csurf = math.sqrt(2) * ch * path_len(ch / 2)
|
||||
area = top + bot + wall + fsurf + csurf
|
||||
write("33-fillet-tangent-chain",
|
||||
"obround (R8, centres 40 apart) x 10 minus R2 fillet spandrel R^2(1-pi/4) and 1 mm chamfer "
|
||||
"triangle swept round the whole loop (Pappus: section x path of its centroid, inset "
|
||||
"R(10-3pi)/(3(4-pi)) resp. c/3); surfaces likewise (fillet arc centroid inset R-2R/pi)",
|
||||
1, mp, area, [(-8, -8, 0), (48, 8, 10)], faces=14)
|
||||
|
||||
|
||||
def c38():
|
||||
mp = cyl(0, 0, 10, 0, 50)
|
||||
area = 2 * PI * 10 * 50 + 2 * PI * 100
|
||||
write("38-coincident-cylinders",
|
||||
"union of r10 z0..30 and r10 z20..50 = cylinder r10 h50: V = pi r^2 h, A = 2 pi r h + 2 pi r^2",
|
||||
1, mp, area, [(-10, -10, 0), (10, 10, 50)], faces=3)
|
||||
|
||||
|
||||
def c40():
|
||||
r_s, R_t, r_t = 12.0, 22.0, 4.0
|
||||
b = box(-30, -30, -10, 30, 30, 0)
|
||||
hemi = MP.at(2 / 3 * PI * r_s ** 3, [0, 0, 3 * r_s / 8])
|
||||
# The sphere (r12) also pokes 2 mm out of the box bottom (z=-10): a spherical cap h=2.
|
||||
h = r_s - 10.0
|
||||
cap = MP.at(PI * h * h * (3 * r_s - h) / 3, [0, 0, -3 * (2 * r_s - h) ** 2 / (4 * (3 * r_s - h))])
|
||||
# Torus centred on the box top: only its lower half lies in material.
|
||||
half_torus = MP.at(PI * r_t ** 2 / 2 * 2 * PI * R_t, [0, 0, -4 * r_t / (3 * PI)])
|
||||
mp = b + hemi + cap - half_torus
|
||||
area = (3600 - PI * (r_s ** 2 - 10.0 ** 2) + 4 * 60 * 10 # bottom (minus cap footprint) + sides
|
||||
+ 3600 - PI * r_s ** 2 - PI * ((R_t + r_t) ** 2 - (R_t - r_t) ** 2) # top
|
||||
+ 2 * PI * r_s ** 2 # upper hemisphere
|
||||
+ 2 * PI * r_s * h # cap below the box
|
||||
+ 2 * PI * R_t * PI * r_t) # half torus (semicircle r_t about R_t)
|
||||
write("40-box-sphere-torus",
|
||||
"box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, "
|
||||
"A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, "
|
||||
"A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44)",
|
||||
1, mp, area, [(-30, -30, -12), (30, 30, 12)], faces=10,
|
||||
note="faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, "
|
||||
"upper sphere, lower cap, torus")
|
||||
|
||||
|
||||
def c43():
|
||||
mp = box(0, 0, 0, 35, 20, 20) + box(45, 0, 0, 80, 20, 20)
|
||||
area = 2 * 2 * (35 * 20 + 20 * 20 + 35 * 20)
|
||||
write("43-cut-splits-body",
|
||||
"two boxes 35x20x20 (x 0..35 and 45..80): V = 2abc, A = 2*2(ab+bc+ca)",
|
||||
2, mp, area, [(0, 0, 0), (80, 20, 20)], faces=12)
|
||||
|
||||
|
||||
def c44():
|
||||
# L (x 0..60, y 0..8 and x 0..8, y 0..40) x 20; shell 2 inward, top open.
|
||||
# SolidCraft's shell convention (intersection join): the inner walls are the outer faces
|
||||
# offset by 2 and extended until they meet, so the cavity is the L shrunk by 2 with a sharp
|
||||
# corner at (6,6) opposite the reflex edge. Floor at z=2, open at z=20.
|
||||
Lpts = [(0, 0), (60, 0), (60, 8), (8, 8), (8, 40), (0, 40)]
|
||||
outer = region([poly(Lpts)])
|
||||
cav = region([poly([(2, 2), (58, 2), (58, 6), (6, 6), (6, 38), (2, 38)])])
|
||||
mp = extrude(outer, 0, 20) - extrude(cav, 2, 18)
|
||||
area = (outer["A"] + outer["P"] * 20 # bottom + outer walls
|
||||
+ (outer["A"] - cav["A"]) # top rim
|
||||
+ cav["A"] + cav["P"] * 18) # cavity floor + inner walls
|
||||
# Rounded (constant-distance, arc) join instead: cavity gains (4-pi) x 18.
|
||||
rv, ra = mp.v - (4 - PI) * 18, area - (4 - PI) * 18
|
||||
write("44-shell-l-bracket",
|
||||
"L prism (area 736, perimeter 200) x 20 minus cavity = L shrunk by 2 (SolidCraft's intersection-join "
|
||||
"shell convention: inner faces offset by 2 and extended to meet, sharp inner corner at (6,6); area 352) "
|
||||
f"from z=2 to 20. A rounded (arc) join would give V {rv:.4f}, A {ra:.4f}, 16 faces",
|
||||
1, mp, area, [(0, 0, 0), (60, 40, 20)], faces=15)
|
||||
|
||||
|
||||
def c45():
|
||||
# Plate 60x60x10 centred + boss r12 z 10..30; shell 2 inward, bottom open.
|
||||
# Intersection-join convention: cavity = box 56x56 z 0..8 + cylinder r10 z 8..28 (the offset
|
||||
# cylinder r10 extended down to meet the offset plate top z=8, sharp circular inner edge).
|
||||
solid = box(-30, -30, 0, 30, 30, 10) + cyl(0, 0, 12, 10, 30)
|
||||
cav = box(-28, -28, 0, 28, 28, 8) + cyl(0, 0, 10, 8, 28)
|
||||
mp = solid - cav
|
||||
area = (3600 - 56 * 56 # bottom rim
|
||||
+ 4 * 60 * 10 # plate sides
|
||||
+ 3600 - PI * 144 # plate top
|
||||
+ 2 * PI * 12 * 20 + PI * 144 # boss side + top
|
||||
+ 4 * 56 * 8 # cavity side walls
|
||||
+ 56 * 56 - PI * 100 # cavity ceiling z=8 (outside r10)
|
||||
+ 2 * PI * 10 * 20 # inner cylinder r10 z8..28
|
||||
+ PI * 100) # inner top disc z=28
|
||||
# Rounded (arc) join: the ring r10..12, z8..10 outside the r2 torus about (12,10) is also hollow.
|
||||
ring = region([[L((10, 8), (12, 8)), A((12, 10), 2, -PI / 2, -PI), L((10, 10), (10, 8))]])
|
||||
rv = mp.v - revolve(ring).v
|
||||
ra = (area - 2 * PI * 10 * 2 - PI * (144 - 100) + 2 * PI * PI * (12 - 4 / PI))
|
||||
write("45-shell-boss-plate",
|
||||
"plate 60x60x10 + boss r12 h20 minus cavity (box 56x56x8 + cylinder r10 z8..28) under SolidCraft's "
|
||||
"intersection-join shell convention (offset faces extended to meet: sharp circular inner edge at r10, "
|
||||
f"z8). A rounded (arc) join would give V {rv:.4f}, A {ra:.4f}, 16 faces (torus round r2)",
|
||||
1, mp, area, [(-30, -30, 0), (30, 30, 30)], faces=15)
|
||||
|
||||
|
||||
def c46():
|
||||
mp = box(0, 0, 0, 50, 40, 30) - box(3, 0, 3, 47, 37, 30)
|
||||
area = (50 * 40 + 50 * 30 + 2 * 40 * 30 # bottom, back, two sides
|
||||
+ (50 * 40 - 44 * 37) + (50 * 30 - 44 * 27) # top rim, front rim
|
||||
+ 44 * 37 + 44 * 27 + 2 * 37 * 27) # floor, back inner, side inners
|
||||
write("46-shell-two-open-faces",
|
||||
"box 50x40x30 minus cavity x3..47, y0..37, z3..30 (3 mm walls, top and front open)",
|
||||
1, mp, area, [(0, 0, 0), (50, 40, 30)], faces=10)
|
||||
|
||||
|
||||
def c48():
|
||||
# Sketch on XZ with y_dir = -Z: profile (r, z) = (sx, -sy).
|
||||
pts = [(5, 0), (40, 0), (40, 3), (33, 8), (33, 12), (40, 17), (40, 23), (33, 28), (33, 32),
|
||||
(40, 37), (40, 40), (5, 40)]
|
||||
reg = region([poly(pts)])
|
||||
mp = revolve(reg)
|
||||
area = revolve_area(reg)
|
||||
write("48-pulley",
|
||||
"revolve of the 12-segment (r,z) polygon about Z: V = 2 pi int r dA, Mz = 2 pi int r z dA, "
|
||||
"A = 2 pi int r ds (Pappus)",
|
||||
1, mp, area, [(-40, -40, 0), (40, 40, 40)], faces=12)
|
||||
|
||||
|
||||
def c49():
|
||||
# Coil: the circle r=2 centred at radius 20 in the meridian plane, screw-swept 3 turns, pitch 8.
|
||||
Rh, a, p, n = 20.0, 2.0, 8.0, 3.0
|
||||
c = p / (2 * PI)
|
||||
v = n * 2 * PI * Rh * PI * a * a # screw motion: only rotation sweeps volume
|
||||
# lateral area: n * 2pi * closed-integral sqrt(r^2 + c^2 (dr/ds)^2) ds, r = R + a cos t, dr/ds = -sin t
|
||||
N = 4096
|
||||
s = 0.0
|
||||
for k in range(N):
|
||||
t = 2 * PI * k / N
|
||||
s += math.sqrt((Rh + a * math.cos(t)) ** 2 + c * c * math.sin(t) ** 2) * a
|
||||
lat = n * 2 * PI * s * 2 * PI / N
|
||||
area = lat + 2 * PI * a * a
|
||||
mp = MP.at(v, [0.0, 0.0, c * n * PI]) # full turns: x,y centroid on the axis; z = c * mean angle
|
||||
write("49-helical-sweep",
|
||||
"true helix coil (screw motion of the axial d4 section, R20, pitch 8, 3 turns): "
|
||||
"V = turns * 2 pi R * pi a^2; lateral A = turns * 2 pi * int sqrt(r^2 + (p/2pi)^2 (dr/ds)^2) ds "
|
||||
"(periodic trapezoid, exact to round-off) + two end discs; COM z = pitch*turns/2",
|
||||
1, mp, area, [(-22, -22, -2), (22, 22, 26)], faces=3)
|
||||
|
||||
|
||||
def c50():
|
||||
r, Rb = 5.0, 5.0
|
||||
l1 = cyl(0, 0, r, 0, 20)
|
||||
# quarter torus: bend centre (5,0,20), axis Y, from (0,0,20) to (5,0,25).
|
||||
vb = PI * r * r * (PI / 2 * Rb)
|
||||
rad_mean = (Rb * Rb + r * r / 4) / Rb # int rho^2 dA / int rho dA
|
||||
off = rad_mean * (math.sin(PI / 4) / (PI / 4)) / math.sqrt(2)
|
||||
bend = MP.at(vb, [5 - off, 0, 20 + off])
|
||||
l2 = MP.at(PI * r * r * 20, [15, 0, 25])
|
||||
mp = l1 + bend + l2
|
||||
Lp = 20 + PI / 2 * Rb + 20
|
||||
area = 2 * PI * r * Lp + 2 * PI * r * r
|
||||
write("50-sweep-tight-bend",
|
||||
"tube r5 along line 20 + R5 quarter bend + line 20: V = pi r^2 L, A = 2 pi r L + 2 pi r^2 "
|
||||
"(Pappus, valid while bend radius >= r; here equal so the inner side pinches to a line); "
|
||||
"bend centroid at (R^2 + r^2/4)/R * sinc(pi/4) along the bisector",
|
||||
1, mp, area, [(-5, -5, 0), (25, 5, 30)], faces=5)
|
||||
|
||||
|
||||
def c52():
|
||||
disc = cyl(0, 0, 40, 0, 8)
|
||||
prof = region([[
|
||||
L((0, 8), (8, 8)),
|
||||
A((8, 10), 2, -PI / 2, -PI), # concave fillet R2 at the boss base
|
||||
L((6, 10), (6, 18)), L((6, 18), (0, 18)), L((0, 18), (0, 8)),
|
||||
]])
|
||||
mp = disc
|
||||
for k in range(6):
|
||||
th = 2 * PI * k / 6
|
||||
mp = mp + revolve(prof, 28 * math.cos(th), 28 * math.sin(th))
|
||||
area = (PI * 1600 + 2 * PI * 40 * 8 + PI * 1600
|
||||
+ 6 * (revolve_area(prof) - 2 * PI * 64)) # boss lateral without its base disc r8; disc top loses it too
|
||||
write("52-circular-pattern-fillet",
|
||||
"disc r40 h8 + 6 x (boss r6 h10 with R2 concave base fillet as a solid of revolution, Pappus/Green "
|
||||
"on its (r,z) profile) at radius 28; disc top loses 6 discs r8",
|
||||
1, mp, area, [(-40, -40, 0), (40, 40, 18)], faces=21)
|
||||
|
||||
|
||||
def c53():
|
||||
cx, cy = 60.0, 10.0 / 3.0
|
||||
R = 45.0 - cy
|
||||
th0 = math.atan2(15 - cy, 100 - cx)
|
||||
sp = 17.875031
|
||||
mp = box(0, 0, 0, 120, 60, 5)
|
||||
for k in range(7):
|
||||
th = th0 + k * sp / R
|
||||
mp = mp - cyl(cx + R * math.cos(th), cy + R * math.sin(th), 3, 0, 5)
|
||||
area = 2 * (7200 - 7 * PI * 9) + 2 * (120 + 60) * 5 + 7 * 2 * PI * 3 * 5
|
||||
write("53-path-pattern",
|
||||
"plate 120x60x5 minus 7 through holes d6 at arc lengths k*17.875031 along the arc through "
|
||||
"(100,15),(60,45),(20,15) (centre (60,10/3), R 125/3), translated copies",
|
||||
1, mp, area, [(0, 0, 0), (120, 60, 5)], faces=13)
|
||||
|
||||
|
||||
def c54():
|
||||
# Mirrored + joined: x -30..30, y 0..40, h10, four R5 vertical corner rounds, holes d8 at (+-15,20).
|
||||
r = 5.0
|
||||
outer = [
|
||||
L((-25, 0), (25, 0)), A((25, 5), r, -PI / 2, 0), L((30, 5), (30, 35)), A((25, 35), r, 0, PI / 2),
|
||||
L((25, 40), (-25, 40)), A((-25, 35), r, PI / 2, PI), L((-30, 35), (-30, 5)), A((-25, 5), r, PI, 1.5 * PI),
|
||||
]
|
||||
reg = region([outer, circle_loop((15, 20), 4, False), circle_loop((-15, 20), 4, False)])
|
||||
mp = extrude(reg, 0, 10)
|
||||
area = extrude_area(reg, 10)
|
||||
write("54-mirror-body-fillets",
|
||||
"prism h10 of the 60x40 rectangle with four R5 corner rounds and two d8 holes at (+-15,20): "
|
||||
"V = A h, area = 2A + perimeter*h",
|
||||
1, mp, area, [(-30, 0, 0), (30, 40, 10)], faces=12)
|
||||
|
||||
|
||||
def c55():
|
||||
r, d, half = 3.4, 12.0, math.radians(59)
|
||||
tip = r / math.tan(half)
|
||||
mp = (box(0, 0, 0, 40, 30, 15) - cyl(20, 15, r, 15 - d, 15)
|
||||
- MP.at(PI * r * r * tip / 3, [20, 15, 15 - d - tip / 4]))
|
||||
area = (2 * 1200 + 2 * (40 * 15 + 30 * 15) - PI * r * r
|
||||
+ 2 * PI * r * d + PI * r * (r / math.sin(half)))
|
||||
write("55-tapped-hole-cosmetic",
|
||||
"plate 40x30x15 minus d6.8 cylinder 12 deep (to the shoulder) and 118 deg drill point cone "
|
||||
"(h = r/tan59, V = pi r^2 h/3, lateral pi r * r/sin59); cosmetic thread adds no geometry",
|
||||
1, mp, area, [(0, 0, 0), (40, 30, 15)], faces=8)
|
||||
|
||||
|
||||
def c57():
|
||||
r, rc, dc, T = 2.75, 5.0, 5.5, 10.0
|
||||
mp = box(0, 0, 0, 80, 50, T)
|
||||
for x in (15, 40, 65):
|
||||
for y in (15, 35):
|
||||
mp = mp - cyl(x, y, rc, T - dc, T) - cyl(x, y, r, 0, T - dc)
|
||||
area = (2 * 4000 + 2 * (80 + 50) * T
|
||||
+ 6 * (-PI * rc * rc - PI * r * r + 2 * PI * rc * dc + PI * (rc * rc - r * r) + 2 * PI * r * (T - dc)))
|
||||
write("57-hole-pattern-sketch-points",
|
||||
"plate 80x50x10 minus 6 counterbored through holes (cb d10 x 5.5, hole d5.5) at x 15/40/65, y 15/35",
|
||||
1, mp, area, [(0, 0, 0), (80, 50, 10)], faces=24)
|
||||
|
||||
|
||||
def c62():
|
||||
mp = box(0, 0, 0, 60, 50, 5)
|
||||
for k in range(15):
|
||||
x0 = 2 + 4 * k
|
||||
mp = mp + box(x0, 0, 5, x0 + 1.5, 50, 30)
|
||||
area = (3000 + 2 * (60 * 5 + 50 * 5) + 3000 - 15 * 1.5 * 50
|
||||
+ 15 * (2 * 50 * 25 + 2 * 1.5 * 25 + 1.5 * 50))
|
||||
write("62-heat-sink",
|
||||
"base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends",
|
||||
1, mp, area, [(0, 0, 0), (60, 50, 30)], faces=66,
|
||||
note="faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces")
|
||||
|
||||
|
||||
def c64():
|
||||
# Solved sketch: four R12 arcs on chord 20 (sagitta 12 - sqrt 44) alternating up/down from
|
||||
# (0,0) to (80,0) tangent in a chain, closed by (80,0)-(80,-20)-(0,-20)-(0,0).
|
||||
R = 12.0
|
||||
h = math.sqrt(R * R - 100) # centre distance from the chord
|
||||
al = math.atan2(h, 10) # half-angle complement
|
||||
loop = [
|
||||
A((10, -h), R, PI - al, al), # a1: (0,0) -> (20,0), bulging up (cw)
|
||||
A((30, h), R, PI + al, 2 * PI - al), # a2: (20,0) -> (40,0), bulging down (ccw)
|
||||
A((50, -h), R, PI - al, al), # a3
|
||||
A((70, h), R, PI + al, 2 * PI - al), # a4
|
||||
L((80, 0), (80, -20)), L((80, -20), (0, -20)), L((0, -20), (0, 0)),
|
||||
]
|
||||
reg = region([reverse(loop)]) # traced clockwise above; reverse to CCW
|
||||
mp = extrude(reg, 0, 3)
|
||||
area = extrude_area(reg, 3)
|
||||
write("64-sketch-tangent-arc-chain",
|
||||
"solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating "
|
||||
"up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid",
|
||||
1, mp, area, [(0, -20, 0), (80, R - h, 3)], faces=9)
|
||||
|
||||
|
||||
def c66():
|
||||
r1, r2, dctr = 12.0, 8.0, 60.0
|
||||
al = math.acos((r1 - r2) / dctr) # direction of the outer tangent normals: +-al
|
||||
outer = [
|
||||
A((0, 0), r1, al, 2 * PI - al),
|
||||
L((r1 * math.cos(al), -r1 * math.sin(al)), (dctr + r2 * math.cos(al), -r2 * math.sin(al))),
|
||||
A((dctr, 0), r2, -al, al),
|
||||
L((dctr + r2 * math.cos(al), r2 * math.sin(al)), (r1 * math.cos(al), r1 * math.sin(al))),
|
||||
]
|
||||
holes = [circle_loop((0, 0), 6, False), circle_loop((60, 0), 4, False),
|
||||
circle_loop((30, 4), 2, False), circle_loop((30, -4), 2, False)]
|
||||
reg = region([outer] + holes)
|
||||
mp = extrude(reg, 0, 6)
|
||||
area = extrude_area(reg, 6)
|
||||
write("66-sketch-linkage",
|
||||
"solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) "
|
||||
"minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6",
|
||||
1, mp, area, [(-12, -12, 0), (68, 12, 6)], faces=10)
|
||||
|
||||
|
||||
CASES = [c32, c33, c38, c40, c43, c44, c45, c46, c48, c49, c50, c52, c53, c54, c55, c57, c62, c64, c66]
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fn in CASES:
|
||||
fn()
|
||||
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Closed-form expected values for the sheet-metal oracle cases (67..76).
|
||||
|
||||
Run from the worktree root: python3 tools/oracle-analytic/cases_sheet_metal.py
|
||||
It (re)writes tests/oracle/<case>/expected.json with "source": "analytic".
|
||||
|
||||
Folded sheet metal is built here from first principles as flat plates (prisms of thickness t)
|
||||
and bends (annular-sector prisms: inner radius R, outer R + t, angle theta), joined face to
|
||||
face. The conventions that place them are SolidCraft's documented sheet-metal semantics
|
||||
(crates/doc/src/sheet.rs module docs, docs/book/src/guide/sheet-metal.md):
|
||||
|
||||
* Steel (mm) rule: t = 2.5, R = Thickness, K = 0.44, corner gap = Thickness.
|
||||
* Edge flange, height datum "outer faces", bend position "inside": the virtual sharp (where
|
||||
the outer faces would meet) lies on the picked edge's outer corner; the parent panel is
|
||||
trimmed back by the setback (R + t) tan(theta/2); the flange's outer face measures
|
||||
`height` from the virtual sharp (along the flange for a non-90 deg angle); the flat leg
|
||||
past the bend is height - (R + t) tan(theta/2).
|
||||
* Several flanges in one command meeting at a 90 deg corner: each bend stops at the trimmed
|
||||
base corner; each panel runs on past its bend by (R + t) - (t + gap/sqrt 2) so the gap
|
||||
between the panels' inner corners is the rule gap (corner open, no corner fill).
|
||||
* A later flange on an adjacent edge trims the base under the earlier bend; that bend stops
|
||||
at the trimmed base corner and the earlier panel runs on alone (an open corner relief).
|
||||
* Flat hem: 180 deg bend with inner radius hem_gap = 0.01 mm toward the face it starts from
|
||||
(inside), length measured from the outer face of the hem bend, leg = L - (gap + t).
|
||||
* Contour flange: the sketch lines are the sheet face on the side away from the material
|
||||
(material right of travel); a corner that bends toward the material has the sketch
|
||||
corner as the outer virtual sharp (setback (R+t)tan), the other as inner (setback R tan).
|
||||
* Unfold/refold: bend zone of flat width BA = theta (R + K t); a point s into the zone maps
|
||||
to angle s / (R + K t) on the bend; past the zone, flat distance maps 1:1 to the panel.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
|
||||
OUT = os.path.join(ROOT, "tests", "oracle")
|
||||
|
||||
PI = math.pi
|
||||
STEEL = dict(t=2.5, R=2.5, K=0.44, gap=2.5, hem_gap=0.01)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 2D section pieces
|
||||
class Poly:
|
||||
def __init__(self, pts):
|
||||
self.pts = pts
|
||||
|
||||
def props(self):
|
||||
a = cx = cy = per = 0.0
|
||||
n = len(self.pts)
|
||||
for i in range(n):
|
||||
x0, y0 = self.pts[i]
|
||||
x1, y1 = self.pts[(i + 1) % n]
|
||||
c = x0 * y1 - x1 * y0
|
||||
a += c
|
||||
cx += (x0 + x1) * c
|
||||
cy += (y0 + y1) * c
|
||||
per += math.hypot(x1 - x0, y1 - y0)
|
||||
a *= 0.5
|
||||
return abs(a), (cx / (6 * a), cy / (6 * a)), per
|
||||
|
||||
def box(self):
|
||||
xs = [p[0] for p in self.pts]
|
||||
ys = [p[1] for p in self.pts]
|
||||
return (min(xs), min(ys)), (max(xs), max(ys))
|
||||
|
||||
|
||||
def Rect(u0, u1, v0, v1):
|
||||
return Poly([(u0, v0), (u1, v0), (u1, v1), (u0, v1)])
|
||||
|
||||
|
||||
class Sector:
|
||||
"""Annular sector: centre c, radii r0 < r1, angles a0 < a1 (radians, CCW)."""
|
||||
|
||||
def __init__(self, c, r0, r1, a0, a1):
|
||||
self.c, self.r0, self.r1, self.a0, self.a1 = c, r0, r1, a0, a1
|
||||
|
||||
def props(self):
|
||||
d = self.a1 - self.a0
|
||||
r0, r1 = self.r0, self.r1
|
||||
a = d / 2 * (r1 * r1 - r0 * r0)
|
||||
rho = (2.0 / 3.0) * (r1 ** 3 - r0 ** 3) / (r1 * r1 - r0 * r0) * math.sin(d / 2) / (d / 2)
|
||||
m = (self.a0 + self.a1) / 2
|
||||
cen = (self.c[0] + rho * math.cos(m), self.c[1] + rho * math.sin(m))
|
||||
per = d * (r0 + r1) + 2 * (r1 - r0)
|
||||
return a, cen, per
|
||||
|
||||
def box(self):
|
||||
pts = []
|
||||
for r in (self.r0, self.r1):
|
||||
for ang in (self.a0, self.a1):
|
||||
pts.append((r * math.cos(ang), r * math.sin(ang)))
|
||||
k = math.ceil(self.a0 / (PI / 2) - 1e-12)
|
||||
while k * PI / 2 <= self.a1 + 1e-12:
|
||||
ang = k * PI / 2
|
||||
pts.append((self.r1 * math.cos(ang), self.r1 * math.sin(ang)))
|
||||
k += 1
|
||||
xs = [p[0] + self.c[0] for p in pts]
|
||||
ys = [p[1] + self.c[1] for p in pts]
|
||||
return (min(xs), min(ys)), (max(xs), max(ys))
|
||||
|
||||
|
||||
class Section:
|
||||
"""Union of pieces meeting face to face; `joints` = total length of shared interfaces."""
|
||||
|
||||
def __init__(self, pieces, joints):
|
||||
self.pieces, self.joints = pieces, joints
|
||||
|
||||
def props(self):
|
||||
A = Su = Sv = P = 0.0
|
||||
for p in self.pieces:
|
||||
a, (cu, cv), per = p.props()
|
||||
A += a
|
||||
Su += a * cu
|
||||
Sv += a * cv
|
||||
P += per
|
||||
return A, (Su / A, Sv / A), P - 2 * self.joints
|
||||
|
||||
def box(self):
|
||||
bs = [p.box() for p in self.pieces]
|
||||
return (min(b[0][0] for b in bs), min(b[0][1] for b in bs)), (max(b[1][0] for b in bs), max(b[1][1] for b in bs))
|
||||
|
||||
|
||||
def extrusion(segments, to_world):
|
||||
"""A solid swept along one axis w: segments = [(w0, w1, Section)], contiguous, with each
|
||||
section nested in its neighbours (so a step's cap area is the area difference).
|
||||
to_world(u, v, w) -> (x, y, z). Returns (V, A, com, bbox)."""
|
||||
V = A = 0.0
|
||||
S = [0.0, 0.0, 0.0]
|
||||
lo = [1e300] * 3
|
||||
hi = [-1e300] * 3
|
||||
areas = []
|
||||
for w0, w1, sec in segments:
|
||||
a, (cu, cv), per = sec.props()
|
||||
L = w1 - w0
|
||||
V += a * L
|
||||
A += per * L
|
||||
c = to_world(cu, cv, (w0 + w1) / 2)
|
||||
for i in range(3):
|
||||
S[i] += a * L * c[i]
|
||||
(u0, v0), (u1, v1) = sec.box()
|
||||
for u in (u0, u1):
|
||||
for v in (v0, v1):
|
||||
for w in (w0, w1):
|
||||
p = to_world(u, v, w)
|
||||
for i in range(3):
|
||||
lo[i] = min(lo[i], p[i])
|
||||
hi[i] = max(hi[i], p[i])
|
||||
areas.append(a)
|
||||
A += areas[0] + areas[-1]
|
||||
for i in range(len(areas) - 1):
|
||||
A += abs(areas[i] - areas[i + 1])
|
||||
return V, A, [s / V for s in S], (lo, hi)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 3D prisms (corner cases)
|
||||
def prism(sec_piece, w0, w1, to_world):
|
||||
a, (cu, cv), per = sec_piece.props()
|
||||
L = w1 - w0
|
||||
c = to_world(cu, cv, (w0 + w1) / 2)
|
||||
(u0, v0), (u1, v1) = sec_piece.box()
|
||||
pts = [to_world(u, v, w) for u in (u0, u1) for v in (v0, v1) for w in (w0, w1)]
|
||||
lo = [min(p[i] for p in pts) for i in range(3)]
|
||||
hi = [max(p[i] for p in pts) for i in range(3)]
|
||||
return dict(V=a * L, A=2 * a + per * L, c=c, lo=lo, hi=hi)
|
||||
|
||||
|
||||
def assemble(prisms, contacts, extra_V=0.0, extra_A=0.0, extra_S=(0.0, 0.0, 0.0)):
|
||||
V = sum(p["V"] for p in prisms) + extra_V
|
||||
A = sum(p["A"] for p in prisms) - 2 * sum(contacts) + extra_A
|
||||
com = [(sum(p["V"] * p["c"][i] for p in prisms) + extra_S[i]) / V for i in range(3)]
|
||||
lo = [min(p["lo"][i] for p in prisms) for i in range(3)]
|
||||
hi = [max(p["hi"][i] for p in prisms) for i in range(3)]
|
||||
return V, A, com, (lo, hi)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- shared shapes
|
||||
def yz(u, v, w): # section in (y, z), swept along x
|
||||
return (w, u, v)
|
||||
|
||||
|
||||
def xz(u, v, w): # section in (x, z), swept along y
|
||||
return (u, w, v)
|
||||
|
||||
|
||||
def edge_flange_section(width_y, height, theta, rv, base_far=None):
|
||||
"""Base plate y in [0, width_y] with an edge flange on its y = 0 top edge (bend up, toward
|
||||
-y), as YZ section pieces. Returns (pieces, joints, info)."""
|
||||
t, R = rv["t"], rv["R"]
|
||||
sb = (R + t) * math.tan(theta / 2)
|
||||
leg = height - sb
|
||||
zc = t + R
|
||||
base = Rect(sb, width_y, 0.0, t)
|
||||
bend = Sector((sb, zc), R, R + t, -PI / 2 - theta, -PI / 2)
|
||||
# Panel: flat (s along -y from the bend line, z in [0, t]) turned by theta about the axis.
|
||||
c, s = math.cos(theta), math.sin(theta)
|
||||
|
||||
def place(sv, z):
|
||||
uu, ww = sv, z - zc # uu outward (-y) from the axis, ww from the axis
|
||||
ru, rw = uu * c - ww * s, uu * s + ww * c
|
||||
return (sb - ru, zc + rw)
|
||||
|
||||
panel = Poly([place(0, 0), place(0, t), place(leg, t), place(leg, 0)])
|
||||
return [base, bend, panel], 2 * t, dict(setback=sb, leg=leg)
|
||||
|
||||
|
||||
# Cases SolidCraft fails today, with the issue that tracks each (cargo xtask oracle reports them
|
||||
# as known failures and fails once they pass, so the mark is removed with the fix).
|
||||
KNOWN_FAILURES = {"75-sm-bracket-holes": "#296"}
|
||||
|
||||
|
||||
def write(case, d):
|
||||
if case in KNOWN_FAILURES:
|
||||
d["known_failure"] = KNOWN_FAILURES[case]
|
||||
path = os.path.join(OUT, case, "expected.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(d, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def clean(v):
|
||||
"""Drop float noise (e.g. 1e-15 from cos(pi/2)) on bbox coordinates."""
|
||||
r = round(v, 9)
|
||||
return r if abs(v - r) < 1e-12 else v
|
||||
|
||||
|
||||
def record(V, A, com, bb, method, faces=None, note=None, body_count=1):
|
||||
bb = ([clean(v) + 0.0 for v in bb[0]], [clean(v) + 0.0 for v in bb[1]])
|
||||
d = {
|
||||
"source": "analytic",
|
||||
"method": method,
|
||||
"body_count": body_count,
|
||||
"volume_mm3": V,
|
||||
"area_mm2": A,
|
||||
"center_of_mass": com,
|
||||
"bbox": {"min": bb[0], "max": bb[1]},
|
||||
}
|
||||
if faces is not None:
|
||||
d["faces"] = faces
|
||||
if note:
|
||||
d["note"] = note
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cases
|
||||
def c67():
|
||||
t = STEEL["t"]
|
||||
sec = Section([Rect(0, 60, 0, t)], 0)
|
||||
V, A, com, bb = extrusion([(0, 100, sec)], yz)
|
||||
return record(V, A, com, bb, "plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca)", faces=6)
|
||||
|
||||
|
||||
def single_flange(width_x, width_y, height, theta, rv, method_extra):
|
||||
pieces, joints, info = edge_flange_section(width_y, height, theta, rv)
|
||||
V, A, com, bb = extrusion([(0, width_x, Section(pieces, joints))], yz)
|
||||
return V, A, com, bb, info
|
||||
|
||||
|
||||
def c68():
|
||||
rv = STEEL
|
||||
V, A, com, bb, info = single_flange(100, 60, 20, PI / 2, rv, "")
|
||||
m = ("extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); "
|
||||
"flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: "
|
||||
"outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec")
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges)")
|
||||
|
||||
|
||||
def c70():
|
||||
rv = STEEL
|
||||
th = PI / 4
|
||||
V, A, com, bb, info = single_flange(100, 60, 15, th, rv, "")
|
||||
m = ("as 68 with a 45 deg bend: setback (R+t)tan22.5 = %.6f, base y %.6f..60, bend annulus 2.5..5 over 45 deg about "
|
||||
"(y %.6f, z 5), flat leg 15 - setback = %.6f turned 45 deg; extrusion along x 100" % (info["setback"], info["setback"], info["setback"], info["leg"]))
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="SolidCraft convention: flange height (15) is measured along the flange's outer face from the virtual sharp at the original edge (y 0, z 0), so the outer top corner is at (y, z) = (-15 cos45, 15 sin45).")
|
||||
|
||||
|
||||
def u_channel(rv, wx, wy, h):
|
||||
t, R = rv["t"], rv["R"]
|
||||
sb = R + t
|
||||
zc = t + R
|
||||
leg = h - sb
|
||||
pieces = [
|
||||
Rect(sb, wy - sb, 0, t),
|
||||
Sector((sb, zc), R, R + t, -PI, -PI / 2),
|
||||
Rect(0, t, zc, zc + leg),
|
||||
Sector((wy - sb, zc), R, R + t, -PI / 2, 0),
|
||||
Rect(wy - t, wy, zc, zc + leg),
|
||||
]
|
||||
return pieces, sb, leg
|
||||
|
||||
|
||||
def c71():
|
||||
rv = dict(t=1.5, R=3.0, K=0.33, gap=1.5)
|
||||
pieces, sb, leg = u_channel(rv, 80, 50, 20)
|
||||
V, A, com, bb = extrusion([(0, 80, Section(pieces, 4 * rv["t"]))], yz)
|
||||
m = ("U channel, rule t 1.5, R 3, K 0.33 (K unused folded): base y 4.5..45.5 x 1.5; two 90 deg bend annuli R 3..4.5; "
|
||||
"flanges y 0..1.5 and 48.5..50, z 4.5..20; extrusion along x 80")
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="the rule edit applies to the whole sheet body (base built before it is 1.5 thick too, as the recipe's edge picks at z = 1.5 state)")
|
||||
|
||||
|
||||
def c72():
|
||||
rv = STEEL
|
||||
t, R = rv["t"], rv["R"]
|
||||
# Sketch lines (world XZ): (0,0)->(40,0)->(40,30)->(70,30), material right of travel (+z, then -x, then +z).
|
||||
pieces = [
|
||||
Rect(0, 40 - (R + t), 0, t), # x 0..35
|
||||
Sector((40 - (R + t), R + t), R, R + t, -PI / 2, 0), # centre (35, 5)
|
||||
Rect(40 - t, 40, R + t, 30 - R), # x 37.5..40, z 5..27.5
|
||||
Sector((40 + R, 30 - R), R, R + t, PI / 2, PI), # centre (42.5, 27.5)
|
||||
Rect(40 + R, 70, 30, 30 + t), # x 42.5..70, z 30..32.5
|
||||
]
|
||||
V, A, com, bb = extrusion([(0, 50, Section(pieces, 4 * t))], xz)
|
||||
m = ("contour flange, width 50 along +Y, XZ section: plate x 0..35 z 0..2.5; bend R 2.5..5 about (35, 5) (sketch corner = "
|
||||
"outer virtual sharp, setback R+t); wall x 37.5..40 z 5..27.5; bend R 2.5..5 about (42.5, 27.5) (sketch corner = inner "
|
||||
"virtual sharp, setback R); plate x 42.5..70 z 30..32.5")
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="SolidCraft convention: the sketch lines are the sheet face away from the material (material right of travel); thickness grows to the material side")
|
||||
|
||||
|
||||
def c73():
|
||||
rv = STEEL
|
||||
t, R, g = rv["t"], rv["R"], rv["hem_gap"]
|
||||
pieces, joints, info = edge_flange_section(60, 20, PI / 2, rv)
|
||||
L = 8.0
|
||||
leg = L - (g + t)
|
||||
hem_bend = Sector((t + g, 20.0), g, g + t, 0.0, PI)
|
||||
hem_panel = Rect(t + 2 * g, 2 * t + 2 * g, 20.0 - leg, 20.0)
|
||||
sec = Section(pieces + [hem_bend, hem_panel], joints + 2 * t)
|
||||
V, A, com, bb = extrusion([(0, 100, sec)], yz)
|
||||
m = ("68's part (height 20) plus a flat hem on the flange top edge: 180 deg bend, inner radius = hem gap 0.01, outer 2.51, "
|
||||
"about (y 2.51, z 20), folded toward +y (inside); hem leg 8 - (0.01 + 2.5) = 5.49: y 2.52..5.02, z 14.51..20; "
|
||||
"extrusion along x 100")
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="SolidCraft conventions: hem gap 0.01 mm (rule hem_gap), hem length measured to the outer face of the hem bend (top at z 22.51), hem folds over the flange's inner face.")
|
||||
|
||||
|
||||
def c74():
|
||||
rv = STEEL
|
||||
t, R, K = rv["t"], rv["R"], rv["K"]
|
||||
sb = R + t
|
||||
zc = t + R
|
||||
leg = 20 - sb
|
||||
BA = PI / 2 * (R + K * t)
|
||||
# Flat: base y 5..45; y=0 side bend zone y (5-BA)..5, panel y (5-BA-15)..(5-BA).
|
||||
# Slot y -12..10: base strip 5..10, whole bend, panel up to 12 - (BA - 5) from the bend.
|
||||
cut_panel = -(-12 - (sb - BA)) # flat distance past the bend zone
|
||||
zcut = zc + cut_panel
|
||||
full = Section([
|
||||
Rect(sb, 50 - sb, 0, t),
|
||||
Sector((sb, zc), R, R + t, -PI, -PI / 2),
|
||||
Rect(0, t, zc, zc + leg),
|
||||
Sector((50 - sb, zc), R, R + t, -PI / 2, 0),
|
||||
Rect(50 - t, 50, zc, zc + leg),
|
||||
], 4 * t)
|
||||
cut = Section([
|
||||
Rect(10, 50 - sb, 0, t),
|
||||
Sector((50 - sb, zc), R, R + t, -PI / 2, 0),
|
||||
Rect(50 - t, 50, zc, zc + leg),
|
||||
Rect(0, t, zcut, zc + leg),
|
||||
], 2 * t)
|
||||
V, A, com, bb = extrusion([(0, 35, full), (35, 45, cut), (45, 80, full)], yz)
|
||||
m = ("U channel 80x50 (t 2.5, R 2.5, base y 5..45, flanges z 5..20) with the flat-pattern slot x 35..45, y -12..10 refolded: "
|
||||
"BA = (pi/2)(R + K t) = %.9f, so the slot removes base y 5..10, the whole y=0 bend and the flange from z 5 to "
|
||||
"5 + (12 - (BA - 5)) = %.9f; x-segments 0..35 / 35..45 / 45..80" % (BA, zcut))
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="unfold/refold maps flat distance through the K-factor neutral layer (K 0.44)")
|
||||
|
||||
|
||||
def c75():
|
||||
rv = STEEL
|
||||
t, R = rv["t"], rv["R"]
|
||||
sb = R + t
|
||||
zc = t + R
|
||||
leg1, leg2 = 30 - sb, 15 - sb
|
||||
P = []
|
||||
P.append(prism(Rect(sb, 40, 0, t), 0, 80 - sb, yz)) # base x 0..75, y 5..40
|
||||
P.append(prism(Sector((sb, zc), R, R + t, -PI, -PI / 2), 0, 80 - sb, yz)) # bend 1, x 0..75
|
||||
P.append(prism(Rect(0, t, zc, zc + leg1), 0, 80, yz)) # flange 1, x 0..80
|
||||
P.append(prism(Sector((80 - sb, zc), R, R + t, -PI / 2, 0), sb, 40, xz)) # bend 2, y 5..40
|
||||
P.append(prism(Rect(80 - t, 80, zc, zc + leg2), sb, 40, xz)) # flange 2, y 5..40
|
||||
contacts = [(80 - sb) * t, (80 - sb) * t, (40 - sb) * t, (40 - sb) * t]
|
||||
r, n = 3.0, 2
|
||||
dV = -n * PI * r * r * t
|
||||
dA = n * (2 * PI * r * t - 2 * PI * r * r)
|
||||
dS = (-PI * r * r * t * (25 + 55), -n * PI * r * r * t * 20, -n * PI * r * r * t * t / 2)
|
||||
V, A, com, bb = assemble(P, contacts, dV, dA, dS)
|
||||
m = ("base x 0..75, y 5..40, z 0..2.5 (trimmed 5 by each flange's setback); bend 1 (R 2.5..5 about y 5, z 5) x 0..75; "
|
||||
"flange 1 y 0..2.5, z 5..30, x 0..80; bend 2 (R 2.5..5 about x 75, z 5) y 5..40; flange 2 x 77.5..80, z 5..15, "
|
||||
"y 5..40; minus two d6 through holes at (25,20), (55,20) in the base. A = sum of prism areas - 2 x 4 joint "
|
||||
"areas (75t, 75t, 35t, 35t) + hole walls - hole discs")
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="SolidCraft convention: the second flange trims the base under the first bend, so bend 1 stops at x 75 while flange 1's panel runs to x 80 (open corner relief, no fill).")
|
||||
|
||||
|
||||
def c69():
|
||||
rv = STEEL
|
||||
t, R, gap = rv["t"], rv["R"], rv["gap"]
|
||||
sb = R + t
|
||||
zc = t + R
|
||||
leg = 25 - sb
|
||||
ext = sb - (t + gap / math.sqrt(2))
|
||||
X, Y = 100.0, 60.0
|
||||
P = [prism(Rect(sb, Y - sb, 0, t), sb, X - sb, yz)] # base x 5..95, y 5..55
|
||||
# y = 0 and y = 60 flanges (swept along x)
|
||||
P.append(prism(Sector((sb, zc), R, R + t, -PI, -PI / 2), sb, X - sb, yz))
|
||||
P.append(prism(Rect(0, t, zc, zc + leg), sb - ext, X - sb + ext, yz))
|
||||
P.append(prism(Sector((Y - sb, zc), R, R + t, -PI / 2, 0), sb, X - sb, yz))
|
||||
P.append(prism(Rect(Y - t, Y, zc, zc + leg), sb - ext, X - sb + ext, yz))
|
||||
# x = 0 and x = 100 flanges (swept along y)
|
||||
P.append(prism(Sector((sb, zc), R, R + t, -PI, -PI / 2), sb, Y - sb, xz))
|
||||
P.append(prism(Rect(0, t, zc, zc + leg), sb - ext, Y - sb + ext, xz))
|
||||
P.append(prism(Sector((X - sb, zc), R, R + t, -PI / 2, 0), sb, Y - sb, xz))
|
||||
P.append(prism(Rect(X - t, X, zc, zc + leg), sb - ext, Y - sb + ext, xz))
|
||||
lx, ly = X - 2 * sb, Y - 2 * sb
|
||||
contacts = [lx * t] * 4 + [ly * t] * 4
|
||||
V, A, com, bb = assemble(P, contacts)
|
||||
m = ("tray: base 90 x 50 x 2.5 (each edge trimmed by the setback 5); four 90 deg bends R 2.5..5 along the trimmed edges "
|
||||
"(90, 50, 90, 50 long); four walls 2.5 thick, z 5..25, each running on past its bend by "
|
||||
"ext = (R+t) - (t + gap/sqrt2) = %.9f at both ends; open corners. A = sum of prisms - 2 x joints" % ext)
|
||||
return record(V, A, com, bb, m, faces=46,
|
||||
note="corner treatment is SolidCraft's convention (sheet.rs close_corners: panels extended until their inner corners are the rule gap = t apart; no corner fill, open corner relief below the walls)")
|
||||
|
||||
|
||||
def c76():
|
||||
rv = dict(t=3.0, R=3.0, K=0.44, gap=3.0)
|
||||
V, A, com, bb, info = single_flange(100, 60, 20, PI / 2, rv, "")
|
||||
m = ("final rule thickness 3 (bend radius = Thickness = 3): base y 6..60 x 3; bend annulus R 3..6 about (y 6, z 6); "
|
||||
"flange y 0..3, z 6..20; extrusion along x 100")
|
||||
return record(V, A, com, bb, m, faces=None,
|
||||
note="the rule's thickness edits regenerate the whole sheet body; only the final 3 mm state matters")
|
||||
|
||||
|
||||
CASES = {
|
||||
"67-sm-base-flange": c67,
|
||||
"68-sm-edge-flange": c68,
|
||||
"69-sm-tray": c69,
|
||||
"70-sm-flange-45": c70,
|
||||
"71-sm-custom-rule": c71,
|
||||
"72-sm-contour-flange": c72,
|
||||
"73-sm-hem": c73,
|
||||
"74-sm-unfold-refold": c74,
|
||||
"75-sm-bracket-holes": c75,
|
||||
"76-sm-thickness-variant": c76,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
for case, fn in CASES.items():
|
||||
d = fn()
|
||||
write(case, d)
|
||||
print(case, d["volume_mm3"], d["area_mm2"], [round(c, 6) for c in d["center_of_mass"]])
|
||||
Reference in new issue
Block a user