diff --git a/ROADMAP.md b/ROADMAP.md index 7c81d298..1c788025 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index e02c4529..a4870fa5 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -289,8 +289,9 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] to: Option, }, - /// 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, + #[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(), } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1f2e8a8d..8e6c6461 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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 { + 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, §ion, 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, 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 } => { diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 0f4b0a30..f2f950b9 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -1131,7 +1131,8 @@ fn thread(s: &mut Session, p: &Value) -> Result { 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})) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b68622f..9002a445 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -429,3 +429,5 @@ impl Session { mod extent_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod thread_tests; diff --git a/crates/engine/src/thread_tests.rs b/crates/engine/src/thread_tests.rs new file mode 100644 index 00000000..a9227e36 --- /dev/null +++ b/crates/engine/src/thread_tests.rs @@ -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}"); + } +} diff --git a/crates/kernel/src/helix.rs b/crates/kernel/src/helix.rs index fe3cce8d..ecae8385 100644 --- a/crates/kernel/src/helix.rs +++ b/crates/kernel/src/helix.rs @@ -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 { + 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; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 328f49ce..9ba4bf27 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index efd6463f..95e85580 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -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> { (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> { + let faces = b.faces(tol).ok()?; + let mesh = b.tessellate(tol).ok()?; + let planes: Vec> = 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> { + 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> { a.require_brep("a boolean")?; @@ -228,7 +309,31 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { 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, pb: Vec, size: f64, op: BoolOp, eps: f64) -> Result> { + 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, pb: Vec, op: BoolOp, eps: f64) -> Result> { 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> { } 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, size: f64) -> Result { // Global vertex table. let mut index: HashMap<(i64, i64, i64), usize> = HashMap::new(); let mut verts: Vec = Vec::new(); + // Points within `q` share a vertex, also across the rounding grid's cell boundaries. let mut id = |p: Vec3, verts: &mut Vec| -> 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)> = Vec::new(); for p in &polys { @@ -304,29 +417,58 @@ fn rebuild(polys: Vec, size: f64) -> Result { 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 = 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::() / 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> = 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 = 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 = 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, size: f64) -> Result { } 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 = Vec::new(); + for v in lp { + if let Some(at) = stack.iter().position(|x| *x == v) { + let sub: Vec = 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, size: f64) -> Result { } } 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); } } diff --git a/crates/kernel/src/thread_tests.rs b/crates/kernel/src/thread_tests.rs new file mode 100644 index 00000000..ad694ad3 --- /dev/null +++ b/crates/kernel/src/thread_tests.rs @@ -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, §ion, 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, §ion, 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}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1b25816d..4b4359dd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2736,7 +2736,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + 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() {