VOOZH about

URL: https://dev.to/lzpel/a-nut-in-rust-2oed

โ‡ฑ Modeling a nut, in Rust - DEV Community


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"

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(())
}

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(())
}

๐Ÿ‘

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(())
}

๐Ÿ‘ 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
 / \
 / \
 \ /
 \___/

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(())
}

๐Ÿ‘ 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(())
}

๐Ÿ‘ 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(())
}

๐Ÿ‘ 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], ...).