DEV Community

Satoshi Misumi
Satoshi Misumi

Posted on

Modeling a nut, in Rust

I tried to build one ISO 4032 M2 hex nut with the Rust cadrum crate (an OpenCASCADE-based B-Rep library), and there were more detours than I expected, so I'm leaving the notes here.

I started with just a cube and grew main.rs over 6 stages, adding one feature at a time. Every step wrote out a PNG (4-view layout) and a STEP file when I ran cargo run, so dimension mistakes and shape glitches showed up right away. What I ended up with was one Solid with thread, double-face chamfer, and lead-in chamfer all per spec.


0. Project setup

Cargo.toml:

[package]
name = "nut"
version = "0.1.0"
edition = "2021"

[dependencies]
cadrum = "0.8.1"
Enter fullscreen mode Exit fullscreen mode

cadrum fetches OpenCASCADE at build time, so on Windows the first build had me waiting a while. The second run was fast because the cache kicked in.


Step 1: one cube

I wanted to confirm that writing code actually got a solid onto the screen, so I just placed a single Solid::cube.

use cadrum::Solid;

fn main() -> Result<(), cadrum::Error> {
    let cube: Solid = Solid::cube(10.0, 10.0, 10.0).color("orange");

    cube.write_multiview_png(&mut std::fs::File::create("step1.png").unwrap())?;
    Solid::write_step([&cube], &mut std::fs::File::create("step1.step").unwrap())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

cargo run to execute.

4-view of a 10mm cube

write_multiview_png packed isometric, front, top, and right views into one PNG, with a scale bar and view-axis icons. While I was iterating on dimensions, it turned out to be faster than STEP. The write_step output I opened in FreeCAD just to sanity-check.


Step 2: extrude a hexagon

The nut's outline is a hex prism, so I built a hexagon profile and extruded it along Z.

For an M2 nut, ISO 4032 gave me width-across-flats s = 4.0 mm and thickness m = 1.6 mm. The distance from center to a vertex (circumscribed radius) came out to s / √3.

use cadrum::{DVec3, Edge, Solid};
use std::f64::consts::TAU;

fn main() -> Result<(), cadrum::Error> {
    let s = 4.0;
    let m = 1.6;
    let r_circum = s / 3f64.sqrt();

    let hex_pts: Vec<DVec3> = (0..6)
        .map(|i| {
            let a = i as f64 * TAU / 6.0;
            DVec3::new(r_circum * a.cos(), r_circum * a.sin(), 0.0)
        })
        .collect();
    let hex_profile = Edge::polygon(&hex_pts)?;

    let body: Solid = Solid::extrude(&hex_profile, DVec3::Z * m)?.color("orange");

    body.write_multiview_png(&mut std::fs::File::create("step2.png").unwrap())?;
    Solid::write_step([&body], &mut std::fs::File::create("step2.step").unwrap())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Passing an array of vertices to Edge::polygon returned a closed polygonal wire. Handing that to Solid::extrude(profile, DVec3::Z * m) let me give direction and length as a single vector.


Step 3: drill the hole

I needed a threaded hole through the middle, so I made a cylinder and subtracted it.

cadrum overloads + / - / * between &Solid for union, difference, and intersection. The return type is Result<Solid, _>, so I chained with ?.

use cadrum::{DVec3, Edge, Solid};
use std::f64::consts::TAU;

fn main() -> Result<(), cadrum::Error> {
    let r = 1.0;                              // M2 nominal radius (major Ø2.0)
    let pitch = 0.4;                          // M2 pitch
    let h = 3f64.sqrt() / 2.0 * pitch;        // ISO 68-1 fundamental triangle height
    let s = 4.0;
    let m = 1.6;

    let r_circum = s / 3f64.sqrt();
    let r_minor = r - h * 6.0 / 8.0;          // minor radius (= thread-cutter shaft radius)

    let hex_pts: Vec<DVec3> = (0..6)
        .map(|i| {
            let a = i as f64 * TAU / 6.0;
            DVec3::new(r_circum * a.cos(), r_circum * a.sin(), 0.0)
        })
        .collect();
    let body = Solid::extrude(&Edge::polygon(&hex_pts)?, DVec3::Z * m)?;
    let bore = Solid::cylinder(r_minor, DVec3::Z, m + 0.4).translate(DVec3::Z * -0.2);

    let part: Solid = (&body - &bore)?.color("orange");

    part.write_multiview_png(&mut std::fs::File::create("step3.png").unwrap())?;
    Solid::write_step([&part], &mut std::fs::File::create("step3.step").unwrap())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

step3: hex prism with hole

At first I set the hole length exactly to m, and the coplanar boolean at the top and bottom faces left a paper-thin shell. Bumping it to m + 0.4 so it pokes out 0.2 mm on each side cleaned that up.

I also went back and forth on the formula for r_minor. The ISO minor radius itself is r - h * 5/8, but using that left a thin wall after the thread cutter was subtracted later. I wanted it to match the thread-cutter shaft radius, so I went with r - h * 6/8.


Step 4: double-face chamfer

Looking at a real ISO 4032 nut from above, the outline came out to a regular 12-gon. It's the corners of the hexagon getting sliced off by a flat plane.

      ___      ←  this "corner sliced at an angle" is the chamfer
     /   \
    /     \
    \     /
     \___/
Enter fullscreen mode Exit fullscreen mode

I first reached for the chamfer_edges API, but it only rounds edges and doesn't give the flat facets the spec calls for. The most direct option turned out to be subtracting half_space 12 times — 6 vertices × top/bottom.

use cadrum::{DVec3, Edge, Solid};
use std::f64::consts::TAU;

fn main() -> Result<(), cadrum::Error> {
    let r = 1.0;
    let pitch = 0.4;
    let h = 3f64.sqrt() / 2.0 * pitch;
    let s = 4.0;
    let m = 1.6;

    let r_apothem = s / 2.0;                       // inscribed radius
    let r_circum = s / 3f64.sqrt();
    let r_minor = r - h * 6.0 / 8.0;

    let cham_angle = 30f64.to_radians();           // ISO 4032 maximum
    let r_cham_reach = r_apothem - 0.1;
    let cham_outer_h = (r_circum - r_cham_reach) * cham_angle.tan();

    let hex_pts: Vec<DVec3> = (0..6)
        .map(|i| {
            let a = i as f64 * TAU / 6.0;
            DVec3::new(r_circum * a.cos(), r_circum * a.sin(), 0.0)
        })
        .collect();
    let mut body = Solid::extrude(&Edge::polygon(&hex_pts)?, DVec3::Z * m)?;

    let nr = cham_angle.sin();
    let nz = cham_angle.cos();
    for i in 0..6 {
        let theta = i as f64 * TAU / 6.0;
        let p_corner = DVec3::new(r_circum * theta.cos(), r_circum * theta.sin(), 0.0);
        let n_radial = DVec3::new(nr * theta.cos(), nr * theta.sin(), 0.0);
        body = (&body - &Solid::half_space(
            p_corner + DVec3::Z * (m - cham_outer_h),
            n_radial + DVec3::Z * nz,
        ))?;
        body = (&body - &Solid::half_space(
            p_corner + DVec3::Z * cham_outer_h,
            n_radial - DVec3::Z * nz,
        ))?;
    }

    let bore = Solid::cylinder(r_minor, DVec3::Z, m + 0.4).translate(DVec3::Z * -0.2);
    let part: Solid = (&body - &bore)?.color("orange");

    part.write_multiview_png(&mut std::fs::File::create("step4.png").unwrap())?;
    Solid::write_step([&part], &mut std::fs::File::create("step4.step").unwrap())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

step4: with double-face chamfer

Solid::half_space(origin, normal) is an infinite solid occupying the side the normal points to; subtracting it removes everything on that side. I used 12 planes, each passing through one of the vertical corner edges, tilted up or down 30° from horizontal.

Initially I had r_cham_reach = r_apothem, sitting exactly on the inscribed circle. In the 4-view, the chamfer width looked noticeably narrower than a conical cut would. A flat plane doesn't reach the middle of each face — it only shaves the corner — so it removes less material than a cone at the same reach. Pulling the reach in by 0.1 mm balanced the look (rule of thumb for M2).

The chamfer-height formula is Δr · tan(angle). I had cot instead of tan once and the result looked like 60°, which someone pointed out.


Step 5: lead-in chamfer

The top and bottom openings of the bore needed a taper — the part that helps a bolt enter.

I wanted to add a conical frustum to each end of the straight cylinder, so I used Solid::cone(r1, r2, axis, h). Even with r1 < r2 it produces a flared frustum without complaint.

use cadrum::{DVec3, Edge, Solid};
use std::f64::consts::TAU;

fn main() -> Result<(), cadrum::Error> {
    let r = 1.0;
    let pitch = 0.4;
    let h = 3f64.sqrt() / 2.0 * pitch;
    let s = 4.0;
    let m = 1.6;

    let r_apothem = s / 2.0;
    let r_circum = s / 3f64.sqrt();
    let r_minor = r - h * 6.0 / 8.0;

    let cham_angle = 30f64.to_radians();
    let r_cham_reach = r_apothem - 0.1;
    let cham_outer_h = (r_circum - r_cham_reach) * cham_angle.tan();

    let r_lead = r;
    let cham_lead_h = 0.20;

    let hex_pts: Vec<DVec3> = (0..6)
        .map(|i| {
            let a = i as f64 * TAU / 6.0;
            DVec3::new(r_circum * a.cos(), r_circum * a.sin(), 0.0)
        })
        .collect();
    let mut body = Solid::extrude(&Edge::polygon(&hex_pts)?, DVec3::Z * m)?;

    let nr = cham_angle.sin();
    let nz = cham_angle.cos();
    for i in 0..6 {
        let theta = i as f64 * TAU / 6.0;
        let p_corner = DVec3::new(r_circum * theta.cos(), r_circum * theta.sin(), 0.0);
        let n_radial = DVec3::new(nr * theta.cos(), nr * theta.sin(), 0.0);
        body = (&body - &Solid::half_space(
            p_corner + DVec3::Z * (m - cham_outer_h),
            n_radial + DVec3::Z * nz,
        ))?;
        body = (&body - &Solid::half_space(
            p_corner + DVec3::Z * cham_outer_h,
            n_radial - DVec3::Z * nz,
        ))?;
    }

    let main_bore  = Solid::cylinder(r_minor, DVec3::Z, m + 0.4)
        .translate(DVec3::Z * -0.2);
    let top_flare  = Solid::cone(r_minor, r_lead, DVec3::Z, cham_lead_h)
        .translate(DVec3::Z * (m - cham_lead_h));
    let bot_flare  = Solid::cone(r_minor, r_lead, -DVec3::Z, cham_lead_h)
        .translate(DVec3::Z * cham_lead_h);

    // union the three pieces into one cutter and subtract once
    let bore_cutter = (&(&main_bore + &top_flare)? + &bot_flare)?;
    let part: Solid = (&body - &bore_cutter)?.color("orange");

    part.write_multiview_png(&mut std::fs::File::create("step5.png").unwrap())?;
    Solid::write_step([&part], &mut std::fs::File::create("step5.step").unwrap())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

step5: with lead-in chamfer

At first I subtracted the bore, top flare, and bottom flare in three separate - calls. The coplanar edges tripped up the boolean engine occasionally. Unioning the three with + into a single cutter and subtracting once made it steady.

For Solid::cone, r1 is the radius at the origin and r2 is at axis * h; passing -DVec3::Z as the axis gives a cone pointing downward.


Step 6: internal thread

Building an ISO 68-1 metric thread from scratch looked like a chore, but framing it as "build the male thread and subtract it from the bore" made it tractable. Same construction as cadrum's own 07_sweep.rs.

The thread cutter combines three pieces:

  1. A triangle swept along a helix — the raw thread fin.
  2. A shaft cylinder (minor diameter) — fills between fins into a continuous shaft.
  3. A crest cylinder (slightly below the major radius) — clips the triangle tips into a flat crest.
use cadrum::{DVec3, Edge, ProfileOrient, Solid, Wire};
use std::f64::consts::TAU;

fn build_m2_hex_nut() -> Result<Solid, cadrum::Error> {
    let r = 1.0;
    let pitch = 0.4;
    let h = 3f64.sqrt() / 2.0 * pitch;
    let s = 4.0;
    let m = 1.6;

    let r_apothem = s / 2.0;
    let r_circum = s / 3f64.sqrt();
    let r_minor = r - h * 6.0 / 8.0;

    let cham_angle = 30f64.to_radians();
    let r_cham_reach = r_apothem - 0.1;
    let cham_outer_h = (r_circum - r_cham_reach) * cham_angle.tan();

    let r_lead = r;
    let cham_lead_h = 0.20;

    // 1. Hex prism
    let hex_pts: Vec<DVec3> = (0..6)
        .map(|i| {
            let a = i as f64 * TAU / 6.0;
            DVec3::new(r_circum * a.cos(), r_circum * a.sin(), 0.0)
        })
        .collect();
    let mut body = Solid::extrude(&Edge::polygon(&hex_pts)?, DVec3::Z * m)?;

    // 2. Double-face chamfer (12 half-space cuts)
    let nr = cham_angle.sin();
    let nz = cham_angle.cos();
    for i in 0..6 {
        let theta = i as f64 * TAU / 6.0;
        let p_corner = DVec3::new(r_circum * theta.cos(), r_circum * theta.sin(), 0.0);
        let n_radial = DVec3::new(nr * theta.cos(), nr * theta.sin(), 0.0);
        body = (&body - &Solid::half_space(
            p_corner + DVec3::Z * (m - cham_outer_h),
            n_radial + DVec3::Z * nz,
        ))?;
        body = (&body - &Solid::half_space(
            p_corner + DVec3::Z * cham_outer_h,
            n_radial - DVec3::Z * nz,
        ))?;
    }
    let body = body;

    // 3. Bore + lead-in chamfer (union once, subtract once)
    let main_bore  = Solid::cylinder(r_minor, DVec3::Z, m + 0.4)
        .translate(DVec3::Z * -0.2);
    let top_flare  = Solid::cone(r_minor, r_lead, DVec3::Z, cham_lead_h)
        .translate(DVec3::Z * (m - cham_lead_h));
    let bot_flare  = Solid::cone(r_minor, r_lead, -DVec3::Z, cham_lead_h)
        .translate(DVec3::Z * cham_lead_h);
    let bore_cutter = (&(&main_bore + &top_flare)? + &bot_flare)?;
    let body = (&body - &bore_cutter)?;

    // 4. Internal thread
    let thread_h = m + 0.4;
    let helix = Edge::helix(r - h, pitch, thread_h, DVec3::Z, DVec3::X)?;

    // ISO 68-1 fundamental triangle (base on the helix, apex pointing outward)
    let tri = Edge::polygon(&[
        DVec3::new(0.0, -pitch / 2.0, 0.0),
        DVec3::new(h, 0.0, 0.0),
        DVec3::new(0.0,  pitch / 2.0, 0.0),
    ])?;
    let tri = tri
        .align_z(helix.start_tangent(), helix.start_point())
        .translate(helix.start_point());

    let thread = Solid::sweep(&tri, &[helix], ProfileOrient::Up(DVec3::Z))?;
    let shaft  = Solid::cylinder(r - h * 6.0 / 8.0, DVec3::Z, thread_h);
    let crest  = Solid::cylinder(r - h / 8.0,       DVec3::Z, thread_h);

    // (thread ∪ shaft) ∩ crest = a shaft with fins whose tips have been clipped
    let cutter = (&(&thread + &shaft)? * &crest)?.translate(DVec3::Z * -0.2);

    let nut = (&body - &cutter)?;
    Ok(nut.color("orange"))
}

fn main() -> Result<(), cadrum::Error> {
    let nut: Solid = build_m2_hex_nut()?;
    nut.write_multiview_png(&mut std::fs::File::create("step6.png").unwrap())?;
    Solid::write_step([&nut], &mut std::fs::File::create("step6.step").unwrap())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

step6: final nut

The first time I forgot align_z, the triangle stayed tilted as it traveled along the helix and the thread came out wavy. Calling Wire::align_z(tangent, point) to align the profile's Z axis with the helix tangent kept the same orientation all the way around.

ProfileOrient::Up(DVec3::Z) on Solid::sweep is the "keep the profile pointed Z-up while traversing the spine" mode. That one tripped me up on the first try too.

The final * &crest is the intersection that clips the triangle tips into a flat crest. &a + &b for union, &a - &b for difference, &a * &b for intersection — the consistency made it easy to revisit the code later.

Final screen shots with Fusion 360.

Fusion360

cross section

cross section


APIs that came up

API Use
Solid::cube / cylinder / cone / sphere Primitives
Edge::polygon / Edge::helix Profiles and spines
Solid::extrude(profile, dir) Profile + straight line → solid
Solid::sweep(profile, spine, orient) Profile + curve → solid
Solid::half_space(origin, normal) Infinite half-space (chamfers, splits)
&a + &b / &a - &b / &a * &b Union / difference / intersection
Wire::align_z(tangent, point) Orient the profile
Solid::write_step / write_multiview_png Output

This was enough to make it all the way through to the thread. When I changed let r = 1.0; to 2.0 and set s = 7.0; m = 3.2;, an ISO 4032 M4 nut came out as-is, so parameterizing the dimensions paid off.

Next I'll pair it with an M2 bolt and try assembly output via Solid::write_step([&nut, &bolt], ...).

Top comments (0)