Modelled threads (Thread with modeled: true): the ISO 68-1 profile cut along a helix, M3 to M20, on shafts and in bores; running out past a free end of the face, stopping square at a set length. The groove is a watertight screw-sweep mesh (kernel::sweep_helix_mesh) cut by kernel::faceted_boolean (BSP on the faceted body, tolerance ladder checked by A−B + A∩B = A); the polygon rebuild welds vertices across grid cells, splits pinched loops, finds T-junctions through a grid, and takes the group's plane for a loop a hair off flat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 12:47:56 +00:00
1 parent 1f619e49cc
commit 141a5fea8a
11 files changed
+421 -34

No files matched your search

+40 -1
View File
@@ -1,7 +1,7 @@
//! Helical sweeps (coils, springs) and lofts to a point (pyramids, cones).
use mt::builder;
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec3};
use solvecraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, Solid, p3};
@@ -59,6 +59,45 @@ pub fn sweep_helix(plane: &Plane, region: &Region2, axis_origin: Vec3, axis: Vec
})
}
/// A screw sweep of a convex polygon (in `plane`'s coordinates) as a closed triangle mesh,
/// wound outward: `stations` sections per turn, flat end caps. Watertight by construction, for
/// faceted booleans (modelled threads).
pub fn sweep_helix_mesh(plane: &Plane, polygon: &[Vec2], axis_origin: Vec3, axis: Vec3, pitch: f64, turns: f64, stations: usize) -> Result<Mesh> {
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("helix axis".into()))?;
let m = polygon.len();
if !(pitch.is_finite() && turns.is_finite() && turns > 0.0 && turns <= 200.0 && (3..=64).contains(&m) && (8..=720).contains(&stations)) {
return Err(KernelError::Invalid("helix mesh: turns in (0, 200], 3…64 corners, 8…720 stations".into()));
}
let n = (turns * stations as f64).ceil() as usize;
let total = std::f64::consts::TAU * turns;
let mut mesh = Mesh::default();
for i in 0..=n {
let a = total * i as f64 / n as f64;
for q in polygon {
let w = plane.to_world(*q);
mesh.positions.push(axis_origin + rot(w - axis_origin, k, a) + k * (pitch * a / std::f64::consts::TAU));
}
}
let id = |i: usize, j: usize| u32::try_from(i * m + j % m).unwrap_or(0);
for i in 0..n {
for j in 0..m {
mesh.triangles.push([id(i, j), id(i + 1, j), id(i + 1, j + 1)]);
mesh.triangles.push([id(i, j), id(i + 1, j + 1), id(i, j + 1)]);
}
}
for j in 1..m - 1 {
mesh.triangles.push([id(0, 0), id(0, j), id(0, j + 1)]);
mesh.triangles.push([id(n, 0), id(n, j + 1), id(n, j)]);
}
mesh.tri_face = vec![0; mesh.triangles.len()];
if mesh.measure().volume < 0.0 {
for t in &mut mesh.triangles {
t.swap(1, 2);
}
}
Ok(mesh)
}
/// Samples per profile segment and stations per turn of the smooth helical walls.
const SEG_SAMPLES: usize = 12;
const STATIONS_PER_TURN: f64 = 12.0;
+4 -1
View File
@@ -41,6 +41,8 @@ mod step_in;
mod step_out;
mod surfaces;
mod sweep_path;
#[cfg(test)]
mod thread_tests;
mod topo;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
@@ -50,12 +52,13 @@ pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use delete_face::delete_faces;
pub use helix::{loft_to_point, sweep_helix};
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;
pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use polybool::planar_boolean;
pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
+190 -25
View File
@@ -5,7 +5,7 @@
use std::collections::HashMap;
use solvecraft_geom::Vec3;
use solvecraft_geom::{Mesh, Vec3};
use truck_modeling::{self as mt, builder};
use crate::body::{Body, Solid, p3};
@@ -220,6 +220,87 @@ fn planar_polys(b: &Body) -> Option<Vec<Poly>> {
(out.len() <= MAX_POLYS).then_some(out)
}
/// Polygons of any body: planar faces exactly, curved ones as their triangles (within `tol`).
fn faceted_polys(b: &Body, tol: f64) -> Option<Vec<Poly>> {
let faces = b.faces(tol).ok()?;
let mesh = b.tessellate(tol).ok()?;
let planes: Vec<Option<PlaneEq>> = faces.iter().map(|f| f.plane_normal.map(|n| PlaneEq { n, w: n.dot(f.centroid) })).collect();
let mut out = Vec::new();
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
let [a, bb, c] = mesh.tri(t)?;
let plane = match planes.get(*fi as usize)? {
Some(pl) => *pl,
None => {
let Some(n) = (bb - a).cross(c - a).normalized() else { continue };
PlaneEq { n, w: n.dot(a) }
}
};
let snap = |p: Vec3| p - plane.n * (plane.n.dot(p) - plane.w);
let mut v = vec![snap(a), snap(bb), snap(c)];
if (v[1] - v[0]).cross(v[2] - v[0]).dot(plane.n) < 0.0 {
v.reverse();
}
out.push(Poly { v, plane });
}
(out.len() <= MAX_POLYS).then_some(out)
}
/// Boolean of a body, its curved faces faceted to `tol`, with a closed triangle mesh (wound
/// outward); the result has planar faces only. For tools the B-rep boolean cannot take, such as
/// the helical groove of a thread.
pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
if !(tol.is_finite() && tol > 0.0) {
return Err(KernelError::Invalid("facet tolerance".into()));
}
let Some(pa) = faceted_polys(a, tol) else {
return Err(KernelError::Failed("the body is too detailed to facet".into()));
};
let mut pb = Vec::with_capacity(b.triangles.len());
for t in &b.triangles {
let Some([p, q, r]) = b.tri(t) else { continue };
let Some(n) = (q - p).cross(r - p).normalized() else { continue };
pb.push(Poly { v: vec![p, q, r], plane: PlaneEq { n, w: n.dot(p) } });
}
if pa.len() + pb.len() > MAX_POLYS {
return Err(KernelError::Failed("the bodies are too detailed to facet".into()));
}
let (lo, hi) = b
.positions
.iter()
.fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(l, h), p| (l.min(*p), h.max(*p)));
let size = if b.positions.is_empty() { a.size() } else { a.size().max((hi - lo).len()) };
// Fine facets of curved faces meet at shallow angles, where the coplanar test decides
// whether the pieces' boundaries come out consistent: try a few tolerances, and take the
// first whose result is watertight and agrees with the complementary boolean (A − B and
// A ∩ B add up to A; A ∪ B is A plus B less A ∩ B).
let (va, vb) = (polys_volume(&pa), polys_volume(&pb));
let mut last = KernelError::Failed("faceted boolean".into());
for rel in [1e-8, 1e-7, 3e-8, 1e-9, 3e-7] {
let eps = size * rel;
let out = csg(pa.clone(), pb.clone(), op, eps)?;
let both = csg(pa.clone(), pb.clone(), BoolOp::Intersect, eps)?;
let (vo, vi) = (polys_volume(&out), polys_volume(&both));
let expect = match op {
BoolOp::Cut => va - vi,
BoolOp::Union => va + vb - vi,
BoolOp::Intersect => vi,
};
if (vo - expect).abs() > 1e-6 * (va.abs() + vb.abs()) || vi < -1e-9 * (va.abs() + vb.abs()) {
last = KernelError::Failed(format!("faceted boolean: inconsistent volumes ({vo} vs {expect})"));
continue;
}
if out.is_empty() {
return Ok(None);
}
match rebuild(out, size) {
Ok(b) => return Ok(Some(b)),
Err(e) => last = e,
}
}
Err(last)
}
/// Boolean of two all-planar bodies.
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
@@ -228,7 +309,31 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
};
let size = a.size().max(b.size());
let eps = size * 1e-9;
polys_boolean(pa, pb, size, op, size * 1e-9)
}
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
let polys = csg(pa, pb, op, eps)?;
if polys.is_empty() {
return Ok(None);
}
rebuild(polys, size).map(Some)
}
/// Enclosed volume of a closed set of polygons (divergence theorem).
fn polys_volume(ps: &[Poly]) -> f64 {
let mut v6 = 0.0;
for p in ps {
let Some(&o) = p.v.first() else { continue };
for w in p.v.windows(2).skip(1) {
v6 += o.dot(w[0].cross(w[1]));
}
}
v6 / 6.0
}
/// The polygons of a BSP boolean (csg.js style).
fn csg(pa: Vec<Poly>, pb: Vec<Poly>, op: BoolOp, eps: f64) -> Result<Vec<Poly>> {
let (mut na, mut nb) = (Node::default(), Node::default());
na.build(pa, eps, 0)?;
nb.build(pb, eps, 0)?;
@@ -269,10 +374,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
let mut polys = Vec::new();
na.all(&mut polys);
if polys.is_empty() {
return Ok(None);
}
rebuild(polys, size).map(Some)
Ok(polys)
}
/// Position key for vertex merging.
@@ -286,12 +388,23 @@ fn rebuild(polys: Vec<Poly>, size: f64) -> Result<Body> {
// Global vertex table.
let mut index: HashMap<(i64, i64, i64), usize> = HashMap::new();
let mut verts: Vec<Vec3> = Vec::new();
// Points within `q` share a vertex, also across the rounding grid's cell boundaries.
let mut id = |p: Vec3, verts: &mut Vec<Vec3>| -> usize {
let k = key(p, q);
*index.entry(k).or_insert_with(|| {
verts.push(p);
verts.len() - 1
})
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(&i) = index.get(&(k.0 + dx, k.1 + dy, k.2 + dz))
&& verts.get(i).is_some_and(|v| (*v - p).len() <= q)
{
return i;
}
}
}
}
verts.push(p);
index.insert(k, verts.len() - 1);
verts.len() - 1
};
let mut loops: Vec<(PlaneEq, Vec<usize>)> = Vec::new();
for p in &polys {
@@ -304,29 +417,58 @@ fn rebuild(polys: Vec<Poly>, size: f64) -> Result<Body> {
loops.push((p.plane, l));
}
}
// Split every polygon edge at vertices lying on it (T-junctions).
// Split every polygon edge at vertices lying on it (T-junctions). Vertices are binned in a
// grid sized to the typical edge, so each edge only looks at its neighbourhood.
let tol = size * 1e-7;
let edge_len: Vec<f64> = loops
.iter()
.flat_map(|(_, l)| (0..l.len()).filter_map(move |i| Some((*l.get(i)?, *l.get((i + 1) % l.len())?))))
.filter_map(|(a, b)| Some((*verts.get(b)? - *verts.get(a)?).len()))
.collect();
let mean = edge_len.iter().sum::<f64>() / edge_len.len().max(1) as f64;
let cell = mean.max(size * 1e-4).max(1e-9);
let ckey = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64);
let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
for (k, v) in verts.iter().enumerate() {
grid.entry(ckey(*v)).or_default().push(k);
}
let mut directed: Vec<(usize, usize, usize)> = Vec::new(); // (plane group, from, to)
let mut groups: Vec<PlaneEq> = Vec::new();
let mut group_of: HashMap<(i64, i64, i64, i64), usize> = HashMap::new();
for (pl, l) in &loops {
let g = match groups.iter().position(|g| g.n.dot(pl.n) > 1.0 - 1e-9 && (g.w - pl.w).abs() < tol * 10.0) {
Some(g) => g,
None => {
groups.push(*pl);
groups.len() - 1
}
};
let gk = ((pl.n.x * 1e8).round() as i64, (pl.n.y * 1e8).round() as i64, (pl.n.z * 1e8).round() as i64, (pl.w / (tol * 10.0)).round() as i64);
let g = *group_of.entry(gk).or_insert_with(|| {
groups.push(*pl);
groups.len() - 1
});
let n = l.len();
for i in 0..n {
let (Some(&a), Some(&b)) = (l.get(i), l.get((i + 1) % n)) else { continue };
let (Some(&pa), Some(&pb)) = (verts.get(a), verts.get(b)) else { continue };
let d = pb - pa;
let len2 = d.len2();
let mut on: Vec<(f64, usize)> = verts
.iter()
.enumerate()
.filter(|(k, _)| *k != a && *k != b)
.filter_map(|(k, v)| {
let (lo, hi) = (ckey(pa.min(pb) - Vec3::new(tol, tol, tol)), ckey(pa.max(pb) + Vec3::new(tol, tol, tol)));
let cells = (hi.0 - lo.0 + 1) * (hi.1 - lo.1 + 1) * (hi.2 - lo.2 + 1);
let candidates: Vec<usize> = if cells > 4096 {
(0..verts.len()).collect()
} else {
let mut c = Vec::new();
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(ks) = grid.get(&(x, y, z)) {
c.extend(ks.iter().copied());
}
}
}
}
c
};
let mut on: Vec<(f64, usize)> = candidates
.into_iter()
.filter(|k| *k != a && *k != b)
.filter_map(|k| {
let v = verts.get(k)?;
let t = (*v - pa).dot(d) / len2.max(1e-300);
(t > 1e-9 && t < 1.0 - 1e-9 && v.dist_to_segment(pa, pb) < tol).then_some((t, k))
})
@@ -374,7 +516,21 @@ fn rebuild(polys: Vec<Poly>, size: f64) -> Result<Body> {
}
if cur == s {
lp.pop();
loops_g.push(lp);
// A walk through a vertex twice (two loops touching there) is split into
// simple loops at that vertex.
let mut stack: Vec<usize> = Vec::new();
for v in lp {
if let Some(at) = stack.iter().position(|x| *x == v) {
let sub: Vec<usize> = stack.split_off(at);
if sub.len() >= 3 {
loops_g.push(sub);
}
}
stack.push(v);
}
if stack.len() >= 3 {
loops_g.push(stack);
}
}
}
for lp in &loops_g {
@@ -447,7 +603,16 @@ fn rebuild(polys: Vec<Poly>, size: f64) -> Result<Body> {
}
}
let _ = ol;
let face = builder::try_attach_plane(&ws).map_err(|e| KernelError::Failed(format!("planar boolean face: {e}")))?;
// A loop a hair off flat (fine facets of a curved face) takes the group's plane.
let face = match builder::try_attach_plane(&ws) {
Ok(f) => f,
Err(e) => {
let o = plane.n * plane.w;
let surface = mt::Plane::new(p3(o), p3(o + u), p3(o + w));
mt::Face::try_new(ws, mt::Surface::Plane(surface))
.map_err(|e2| KernelError::Failed(format!("planar boolean face: {e}; {e2}")))?
}
};
faces.push(face);
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Faceted booleans with helical tools (modelled threads).
use std::f64::consts::PI;
use solvecraft_geom::{Plane, Vec2, Vec3};
use crate::{BoolOp, cylinder, faceted_boolean, sweep_helix_mesh};
/// A trapezoid groove screwed into a shaft: the result is closed, and the volume removed is
/// the section's 2π∫r dA per turn over the turns inside the shaft.
#[test]
fn faceted_cut_of_a_helical_groove() {
let shaft = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), 1.5, 4.0).unwrap();
let (p, k) = (0.5, Vec3::new(0.0, 0.0, 1.0));
// From a turn under the shaft to its middle: 4.5 turns, the first half-turn in the air.
let base = Vec3::new(0.0, 0.0, -p);
let plane = Plane { origin: base, x: Vec3::new(1.0, 0.0, 0.0), y: k };
let section = [Vec2::new(1.2, -0.06), Vec2::new(1.6, -0.06), Vec2::new(1.6, 0.06), Vec2::new(1.2, 0.06)];
let groove = sweep_helix_mesh(&plane, &section, base, k, p, 5.0, 48).unwrap();
let cut = faceted_boolean(&shaft, &groove, BoolOp::Cut, 1e-3).unwrap().unwrap();
let m = cut.tessellate(0.01).unwrap().measure();
// In the shaft from z = 0: the section (r 1.2…1.5, 0.12 tall) for 4 turns, plus the part of
// the first turn's section above z = 0 (its z range is ±0.06 about the turn's height).
let per_turn = 2.0 * PI * 0.12 * (1.5f64.powi(2) - 1.2f64.powi(2)) / 2.0;
let removed = PI * 2.25 * 4.0 - m.volume;
assert!((removed - 4.0 * per_turn).abs() < 0.03 * per_turn * 4.0, "{removed} vs {}", 4.0 * per_turn);
}
/// A groove cut into the wall of a bore through a block (a nut): it runs out of both ends.
#[test]
fn faceted_cut_into_a_bore() {
let nut = crate::box_solid(Vec3::new(-3.0, -3.0, 0.0), Vec3::new(3.0, 3.0, 2.0)).unwrap();
let bore = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 1.23, 4.0).unwrap();
let nut = crate::boolean(&nut, &bore, BoolOp::Cut).unwrap().unwrap();
let v0 = nut.tessellate(1e-3).unwrap().measure().volume;
let (p, k) = (0.5, Vec3::new(0.0, 0.0, 1.0));
let base = Vec3::new(0.0, 0.0, -p);
let plane = Plane { origin: base, x: Vec3::new(1.0, 0.0, 0.0), y: k };
let section = [Vec2::new(1.1, -0.1), Vec2::new(1.5, -0.1), Vec2::new(1.5, 0.1), Vec2::new(1.1, 0.1)];
let groove = sweep_helix_mesh(&plane, &section, base, k, p, 6.0, 48).unwrap();
let cut = faceted_boolean(&nut, &groove, BoolOp::Cut, 2.5e-3).unwrap().unwrap();
let removed = v0 - cut.tessellate(1e-3).unwrap().measure().volume;
// Four turns of the section's part outside the bore (r 1.23…1.5, 0.2 tall).
let want = 4.0 * PI * 0.2 * (1.5f64.powi(2) - 1.23f64.powi(2));
assert!((removed - want).abs() < 0.03 * want, "{removed} vs {want}");
}