diff --git a/apps/solidcraft-cli/src/recipe.rs b/apps/solidcraft-cli/src/recipe.rs index 0282c41f..3180262e 100644 --- a/apps/solidcraft-cli/src/recipe.rs +++ b/apps/solidcraft-cli/src/recipe.rs @@ -517,13 +517,15 @@ pub fn to_script(recipe: &Value) -> Result { let mut p = json!({"edges": edge_points(f), "radius": f.get("radius"), "name": name}); // Constant radius (the default), chord length, or a radius varying along each edge // (`radius` at the end nearest `start`, `radius2` at the other, `mid` between, - // `smooth`); `fit` for a radius too large for the geometry. + // `smooth`), or a B-spline along each edge (`radius` its first control radius, + // `radii` the rest, `knots`); `fit` for a radius too large for the geometry. match f.get("type").and_then(Value::as_str) { Some("chord_length") => p["type"] = json!("chord"), Some("variable_radius") => p["type"] = json!("variable"), + Some("spline_radius") => p["type"] = json!("spline"), _ => {} } - for key in ["radius2", "start", "mid", "smooth", "fit"] { + for key in ["radius2", "start", "mid", "smooth", "fit", "radii", "knots"] { if let Some(v) = f.get(key) { p[key] = v.clone(); } diff --git a/docs/oracle.md b/docs/oracle.md index 131d9f51..93d9a74a 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -9,7 +9,7 @@ Where the expected values come from (the **Source** column): No expected value comes from a commercial CAD product. -**80 / 80 cases pass**; sources: 59 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet. +**81 / 81 cases pass**; sources: 60 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet. | Case | Source | Result | Surface distance mm (max / mean) | Method | Notes | |---|---|---|---|---|---| @@ -93,6 +93,7 @@ No expected value comes from a commercial CAD product. | 78-fillet-variable-to-zero | analytic | pass | | 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 | | | 79-fillet-chord-length | analytic | pass | | 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 | | | 80-fillet-taper-to-fit | analytic | pass | | 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): 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 3.0000, 2.4750, 1.8000. Sections are quarter rounds; V, moments and areas integrated along the edge | | +| 81-fillet-spline-radius | analytic | pass | | box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are quarter rounds; V, moments and areas integrated along the edge, exactly per knot span | | ## Not covered by a reference case diff --git a/tests/oracle/81-fillet-spline-radius/expected.json b/tests/oracle/81-fillet-spline-radius/expected.json new file mode 100644 index 00000000..392a2b5f --- /dev/null +++ b/tests/oracle/81-fillet-spline-radius/expected.json @@ -0,0 +1,25 @@ +{ + "source": "analytic", + "method": "box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are quarter rounds; V, moments and areas integrated along the edge, exactly per knot span", + "body_count": 1, + "volume_mm3": 23933.601078437176, + "area_mm2": 5150.5132169272065, + "center_of_mass": [ + 20.008680496725603, + 15.039806270911098, + 9.974065214901927 + ], + "bbox": { + "min": [ + 0.0, + 0.0, + 0.0 + ], + "max": [ + 40.0, + 30.0, + 20.0 + ] + }, + "faces": 7 +} diff --git a/tests/oracle/81-fillet-spline-radius/recipe.json b/tests/oracle/81-fillet-spline-radius/recipe.json new file mode 100644 index 00000000..375fce14 --- /dev/null +++ b/tests/oracle/81-fillet-spline-radius/recipe.json @@ -0,0 +1,204 @@ +{ + "id": "81-fillet-spline-radius", + "name": "Fillet with a B-spline radius", + "description": "40x30x20 box, the top front edge rounded with a radius following a clamped cubic B-spline along it: control radii 2, 5, 1.5, 4, 1, 3 mm from x = 0, knots 0, 0, 0, 0, 0.25, 0.6, 1, 1, 1, 1.", + "exercises": [ + "extrude", + "fillet", + "spline radius" + ], + "units": { + "length": "mm", + "angle": "deg", + "area": "mm2", + "volume": "mm3" + }, + "conventions": { + "world": "right-handed; Z up. XY/XZ/YZ are the origin planes.", + "sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.", + "point_refs": "'.start' / '.end' / '.center' reuse (share) that existing sketch point; 'at' is its position when referenced.", + "extrude_direction": "positive = along sketch plane normal unless stated", + "selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it." + }, + "parameters": [], + "features": [ + { + "op": "sketch", + "name": "Extrude1_sketch", + "plane": { + "ref": "XY", + "origin": [ + 0.0, + 0.0, + 0.0 + ], + "x_dir": [ + 1.0, + 0.0, + 0.0 + ], + "y_dir": [ + 0.0, + 1.0, + 0.0 + ], + "normal": [ + 0.0, + 0.0, + 1.0 + ] + }, + "entities": [ + { + "type": "line", + "start": { + "at": [ + 0, + 0 + ] + }, + "end": { + "at": [ + 40, + 0 + ] + }, + "id": "l1" + }, + { + "type": "line", + "start": { + "ref": "l1.end", + "at": [ + 40.0, + 0.0 + ] + }, + "end": { + "at": [ + 40, + 30 + ] + }, + "id": "l2" + }, + { + "type": "line", + "start": { + "ref": "l2.end", + "at": [ + 40.0, + 30.0 + ] + }, + "end": { + "at": [ + 0, + 30 + ] + }, + "id": "l3" + }, + { + "type": "line", + "start": { + "ref": "l3.end", + "at": [ + 0.0, + 30.0 + ] + }, + "end": { + "ref": "l1.start", + "at": [ + 0.0, + 0.0 + ] + }, + "id": "l4" + } + ], + "constraints": [], + "dimensions": [] + }, + { + "op": "extrude", + "name": "Extrude1", + "operation": "new", + "profiles": [ + { + "sketch": "Extrude1_sketch", + "loops": [ + { + "outer": true, + "curves": [ + "l1", + "l2", + "l3", + "l4" + ] + } + ] + } + ], + "extent": { + "type": "distance", + "distance": 20, + "direction": "positive" + }, + "result_bodies": [ + "Part" + ] + }, + { + "op": "fillet", + "name": "Fillet1", + "type": "spline_radius", + "radius": 2.0, + "radii": [ + 5.0, + 1.5, + 4.0, + 1.0, + 3.0 + ], + "knots": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.25, + 0.6, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "start": [ + 0.0, + 0.0, + 20.0 + ], + "edges": [ + { + "type": "line", + "start": [ + 0.0, + 0.0, + 20.0 + ], + "end": [ + 40.0, + 0.0, + 20.0 + ], + "point": [ + 20.0, + 0.0, + 20.0 + ] + } + ] + } + ] +} diff --git a/tools/oracle-analytic/README.md b/tools/oracle-analytic/README.md index 75155b20..e984bb50 100644 --- a/tools/oracle-analytic/README.md +++ b/tools/oracle-analytic/README.md @@ -11,7 +11,7 @@ plain Python 3 (standard library only) with one function per case. Running a scr 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 +python3 tools/oracle-analytic/cases_fillets.py # 77-81: variable, chord-length, tapered and spline fillets ``` Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp diff --git a/tools/oracle-analytic/cases_fillets.py b/tools/oracle-analytic/cases_fillets.py index 4dfe0925..807d4d02 100644 --- a/tools/oracle-analytic/cases_fillets.py +++ b/tools/oracle-analytic/cases_fillets.py @@ -1,6 +1,7 @@ #!/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). +(cases 77-81 of tests/oracle: variable, closing at a corner, chord length, taper to fit, a +B-spline radius). Standalone, Python 3 stdlib only (it reuses the helpers of cases_features.py). Run from the worktree root: @@ -96,6 +97,28 @@ def c79(): 1, box(0, 0, 0, 40, 30, 20) - cut, 5200 + da, [(0, 0, 0), (40, 30, 20)], faces=7) +def bspline(degree, knots, ctrl, length): + """A clamped B-spline radius in u = x / length (the kernel's `RadiusSpline`): r(x), its slope + dr/dx (de Boor on the span holding u, the slope from the differenced controls) and the x + pieces between distinct knots, on which r is one polynomial.""" + n = len(ctrl) + + def deboor(p, kn, c, u): + k = max(i for i in range(p, len(c)) if kn[i] <= u) if u < kn[len(c)] else len(c) - 1 + d = [c[j + k - p] for j in range(p + 1)] + for rr in range(1, p + 1): + for j in range(p, rr - 1, -1): + i = j + k - p + al = (u - kn[i]) / (kn[i + p + 1 - rr] - kn[i]) + d[j] = (1 - al) * d[j - 1] + al * d[j] + return d[p] + + dctrl = [degree * (ctrl[i + 1] - ctrl[i]) / (knots[i + degree + 1] - knots[i + 1]) for i in range(n - 1)] + breaks = sorted(set(knots)) + pieces = [(length * a, length * b) for a, b in zip(breaks, breaks[1:])] + return (lambda x: deboor(degree, knots, ctrl, x / length)), (lambda x: deboor(degree - 1, knots[1:-1], dctrl, x / length) / length), pieces + + # 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 @@ -103,32 +126,9 @@ 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)] + greville = [sum(knots[j + 1:j + 4]) / 3 for j in range(SPANS + 3)] 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 + return (*bspline(3, knots, ctrl, length), ctrl) def c80(): @@ -145,7 +145,19 @@ def c80(): 1, extrude(reg, 0, 15) - cut, extrude_area(reg, 15) + da, [(0, 0, 0), (40, 20, 15)], faces=7) -CASES = [c77, c78, c79, c80] +def c81(): + knots = [0.0, 0.0, 0.0, 0.0, 0.25, 0.6, 1.0, 1.0, 1.0, 1.0] + ctrl = [2.0, 5.0, 1.5, 4.0, 1.0, 3.0] + r, dr, pieces = bspline(3, knots, ctrl, 40.0) + cut, da = removed(r, dr, pieces, 20.0) + write("81-fillet-spline-radius", + "box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in " + "u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are " + "quarter rounds; V, moments and areas integrated along the edge, exactly per knot span", + 1, box(0, 0, 0, 40, 30, 20) - cut, 5200 + da, [(0, 0, 0), (40, 30, 20)], faces=7) + + +CASES = [c77, c78, c79, c80, c81] if __name__ == "__main__": for fn in CASES: