use alice_sdf::SdfNode;
use glam::Vec3;
use std::sync::Arc;
pub const PLA_ELASTIC_MODULUS_GPA: f32 = 3.5;
pub const PETG_ELASTIC_MODULUS_GPA: f32 = 2.2;
pub const PLA_YIELD_STRESS_MPA: f32 = 60.0;
pub const HINGE_CLEARANCE: f32 = 0.3;
pub const ANNULAR_BULGE_STANDARD_HEIGHT: f32 = 0.4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SnapFitCantileverSpec {
pub length: f32,
pub width: f32,
pub thickness: f32,
pub hook_height: f32,
pub hook_offset: f32,
}
impl SnapFitCantileverSpec {
pub const PLA_STANDARD: Self = Self {
length: 10.0,
width: 5.0,
thickness: 2.0,
hook_height: 0.5,
hook_offset: 1.5,
};
#[must_use]
pub fn peak_stress_mpa(&self, elastic_modulus_gpa: f32) -> f32 {
3.0 * elastic_modulus_gpa * 1000.0 * self.thickness * self.hook_height
/ (2.0 * self.length * self.length)
}
#[must_use]
pub fn is_safe_for_pla(&self) -> bool {
self.peak_stress_mpa(PLA_ELASTIC_MODULUS_GPA) < PLA_YIELD_STRESS_MPA / 2.0
}
}
#[must_use]
pub fn snap_fit_cantilever(spec: SnapFitCantileverSpec) -> SdfNode {
let beam = SdfNode::Box3d {
half_extents: Vec3::new(spec.length * 0.5, spec.thickness * 0.5, spec.width * 0.5),
};
let hook = SdfNode::Box3d {
half_extents: Vec3::new(
spec.hook_offset * 0.5,
spec.hook_height * 0.5,
spec.width * 0.5,
),
};
let hook_placed = SdfNode::Translate {
child: Arc::new(hook),
offset: Vec3::new(
spec.hook_offset.mul_add(-0.5, spec.length * 0.5),
spec.hook_height.mul_add(0.5, spec.thickness * 0.5),
0.0,
),
};
SdfNode::Union {
a: Arc::new(beam),
b: Arc::new(hook_placed),
}
}
#[must_use]
pub fn snap_fit_annular(
shaft_diameter: f32,
shaft_length: f32,
bulge_height: f32,
bulge_y_offset: f32,
) -> SdfNode {
let shaft = SdfNode::Cylinder {
radius: shaft_diameter * 0.5,
half_height: shaft_length * 0.5,
};
let bulge = SdfNode::Torus {
major_radius: bulge_height.mul_add(0.5, shaft_diameter * 0.5),
minor_radius: bulge_height * 0.5,
};
let bulge_placed = SdfNode::Translate {
child: Arc::new(bulge),
offset: Vec3::new(0.0, bulge_y_offset, 0.0),
};
SdfNode::Union {
a: Arc::new(shaft),
b: Arc::new(bulge_placed),
}
}
#[must_use]
pub fn slot(length: f32, width: f32, depth: f32) -> SdfNode {
let center_length = length - width;
let straight = SdfNode::Box3d {
half_extents: Vec3::new(center_length * 0.5, depth * 0.5, width * 0.5),
};
let end = SdfNode::Cylinder {
radius: width * 0.5,
half_height: depth * 0.5,
};
let left = SdfNode::Translate {
child: Arc::new(end.clone()),
offset: Vec3::new(-center_length * 0.5, 0.0, 0.0),
};
let right = SdfNode::Translate {
child: Arc::new(end),
offset: Vec3::new(center_length * 0.5, 0.0, 0.0),
};
SdfNode::Union {
a: Arc::new(SdfNode::Union {
a: Arc::new(straight),
b: Arc::new(left),
}),
b: Arc::new(right),
}
}
pub const T_SLOT_2020_OPENING_WIDTH: f32 = 6.0;
pub const T_SLOT_2020_OPENING_DEPTH: f32 = 5.0;
pub const T_SLOT_2020_INNER_WIDTH: f32 = 11.0;
pub const T_SLOT_2020_INNER_DEPTH: f32 = 6.0;
#[must_use]
pub fn t_slot_2020(length: f32) -> SdfNode {
let opening = SdfNode::Box3d {
half_extents: Vec3::new(
T_SLOT_2020_OPENING_DEPTH * 0.5,
length * 0.5,
T_SLOT_2020_OPENING_WIDTH * 0.5,
),
};
let inner = SdfNode::Box3d {
half_extents: Vec3::new(
T_SLOT_2020_INNER_DEPTH * 0.5,
length * 0.5,
T_SLOT_2020_INNER_WIDTH * 0.5,
),
};
let inner_placed = SdfNode::Translate {
child: Arc::new(inner),
offset: Vec3::new(
-(T_SLOT_2020_OPENING_DEPTH + T_SLOT_2020_INNER_DEPTH) * 0.5,
0.0,
0.0,
),
};
SdfNode::Union {
a: Arc::new(opening),
b: Arc::new(inner_placed),
}
}
pub const DOVETAIL_TAPER_DEG: f32 = 10.0;
#[must_use]
pub fn dovetail(base_width: f32, height: f32, depth: f32) -> SdfNode {
let taper_rad = DOVETAIL_TAPER_DEG.to_radians();
let (sin_t, cos_t) = alice_det_math::sin_cos(taper_rad);
let base = SdfNode::Box3d {
half_extents: Vec3::new(base_width * 0.5, height * 0.5, depth * 0.5),
};
let d = (sin_t * height).mul_add(-0.5, cos_t * base_width * 0.5);
let plane_left = SdfNode::Plane {
normal: Vec3::new(-cos_t, sin_t, 0.0),
distance: d,
};
let plane_right = SdfNode::Plane {
normal: Vec3::new(cos_t, sin_t, 0.0),
distance: d,
};
let step1 = SdfNode::Intersection {
a: Arc::new(base),
b: Arc::new(plane_left),
};
SdfNode::Intersection {
a: Arc::new(step1),
b: Arc::new(plane_right),
}
}
#[must_use]
pub fn pin_hinge_knuckle(pin_diameter: f32, knuckle_length: f32, knuckle_od: f32) -> SdfNode {
let barrel = SdfNode::Cylinder {
radius: knuckle_od * 0.5,
half_height: knuckle_length * 0.5,
};
let hole = SdfNode::Cylinder {
radius: f32::midpoint(pin_diameter, HINGE_CLEARANCE),
half_height: knuckle_length.mul_add(0.5, 5.0),
};
SdfNode::Subtraction {
a: Arc::new(barrel),
b: Arc::new(hole),
}
}
pub const JST_PH_PITCH: f32 = 2.0;
#[must_use]
pub fn jst_ph_slot(pins: u32) -> SdfNode {
let pins_f = f32::from(u8::try_from(pins.clamp(2, 12)).unwrap_or(12));
let width = JST_PH_PITCH * (pins_f + 1.0);
let depth = 4.5;
let height = 5.5;
SdfNode::Box3d {
half_extents: Vec3::new(width * 0.5, depth * 0.5, height * 0.5),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alice_sdf::eval;
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn snap_fit_pla_standard_stress_is_safe() {
let spec = SnapFitCantileverSpec::PLA_STANDARD;
let stress = spec.peak_stress_mpa(PLA_ELASTIC_MODULUS_GPA);
assert!(stress > 50.0 && stress < 55.0, "stress = {stress}");
}
#[test]
fn snap_fit_cantilever_returns_union_of_beam_and_hook() {
let node = snap_fit_cantilever(SnapFitCantileverSpec::PLA_STANDARD);
match &node {
SdfNode::Union { a, b } => {
assert!(matches!(&**a, SdfNode::Box3d { .. }));
assert!(matches!(&**b, SdfNode::Translate { .. }));
}
_ => panic!("expected Union"),
}
}
#[test]
fn snap_fit_stress_formula_matches_petg() {
let spec = SnapFitCantileverSpec::PLA_STANDARD;
let stress = spec.peak_stress_mpa(PETG_ELASTIC_MODULUS_GPA);
assert!(approx_eq(stress, 33.0));
}
#[test]
fn snap_fit_annular_bulge_creates_outer_ring() {
let node = snap_fit_annular(8.0, 20.0, ANNULAR_BULGE_STANDARD_HEIGHT, 7.0);
match &node {
SdfNode::Union { a, b } => {
match &**a {
SdfNode::Cylinder { radius, .. } => assert!(approx_eq(*radius, 4.0)),
_ => panic!("expected Cylinder for shaft"),
}
match &**b {
SdfNode::Translate { child, .. } => {
assert!(matches!(&**child, SdfNode::Torus { .. }));
}
_ => panic!("expected Translate for bulge"),
}
}
_ => panic!("expected Union"),
}
}
#[test]
fn slot_center_is_inside() {
let node = slot(20.0, 4.0, 6.0);
assert!(eval(&node, Vec3::ZERO) < 0.0, "slot center must be inside");
assert!(eval(&node, Vec3::new(8.0, 0.0, 0.0)) < 0.0);
}
#[test]
fn slot_outside_at_extremes() {
let node = slot(20.0, 4.0, 6.0);
assert!(eval(&node, Vec3::new(15.0, 0.0, 0.0)) > 0.0);
assert!(eval(&node, Vec3::new(0.0, 0.0, 10.0)) > 0.0);
}
#[test]
fn t_slot_2020_matches_misumi_openbuilds_spec() {
assert!(approx_eq(T_SLOT_2020_OPENING_WIDTH, 6.0));
assert!(approx_eq(T_SLOT_2020_INNER_WIDTH, 11.0));
let node = t_slot_2020(100.0);
assert!(eval(&node, Vec3::ZERO) < 0.0);
}
#[test]
fn t_slot_2020_inner_chamber_wider_than_opening() {
let node = t_slot_2020(100.0);
assert!(eval(&node, Vec3::new(0.0, 0.0, 2.8)) < 0.0); assert!(eval(&node, Vec3::new(0.0, 0.0, 3.5)) > 0.0); assert!(eval(&node, Vec3::new(-5.5, 0.0, 5.0)) < 0.0);
}
#[test]
fn dovetail_taper_reduces_top_width() {
let node = dovetail(10.0, 5.0, 20.0);
assert!(eval(&node, Vec3::new(0.0, -2.4, 0.0)) < 0.0);
assert!(eval(&node, Vec3::new(0.0, 2.4, 0.0)) < 0.0);
assert!(eval(&node, Vec3::new(4.5, 2.4, 0.0)) > 0.0);
assert!(eval(&node, Vec3::new(4.9, -2.4, 0.0)) < 0.0);
}
#[test]
fn pin_hinge_knuckle_has_axial_pin_hole() {
let node = pin_hinge_knuckle(3.0, 8.0, 8.0);
assert!(eval(&node, Vec3::ZERO) > 0.0);
assert!(eval(&node, Vec3::new(0.0, 5.0, 0.0)) > 0.0);
assert!(eval(&node, Vec3::new(5.0, 0.0, 0.0)) > 0.0);
assert!(eval(&node, Vec3::new(3.0, 0.0, 0.0)) < 0.0);
}
#[test]
fn all_joint_primitives_have_valid_evaluation() {
let nodes = [
snap_fit_cantilever(SnapFitCantileverSpec::PLA_STANDARD),
snap_fit_annular(8.0, 20.0, 0.4, 7.0),
slot(20.0, 4.0, 6.0),
t_slot_2020(100.0),
dovetail(10.0, 5.0, 20.0),
pin_hinge_knuckle(3.0, 8.0, 8.0),
];
for (i, node) in nodes.iter().enumerate() {
let d = eval(node, Vec3::new(0.1, 0.1, 0.1));
assert!(
d.is_finite(),
"primitive {i} produced non-finite SDF at (0.1,0.1,0.1): {d}"
);
}
}
#[test]
fn jst_ph_slot_4pin_width() {
let node = jst_ph_slot(4);
if let SdfNode::Box3d { half_extents } = node {
assert!(approx_eq(half_extents.x, 5.0), "4-pin width half = 5.0");
assert!(approx_eq(half_extents.y, 2.25), "depth half = 2.25 (4.5mm)");
assert!(
approx_eq(half_extents.z, 2.75),
"height half = 2.75 (5.5mm)"
);
} else {
panic!("expected Box3d");
}
}
#[test]
fn jst_ph_slot_2pin_min() {
let node = jst_ph_slot(2);
if let SdfNode::Box3d { half_extents } = node {
assert!(approx_eq(half_extents.x, 3.0), "2-pin width half = 3.0");
} else {
panic!("expected Box3d");
}
}
}