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

+1 -1
View File
@@ -46,7 +46,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
+6 -2
View File
@@ -289,8 +289,9 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
/// A cosmetic thread on a cylindrical face (no geometry change; drawn and exported as an
/// annotation). `designation` is an ISO metric size such as "M8" or "M8x1".
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
/// `designation` is an ISO metric size such as "M8" or "M8x1".
Thread {
face: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -298,6 +299,8 @@ pub enum FeatureKind {
/// Thread length from the face's end nearest `face`; `None` = full length.
#[serde(default, skip_serializing_if = "Option::is_none")]
length: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
@@ -1033,6 +1036,7 @@ impl FeatureKind {
FeatureKind::Hole { to: Some(t), .. } => vec![t],
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
_ => Vec::new(),
}
}
+60 -2
View File
@@ -186,6 +186,49 @@ fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>,
})
}
/// The helical space between the teeth of a modelled ISO metric thread (ISO 68-1 basic
/// profile, 60° flanks): cut from the shaft (external) or the hole wall (internal), running from
/// `start` to `end` along the axis. `radius` is the face's own radius; `open` says which ends
/// of the threaded length are free ends of the face.
fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<solvecraft_geom::Mesh> {
let (p, d) = (t.pitch, t.major_diameter);
let h = p * 3f64.sqrt() / 2.0;
let slope = (std::f64::consts::PI / 6.0).tan();
let len = t.end - t.start;
if !(p > 1e-3 && len > 1e-6) {
return Err(DocError::Invalid("thread too short".into()));
}
if len / p > 190.0 {
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p)));
}
// (radius, half width of the groove) at its two ends: a trapezoid in the axial plane.
let (r0, w0, r1, w1) = if t.internal {
// From inside the hole wall out to the major diameter, where the bolt's crest sits
// (the root flat is P/8 wide).
let r_in = radius.min(d / 2.0 - 5.0 * h / 8.0) - 0.1 * p;
(r_in, (p / 8.0 + 2.0 * (d / 2.0 - r_in) * slope) / 2.0, d / 2.0, p / 16.0)
} else {
// From the root (5H/8 under the major diameter, flat P/4 wide) out past the surface.
let r_root = d / 2.0 - 5.0 * h / 8.0;
let r_out = radius.max(d / 2.0) + 0.05 * p;
(r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0)
};
if w0.max(w1) * 2.0 >= p * 0.98 {
return Err(DocError::Invalid("the face is too far from the thread's diameter".into()));
}
let k = t.axis;
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?;
// Past a free end of the face the groove runs out into the air; elsewhere it stops square
// at the threaded length's end.
let (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
let base = t.axis_point + k * s0;
let plane = Plane { origin: base, x: u, y: k };
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
// Sections 7.5° apart: the chords sag 0.2% of the radius.
Ok(kernel::sweep_helix_mesh(&plane, &section, base, k, p, (s1 - s0) / p, 48)?)
}
/// Model after some prefix of the timeline.
#[derive(Clone, Debug, Default)]
pub struct ModelState {
@@ -2272,13 +2315,28 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Thread { face, designation, length } => {
FeatureKind::Thread { face, designation, length, modeled } => {
let l = match length {
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
};
let t = thread_on(st, f.id, *face, designation.as_deref(), l)?;
st.threads.push(t);
if !*modeled {
st.threads.push(t);
return Ok(());
}
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let cyl = kernel::cylinder_face_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
let tol = t.pitch * 1e-3;
let groove = thread_groove(&t, cyl.radius, ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol))?;
// The B-rep boolean can't take helical walls in reasonable time: the body is cut
// faceted (curved faces within a two-hundredth of the pitch).
let nb = kernel::faceted_boolean(&mb.body, &groove, BoolOp::Cut, t.pitch * 0.005)?
.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => {
+2 -1
View File
@@ -1131,7 +1131,8 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length })?;
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length, modeled })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
+2
View File
@@ -429,3 +429,5 @@ impl Session {
mod extent_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod thread_tests;
+69
View File
@@ -0,0 +1,69 @@
//! Modelled threads: the ISO metric profile cut along a helix, M3 to M20, outside and inside.
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// Volume from the body's mesh: exact for the faceted threaded body, and quicker than a full
/// measurement of its thousands of faces.
fn volume(s: &mut Session, b: &str) -> f64 {
let st = s.model.state();
st.body(b).map(|mb| mb.body.tessellate(1e-3).unwrap().measure().volume).unwrap_or(0.0)
}
const SIZES: [(f64, f64); 9] = [(3.0, 0.5), (4.0, 0.7), (5.0, 0.8), (6.0, 1.0), (8.0, 1.25), (10.0, 1.5), (12.0, 1.75), (16.0, 2.0), (20.0, 2.5)];
/// ∫ 2π r · width(r) dr over [a, b]: the volume one turn of the groove takes out (a screw
/// motion of a section in an axial plane sweeps 2π∫r dA per turn).
fn per_turn(a: f64, b: f64, width: impl Fn(f64) -> f64) -> f64 {
let n = 400;
(0..n).map(|i| a + (b - a) * (i as f64 + 0.5) / n as f64).map(|r| 2.0 * PI * r * width(r) * (b - a) / n as f64).sum()
}
#[test]
fn modelled_external_threads_m3_to_m20() {
let h = |p: f64| p * 3f64.sqrt() / 2.0;
let slope = (PI / 6.0).tan();
for (d, p) in SIZES {
let mut s = Session::default();
let len = 4.0 * p;
run(&mut s, "solid.cylinder", json!({"radius": d / 2.0, "height": len + 2.0, "body_name": "Shaft"}));
let before = volume(&mut s, "Shaft");
let th = run(&mut s, "solid.thread", json!({"face": [d / 2.0, 0, 1], "length": len, "modeled": true}));
assert!(th["thread"].is_null(), "a modelled thread is real geometry, not an annotation: {th}");
let r_root = d / 2.0 - 5.0 * h(p) / 8.0;
let want = per_turn(r_root, d / 2.0, |r| p / 4.0 + 2.0 * (r - r_root) * slope) * len / p;
let got = before - volume(&mut s, "Shaft");
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
}
}
#[test]
fn modelled_internal_threads_m3_to_m20() {
let h = |p: f64| p * 3f64.sqrt() / 2.0;
let slope = (PI / 6.0).tan();
for (d, p) in SIZES {
let mut s = Session::default();
let len = 4.0 * p;
let d1 = d - 2.0 * 5.0 * h(p) / 8.0;
run(&mut s, "solid.box", json!({"corner": [-d, -d, 0], "length": 2.0 * d, "width": 2.0 * d, "height": len, "body_name": "Nut"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -1], "radius": d1 / 2.0, "height": len + 2.0, "body_name": "Bore"}));
run(&mut s, "solid.combine", json!({"target": "Nut", "tools": ["Bore"], "operation": "cut"}));
let before = volume(&mut s, "Nut");
let th = run(&mut s, "solid.thread", json!({"face": [d1 / 2.0, 0, len / 2.0], "modeled": true}));
assert!(th["thread"].is_null(), "{th}");
// Bolt-shaped space: P/8 flat at the major diameter, widening towards the bore.
let want = per_turn(d1 / 2.0, d / 2.0, |r| p / 8.0 + 2.0 * (d / 2.0 - r) * slope) * len / p;
let got = before - volume(&mut s, "Nut");
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
}
}
+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}");
}
+1 -1
View File
@@ -2736,7 +2736,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Thread { face, designation, length } => {
FeatureKind::Thread { face, designation, length, .. } => {
let mut d = start("solid.thread")?;
d.kind = Kind::Thread { designation: designation.clone().unwrap_or_default(), length: length.clone().unwrap_or_default() };
if let Some(inp) = d.inputs.first_mut() {