use alice_sdf::SdfNode;
use glam::Vec3;
use std::sync::Arc;
pub const CLEARANCE_H2D_FDM: f32 = 0.2;
pub const DEFAULT_ACCURACY: f32 = 0.1;
pub const COUNTERSUNK_TAPER_ANGLE_DEG: f32 = 90.0;
pub const HEAT_SET_SINK_MARGIN: f32 = 0.3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetricSize {
M2,
M2_5,
M3,
M4,
M5,
M6,
M8,
}
impl MetricSize {
#[must_use]
pub const fn nominal_diameter(self) -> f32 {
match self {
Self::M2 => 2.0,
Self::M2_5 => 2.5,
Self::M3 => 3.0,
Self::M4 => 4.0,
Self::M5 => 5.0,
Self::M6 => 6.0,
Self::M8 => 8.0,
}
}
#[must_use]
pub const fn head_diameter_socket(self) -> f32 {
match self {
Self::M2 => 3.8,
Self::M2_5 => 4.5,
Self::M3 => 5.5,
Self::M4 => 7.0,
Self::M5 => 8.5,
Self::M6 => 10.0,
Self::M8 => 13.0,
}
}
#[must_use]
pub const fn head_height_socket(self) -> f32 {
match self {
Self::M2 => 2.0,
Self::M2_5 => 2.5,
Self::M3 => 3.0,
Self::M4 => 4.0,
Self::M5 => 5.0,
Self::M6 => 6.0,
Self::M8 => 8.0,
}
}
#[must_use]
pub const fn head_diameter_countersunk(self) -> f32 {
match self {
Self::M2 => 4.0,
Self::M2_5 => 4.5,
Self::M3 => 6.0,
Self::M4 => 8.0,
Self::M5 => 10.0,
Self::M6 => 12.0,
Self::M8 => 16.0,
}
}
#[must_use]
pub const fn heat_set_insert_diameter(self) -> f32 {
match self {
Self::M2 => 3.2,
Self::M2_5 => 3.6,
Self::M3 => 4.0,
Self::M4 => 5.6,
Self::M5 => 6.4,
Self::M6 => 8.0,
Self::M8 => 10.3,
}
}
#[must_use]
pub const fn heat_set_insert_depth(self) -> f32 {
match self {
Self::M2 => 3.0,
Self::M2_5 => 3.3,
Self::M3 => 3.8,
Self::M4 | Self::M5 => 5.7,
Self::M6 => 7.9,
Self::M8 => 10.0,
}
}
#[must_use]
pub fn from_f32_snap(nominal: f32) -> Self {
Self::try_from_f32(nominal).unwrap_or(Self::M2)
}
#[must_use]
pub fn try_from_f32(nominal: f32) -> Option<Self> {
if !nominal.is_finite() {
return None;
}
let candidates = [
(2.0_f32, Self::M2),
(2.5, Self::M2_5),
(3.0, Self::M3),
(4.0, Self::M4),
(5.0, Self::M5),
(6.0, Self::M6),
(8.0, Self::M8),
];
let (min_nom, min_size) = candidates[0];
if nominal <= min_nom {
return Some(min_size);
}
let (max_nom, max_size) = candidates[candidates.len() - 1];
if nominal >= max_nom {
return Some(max_size);
}
let mut best: Option<(f32, Self)> = None;
for (nom, size) in candidates {
let d = (nom - nominal).abs();
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, size));
}
}
best.map(|(_, size)| size)
}
}
#[must_use]
pub fn screw_hole(size: MetricSize, depth: f32) -> SdfNode {
let dia = size.nominal_diameter() + CLEARANCE_H2D_FDM;
SdfNode::Cylinder {
radius: dia * 0.5,
half_height: depth * 0.5,
}
}
#[must_use]
pub fn tap_hole(size: MetricSize, depth: f32, accuracy: f32) -> SdfNode {
let dia = size.nominal_diameter().mul_add(0.85, 2.0 * accuracy);
SdfNode::Cylinder {
radius: dia * 0.5,
half_height: depth * 0.5,
}
}
#[must_use]
pub fn counterbore(size: MetricSize, plate_thickness: f32) -> SdfNode {
let head_dia = size.head_diameter_socket();
let bore_depth = size.head_height_socket() + 0.5;
let through = screw_hole(size, plate_thickness + 10.0);
let bore = SdfNode::Cylinder {
radius: head_dia * 0.5,
half_height: bore_depth * 0.5,
};
let bore_shifted = SdfNode::Translate {
child: Arc::new(bore),
offset: Vec3::new(0.0, (plate_thickness - bore_depth) * 0.5, 0.0),
};
SdfNode::Union {
a: Arc::new(through),
b: Arc::new(bore_shifted),
}
}
#[must_use]
pub fn countersink(size: MetricSize, plate_thickness: f32) -> SdfNode {
let head_dia = size.head_diameter_countersunk();
let half_angle = (COUNTERSUNK_TAPER_ANGLE_DEG * 0.5).to_radians();
let cone_h = head_dia * 0.5 / alice_det_math::tan(half_angle);
let through = screw_hole(size, plate_thickness + 10.0);
let cone = SdfNode::Cone {
radius: head_dia * 0.5,
half_height: cone_h * 0.5,
};
let cone_inverted = SdfNode::Rotate {
child: Arc::new(cone),
rotation: crate::angle::quat_from_rotation_x(std::f32::consts::PI),
};
let cone_shifted = SdfNode::Translate {
child: Arc::new(cone_inverted),
offset: Vec3::new(0.0, (plate_thickness - cone_h) * 0.5, 0.0),
};
SdfNode::Union {
a: Arc::new(through),
b: Arc::new(cone_shifted),
}
}
#[must_use]
pub fn bolt(size: MetricSize, shank_length: f32) -> SdfNode {
let shank_r = size.nominal_diameter() * 0.5;
let head_r = size.head_diameter_socket() * 0.5;
let head_h = size.head_height_socket();
let shank = SdfNode::Cylinder {
radius: shank_r,
half_height: shank_length * 0.5,
};
let head = SdfNode::Cylinder {
radius: head_r,
half_height: head_h * 0.5,
};
let head_shifted = SdfNode::Translate {
child: Arc::new(head),
offset: Vec3::new(0.0, f32::midpoint(shank_length, head_h), 0.0),
};
SdfNode::Union {
a: Arc::new(shank),
b: Arc::new(head_shifted),
}
}
#[must_use]
pub fn heat_set_insert_hole(size: MetricSize) -> SdfNode {
let dia = size.heat_set_insert_diameter() + CLEARANCE_H2D_FDM;
let depth = size.heat_set_insert_depth() + HEAT_SET_SINK_MARGIN;
SdfNode::Cylinder {
radius: dia * 0.5,
half_height: depth * 0.5,
}
}
#[must_use]
pub fn dowel_hole(dia: f32, depth: f32) -> SdfNode {
let hole_dia = dia + 0.1;
SdfNode::Cylinder {
radius: hole_dia * 0.5,
half_height: depth * 0.5,
}
}
#[must_use]
pub fn wood_screw_pilot(screw_dia: f32, hardwood: bool) -> SdfNode {
let factor = if hardwood { 0.9 } else { 0.7 };
let hole_dia = screw_dia * factor;
let depth = screw_dia * 5.0;
SdfNode::Cylinder {
radius: hole_dia * 0.5,
half_height: depth * 0.5,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn screw_hole_m4_diameter_is_nominal_plus_clearance() {
let node = screw_hole(MetricSize::M4, 5.0);
match node {
SdfNode::Cylinder {
radius,
half_height,
} => {
assert!(approx_eq(radius, 2.1)); assert!(approx_eq(half_height, 2.5));
}
_ => panic!("expected Cylinder"),
}
}
#[test]
fn tap_hole_m3_matches_pipeline_formula() {
let node = tap_hole(MetricSize::M3, 4.0, DEFAULT_ACCURACY);
match node {
SdfNode::Cylinder { radius, .. } => {
assert!(approx_eq(radius, 1.375));
}
_ => panic!("expected Cylinder"),
}
}
#[test]
fn tap_hole_m5_with_custom_accuracy() {
let node = tap_hole(MetricSize::M5, 6.0, 0.15);
match node {
SdfNode::Cylinder { radius, .. } => {
assert!(approx_eq(radius, 2.275));
}
_ => panic!("expected Cylinder"),
}
}
#[test]
fn counterbore_m4_produces_union_of_through_and_bore() {
let node = counterbore(MetricSize::M4, 8.0);
match &node {
SdfNode::Union { a, b } => {
assert!(matches!(&**a, SdfNode::Cylinder { .. }));
assert!(matches!(&**b, SdfNode::Translate { .. }));
}
_ => panic!("expected Union"),
}
}
#[test]
fn countersink_m4_uses_iso10642_head_diameter() {
let node = countersink(MetricSize::M4, 8.0);
match &node {
SdfNode::Union { a: _, b } => match &**b {
SdfNode::Translate { child, .. } => {
assert!(matches!(&**child, SdfNode::Rotate { .. }));
}
_ => panic!("expected Translate at Union.b"),
},
_ => panic!("expected Union"),
}
}
#[test]
fn bolt_m4_produces_shank_plus_head_union() {
let node = bolt(MetricSize::M4, 20.0);
match &node {
SdfNode::Union { a, b } => {
match &**a {
SdfNode::Cylinder {
radius,
half_height,
} => {
assert!(approx_eq(*radius, 2.0)); assert!(approx_eq(*half_height, 10.0)); }
_ => panic!("expected Cylinder for shank"),
}
assert!(matches!(&**b, SdfNode::Translate { .. }));
}
_ => panic!("expected Union"),
}
}
#[test]
fn heat_set_insert_hole_m3_matches_voxel8_spec() {
let node = heat_set_insert_hole(MetricSize::M3);
match node {
SdfNode::Cylinder {
radius,
half_height,
} => {
assert!(approx_eq(radius, 2.1));
assert!(approx_eq(half_height, 2.05));
}
_ => panic!("expected Cylinder"),
}
}
#[test]
fn metric_size_iso4762_head_dimensions() {
assert!(approx_eq(MetricSize::M3.head_diameter_socket(), 5.5));
assert!(approx_eq(MetricSize::M8.head_diameter_socket(), 13.0));
assert!(approx_eq(MetricSize::M6.head_height_socket(), 6.0));
}
#[test]
fn metric_size_iso10642_countersunk_head_diameters() {
assert!(approx_eq(MetricSize::M3.head_diameter_countersunk(), 6.0));
assert!(approx_eq(MetricSize::M8.head_diameter_countersunk(), 16.0));
}
#[test]
fn all_metric_sizes_have_positive_dimensions() {
for size in [
MetricSize::M3,
MetricSize::M4,
MetricSize::M5,
MetricSize::M6,
MetricSize::M8,
] {
assert!(size.nominal_diameter() > 0.0);
assert!(size.head_diameter_socket() > size.nominal_diameter());
assert!(size.head_height_socket() > 0.0);
assert!(size.head_diameter_countersunk() >= size.head_diameter_socket());
assert!(size.heat_set_insert_diameter() > size.nominal_diameter());
assert!(size.heat_set_insert_depth() > 0.0);
}
}
#[test]
fn evaluation_at_origin_is_inside_all_fastener_holes() {
use alice_sdf::eval;
let plate_thickness = 6.0;
for size in [
MetricSize::M3,
MetricSize::M4,
MetricSize::M5,
MetricSize::M6,
MetricSize::M8,
] {
let sh = screw_hole(size, plate_thickness);
assert!(eval(&sh, Vec3::ZERO) < 0.0, "screw_hole {size:?}");
let th = tap_hole(size, plate_thickness, DEFAULT_ACCURACY);
assert!(eval(&th, Vec3::ZERO) < 0.0, "tap_hole {size:?}");
let cb = counterbore(size, plate_thickness);
assert!(eval(&cb, Vec3::ZERO) < 0.0, "counterbore {size:?}");
let cs = countersink(size, plate_thickness);
assert!(eval(&cs, Vec3::ZERO) < 0.0, "countersink {size:?}");
let hs = heat_set_insert_hole(size);
assert!(eval(&hs, Vec3::ZERO) < 0.0, "heat_set_insert_hole {size:?}");
}
}
#[test]
fn from_f32_snap_exact_matches() {
assert_eq!(MetricSize::from_f32_snap(2.0), MetricSize::M2);
assert_eq!(MetricSize::from_f32_snap(2.5), MetricSize::M2_5);
assert_eq!(MetricSize::from_f32_snap(3.0), MetricSize::M3);
assert_eq!(MetricSize::from_f32_snap(4.0), MetricSize::M4);
assert_eq!(MetricSize::from_f32_snap(5.0), MetricSize::M5);
assert_eq!(MetricSize::from_f32_snap(6.0), MetricSize::M6);
assert_eq!(MetricSize::from_f32_snap(8.0), MetricSize::M8);
}
#[test]
fn from_f32_snap_near_matches_pick_closest() {
assert_eq!(MetricSize::from_f32_snap(4.5), MetricSize::M4);
assert_eq!(MetricSize::from_f32_snap(3.5), MetricSize::M3);
assert_eq!(MetricSize::from_f32_snap(7.0), MetricSize::M6);
assert_eq!(MetricSize::from_f32_snap(4.1), MetricSize::M4);
assert_eq!(MetricSize::from_f32_snap(5.9), MetricSize::M6);
assert_eq!(MetricSize::from_f32_snap(2.3), MetricSize::M2_5);
assert_eq!(MetricSize::from_f32_snap(2.1), MetricSize::M2);
}
#[test]
fn from_f32_snap_out_of_range_clamps() {
assert_eq!(MetricSize::from_f32_snap(100.0), MetricSize::M8);
assert_eq!(MetricSize::from_f32_snap(0.5), MetricSize::M2);
}
#[test]
fn m2_dimensions_are_iso_compliant() {
assert!((MetricSize::M2.nominal_diameter() - 2.0).abs() < 1e-6);
assert!((MetricSize::M2.head_diameter_socket() - 3.8).abs() < 1e-6);
assert!((MetricSize::M2.head_height_socket() - 2.0).abs() < 1e-6);
assert!((MetricSize::M2.head_diameter_countersunk() - 4.0).abs() < 1e-6);
}
#[test]
fn m2_5_dimensions_are_pi_compliant() {
assert!((MetricSize::M2_5.nominal_diameter() - 2.5).abs() < 1e-6);
assert!((MetricSize::M2_5.head_diameter_socket() - 4.5).abs() < 1e-6);
assert!((MetricSize::M2_5.head_height_socket() - 2.5).abs() < 1e-6);
assert!((MetricSize::M2_5.head_diameter_countersunk() - 4.5).abs() < 1e-6);
assert!((MetricSize::M2_5.heat_set_insert_diameter() - 3.6).abs() < 1e-6);
assert!((MetricSize::M2_5.heat_set_insert_depth() - 3.3).abs() < 1e-6);
}
#[test]
fn m2_all_holes_evaluate_negative_at_origin() {
use alice_sdf::eval;
let plate_thickness = 6.0;
for size in [MetricSize::M2, MetricSize::M2_5] {
let sh = screw_hole(size, plate_thickness);
assert!(eval(&sh, Vec3::ZERO) < 0.0, "screw_hole {size:?}");
let th = tap_hole(size, plate_thickness, DEFAULT_ACCURACY);
assert!(eval(&th, Vec3::ZERO) < 0.0, "tap_hole {size:?}");
let cb = counterbore(size, plate_thickness);
assert!(eval(&cb, Vec3::ZERO) < 0.0, "counterbore {size:?}");
let cs = countersink(size, plate_thickness);
assert!(eval(&cs, Vec3::ZERO) < 0.0, "countersink {size:?}");
let hs = heat_set_insert_hole(size);
assert!(eval(&hs, Vec3::ZERO) < 0.0, "heat_set_insert_hole {size:?}");
}
}
#[test]
fn dowel_hole_standard_8mm() {
let node = dowel_hole(8.0, 15.0);
if let SdfNode::Cylinder {
radius,
half_height,
} = node
{
assert!(approx_eq(radius, 4.05), "dowel radius = 4.05, got {radius}");
assert!(approx_eq(half_height, 7.5), "dowel half_height = 7.5");
} else {
panic!("expected Cylinder");
}
}
#[test]
fn wood_screw_pilot_softwood_formula() {
let node = wood_screw_pilot(4.0, false);
if let SdfNode::Cylinder {
radius,
half_height,
} = node
{
assert!(approx_eq(radius, 1.4), "softwood pilot radius = 1.4");
assert!(approx_eq(half_height, 10.0));
} else {
panic!("expected Cylinder");
}
}
#[test]
fn wood_screw_pilot_hardwood_formula() {
let node = wood_screw_pilot(4.0, true);
if let SdfNode::Cylinder { radius, .. } = node {
assert!(approx_eq(radius, 1.8), "hardwood pilot radius = 1.8");
} else {
panic!("expected Cylinder");
}
}
}