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.
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(())
}
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(())
}
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(())
}
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:
- A triangle swept along a helix — the raw thread fin.
- A shaft cylinder (minor diameter) — fills between fins into a continuous shaft.
- 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(())
}
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.
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)