solidcraft-smlib: Brep::blend (per-edge sizes; round, G2, chord, chamfer) and Brep::fillet_variable

The Rust side of the fork's smlib_blend and smlib_fillet_variable, which the kernel's fillets and
chamfers need on SMLib: `Brep::blend` takes a size per picked edge and a `Blend` (circular round,
curvature-continuous round between the round's contacts, constant-chord round, equal-distance
chamfer); `Brep::fillet_variable` rounds one edge with a clamped B-spline radius law from the end
nearest a start point. Selection errors map as for the other picking operations.

SMLIB_REV moves to the fork commit that adds them (smlib-blends, on capi-solidcraft ffcf56d).

Tests: per-edge rounds and chamfers and the chord round against analytic volumes, the linear law
from either end, and refused laws and picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 08:03:11 -04:00
parent b900d6d469
commit 3fcb3ea5c6
5 changed files with 155 additions and 5 deletions
+1 -1
View File
@@ -1 +1 @@
e87e9b3bdc348b00de5238963f387bfa4ba337d6
d041e7f9679436a4ad3a9620861965dd41105a27
+73 -3
View File
@@ -1,7 +1,7 @@
//! The operations block of `capi/include/smlib.h` (`smlib_boolean`, `smlib_fillet`,
//! `smlib_chamfer`, `smlib_shell`, `smlib_offset_faces`, `smlib_thicken`,
//! `smlib_brep_solid_count`; the fork's `capi/src/smlib_ops.cpp`),
//! declared by hand. Part of the FFI boundary, like its parent: called only through
//! `smlib_chamfer`, `smlib_blend`, `smlib_fillet_variable`, `smlib_shell`, `smlib_offset_faces`,
//! `smlib_thicken`, `smlib_brep_solid_count`; the fork's `capi/src/smlib_ops.cpp`), declared by
//! hand. Part of the FFI boundary, like its parent: called only through
//! [`crate::ops`].
#![allow(unsafe_code)]
@@ -21,6 +21,11 @@ pub const BOOLEAN_UNION: i32 = 0;
pub const BOOLEAN_SUBTRACT: i32 = 1;
pub const BOOLEAN_INTERSECT: i32 = 2;
pub const BLEND_ROUND: i32 = 0;
pub const BLEND_CURVATURE: i32 = 1;
pub const BLEND_CHORD: i32 = 2;
pub const BLEND_CHAMFER: i32 = 3;
/// Which selection operation: the C functions share one signature.
#[derive(Clone, Copy)]
pub enum Pick {
@@ -59,6 +64,28 @@ unsafe extern "C" {
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_blend(
brep: *const RawBrep,
kind: i32,
points: *const f64,
sizes: *const f64,
n_points: i64,
tolerance: f64,
out: *mut *mut RawBrep,
out_failed_point: *mut i64,
) -> i32;
fn smlib_fillet_variable(
brep: *const RawBrep,
point: *const f64,
tolerance: f64,
start: *const f64,
degree: i32,
knots: *const f64,
n_knots: i64,
radii: *const f64,
n_radii: i64,
out: *mut *mut RawBrep,
) -> i32;
fn smlib_shell(
brep: *const RawBrep,
points: *const f64,
@@ -122,6 +149,49 @@ pub fn thicken(_: &Handle, _: f64, _: bool) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
/// `smlib_blend`: `sizes` holds one size per point (the caller checks the lengths match).
#[cfg(feature = "native")]
pub fn blend(kind: i32, b: &Handle, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<Handle, PickError> {
let n = i64::try_from(points.len()).map_err(|_| (ERR_ARG, None))?;
if sizes.len() != points.len() {
return Err((ERR_ARG, None));
}
let flat = points.as_ptr().cast::<f64>();
let mut failed = -1i64;
new_brep(|out| unsafe { smlib_blend(b.raw(), kind, flat, sizes.as_ptr(), n, tolerance, out, &mut failed) }).map_err(|s| {
let at = (s == ERR_PICK_NONE || s == ERR_PICK_AMBIGUOUS).then(|| usize::try_from(failed).ok()).flatten();
(s, at)
})
}
/// `smlib_fillet_variable`.
#[cfg(feature = "native")]
pub fn fillet_variable(
b: &Handle,
point: &[f64; 3],
tolerance: f64,
start: &[f64; 3],
degree: i32,
knots: &[f64],
radii: &[f64],
) -> Result<Handle, i32> {
let nk = i64::try_from(knots.len()).map_err(|_| ERR_ARG)?;
let nr = i64::try_from(radii.len()).map_err(|_| ERR_ARG)?;
new_brep(|out| unsafe {
smlib_fillet_variable(b.raw(), point.as_ptr(), tolerance, start.as_ptr(), degree, knots.as_ptr(), nk, radii.as_ptr(), nr, out)
})
}
#[cfg(not(feature = "native"))]
pub fn blend(_: i32, _: &Handle, _: &[[f64; 3]], _: &[f64], _: f64) -> Result<Handle, PickError> {
Err((NOT_BUILT, None))
}
#[cfg(not(feature = "native"))]
pub fn fillet_variable(_: &Handle, _: &[f64; 3], _: f64, _: &[f64; 3], _: i32, _: &[f64], _: &[f64]) -> Result<Handle, i32> {
Err(NOT_BUILT)
}
#[cfg(not(feature = "native"))]
pub fn boolean(_: &Handle, _: &Handle, _: i32) -> Result<Handle, i32> {
Err(NOT_BUILT)
+1 -1
View File
@@ -28,7 +28,7 @@ pub mod sweeps;
pub use check::{CheckLevel, CheckReport};
pub use measure::{MEASURE_ACCURACY, MassProperties};
pub use ops::{BooleanOp, MAX_POINTS};
pub use ops::{Blend, BooleanOp, MAX_POINTS};
/// Why an SMLib call failed.
#[derive(Clone, Debug, PartialEq, Eq)]
+45
View File
@@ -17,6 +17,21 @@ pub enum BooleanOp {
Intersect,
}
/// What [`Brep::blend`] makes of each selected edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
/// A circular cross-section at a constant radius ([`Brep::fillet`]).
Round,
/// A curvature-continuous (G2) cross-section between the contact lines of the round of
/// that radius.
Curvature,
/// A circular cross-section of constant width from contact line to contact line: the size
/// is that width.
Chord,
/// An equal-distance chamfer ([`Brep::chamfer`]): the size is the setback along each face.
Chamfer,
}
/// Most points one selection may hold (the C API's limit).
pub const MAX_POINTS: usize = 4096;
@@ -65,6 +80,36 @@ impl Brep {
self.pick_op(ffi::ops::Pick::Chamfer, edges, tolerance, distance, false)
}
/// The edges nearest to `edges` (each within `tolerance`), with their tangent chains, blended
/// as `kind`, edge i (and its chain) at `sizes[i]`.
pub fn blend(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<Brep> {
init()?;
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
return Err(Error::Argument);
}
let kind = match kind {
Blend::Round => ffi::ops::BLEND_ROUND,
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
Blend::Chord => ffi::ops::BLEND_CHORD,
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
};
ffi::ops::blend(kind, &self.0, edges, sizes, tolerance).map(Brep).map_err(pick_error)
}
/// The one edge nearest to `edge` (within `tolerance`, no tangent chain) rounded with a radius
/// that varies along it: a clamped B-spline of `degree` (1 to 3) with `knots` and control
/// `radii` over the position along the edge, 0 at its end nearest `start` and 1 at the other
/// (the edge curve's parameter scaled to 0..1).
pub fn fillet_variable(&self, edge: [f64; 3], tolerance: f64, start: [f64; 3], degree: usize, knots: &[f64], radii: &[f64]) -> Result<Brep> {
init()?;
let degree = i32::try_from(degree).map_err(|_| Error::Argument)?;
ffi::ops::fillet_variable(&self.0, &edge, tolerance, &start, degree, knots, radii).map(Brep).map_err(|s| match s {
ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 },
ffi::ops::ERR_PICK_AMBIGUOUS => Error::Ambiguous { point: 0 },
s => error(s),
})
}
/// Hollowed to a wall of `thickness` (negative: inside the original surface, positive:
/// outside), open at the faces nearest to `open_faces` (each within `tolerance`); none gives
/// a closed hollow body. An inward wall that leaves no room inside is [`Error::NoRoom`].
+35
View File
@@ -240,3 +240,38 @@ fn boolean_history_through_tagged_operands() {
// The 8 edges where the slot's walls and floor meet the top, front and back are new.
assert_eq!(h.edges.iter().filter(|e| e.origins.is_empty()).count(), 8, "{h:?}");
}
/// `blend` gives each picked edge its own size: two parallel top edges of a 20 mm cube rounded at
/// 2 and 3 lose (1 - pi/4) r^2 L each, chamfered r^2 L / 2 each; a chord of 2 sqrt 2 on a right
/// angle is the round of radius 2.
#[test]
fn blends_take_a_size_per_edge() {
let c = cube(20.0);
let tops = [[10.0, 20.0, 20.0], [10.0, 0.0, 20.0]];
let q = 1.0 - std::f64::consts::FRAC_PI_4;
let r = c.blend(Blend::Round, &tops, &[2.0, 3.0], 0.1).unwrap();
shot(&r, "blend-round-2-3");
assert_close(volume(&r), 8000.0 - q * 13.0 * 20.0, 1e-6, "rounds 2 and 3");
let ch = c.blend(Blend::Chamfer, &tops, &[2.0, 3.0], 0.1).unwrap();
shot(&ch, "blend-chamfer-2-3");
assert_close(volume(&ch), 8000.0 - 0.5 * 13.0 * 20.0, 1e-6, "chamfers 2 and 3");
let chord = c.blend(Blend::Chord, &tops[..1], &[2.0 * 2f64.sqrt()], 0.1).unwrap();
assert_close(volume(&chord), 8000.0 - q * 4.0 * 20.0, 2e-6, "chord");
assert!(c.blend(Blend::Round, &tops, &[2.0], 0.1).is_err());
}
/// A radius running linearly from 3 to 1 along a cube edge: (1 - pi/4) L (r0² + r0 r1 + r1²) / 3
/// taken away, whichever end is the start.
#[test]
fn a_variable_round_follows_its_law() {
let c = cube(20.0);
let q = 1.0 - std::f64::consts::FRAC_PI_4;
let knots = [0.0, 0.0, 1.0, 1.0];
let r = c.fillet_variable([10.0, 20.0, 20.0], 0.1, [0.0, 20.0, 20.0], 1, &knots, &[3.0, 1.0]).unwrap();
shot(&r, "variable-3-1");
assert_close(volume(&r), 8000.0 - q * 20.0 * 13.0 / 3.0, 1e-5, "linear law");
let back = c.fillet_variable([10.0, 20.0, 20.0], 0.1, [20.0, 20.0, 20.0], 1, &knots, &[1.0, 3.0]).unwrap();
assert_close(volume(&back), volume(&r), 1e-7, "reversed");
assert!(c.fillet_variable([10.0, 20.0, 20.0], 0.1, [0.0, 20.0, 20.0], 1, &[0.0, 0.5, 1.0, 1.0], &[3.0, 1.0]).is_err());
assert!(matches!(c.fillet_variable([90.0, 90.0, 90.0], 0.1, [0.0; 3], 1, &knots, &[3.0, 1.0]), Err(Error::NoMatch { .. })));
}