From 23bfae0107db899f73687f8b42dfb190331ae19d Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:58:54 -0400 Subject: [PATCH] kernel: surfaces on the schema 4 record and SMLib Copy, unstitch, reverse and remove faces, Patch (a sketch region, or a planar loop of a body's edges) and Extend work on the body's record: Parts puts any faces, edges and vertices into canonical order, and each face taken is the body's face (Kept), its edges the body's. Sheets are SMLib's sweeps with no caps (extrude, revolve, curve sweep, stations for twist/scale/orientation/rails), a chain at a time; a loft lofts each column of matched segments and stitches the columns. History: a wall comes from the segments its edges in the start plane lie along. Stitch joins the bodies' records and sews them with smlib_stitch, faces and edges carried back to the bodies through SMLib's tags. Offset Surface offsets each face on its own with smlib_offset_sheet and stitches the offsets within the tolerance: smooth neighbours stay joined, sharp ones come apart, as before; the fitted offset surfaces and their fold check are gone. Thicken is SMLib's thicken. Not yet: a surface sweep along a path with corners (refused). Tests ported; the fold-radius message test now checks only the refusal. (not built: base in progress) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt --- crates/kernel/src/surface_offset.rs | 329 +--- crates/kernel/src/surfaces.rs | 2369 +++++++++++++++------------ 2 files changed, 1330 insertions(+), 1368 deletions(-) diff --git a/crates/kernel/src/surface_offset.rs b/crates/kernel/src/surface_offset.rs index 2e34338398..a19d34fa95 100644 --- a/crates/kernel/src/surface_offset.rs +++ b/crates/kernel/src/surface_offset.rs @@ -1,114 +1,17 @@ //! Offset surfaces (Surface › Offset): faces of a body copied out at a signed distance along -//! their normals, as a new surface body. Planes and circular cylinders move exactly (as Thicken -//! moves them); any other face gets a B-spline surface fitted through offset samples on a grid, -//! refined until it is within a tolerance of the true offset everywhere it is checked. An -//! offset as deep as a face's smallest concave radius would fold it over, and is refused, -//! naming that radius. Neighbouring faces moved to the same place along their shared edge -//! (meeting smoothly) keep sharing it; across a sharp edge they come apart. Faces that close up -//! make a solid. +//! their normals, as a new surface body. Each face goes onto its surface's offset, SMLib's exact +//! offset (`smlib_offset_sheet`) of the face on its own; the offsets are then stitched where they +//! meet within the tolerance, so neighbouring faces moved to the same place along their shared +//! edge (meeting smoothly) keep sharing it, and across a sharp edge they come apart. Faces that +//! close up make a solid. An offset SMLib cannot make (one that would fold a face over) is its +//! error, never a fitted surface. -use std::collections::HashMap; - -use mt::{BoundedCurve, InnerSpace, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter}; -use solidcraft_geom::{Grid, Vec3}; -use truck_modeling as mt; - -use crate::body::{Body, from_p3, p3}; -use crate::build::interpolate_net; -use crate::history::FaceOrigin; -use crate::surfaces::{Offset, map_curve, map_surface, surface_body}; -use crate::{KernelError, Result, guard}; - -/// Grid samples a side of a fitted offset starts with, and the most it is refined to. -const START_GRID: usize = 12; -const MAX_GRID: usize = 96; -/// Samples along an edge of a fitted offset. -const EDGE_SAMPLES: usize = 33; - -/// How one face moves along its normals: exactly (a plane, a circular cylinder) or onto -/// surfaces fitted for the distances it was made for. Shared by Offset Surface and Thicken. -pub(crate) struct FaceMover { - face: mt::Face, - how: How, -} - -enum How { - Exact(Offset), - /// Each fitted distance (by its bits) and its surface (absolute parameters of the face's own - /// surface); the face's outward sign (its orientation). - Fitted { - surfaces: Vec<(u64, mt::BSplineSurface)>, - sign: f64, - }, -} - -impl FaceMover { - /// A mover for `face` at each of `distances` (along its outward normal), refused where a - /// distance would fold it over; fitted faces within `tolerance`. - pub(crate) fn new(face: &mt::Face, distances: &[f64], tolerance: f64) -> Result { - let how = match Offset::of(face) { - Some(off) => { - if let Offset::Cylinder { r, out, .. } = off - && let Some(d) = distances.iter().find(|d| r + out * **d <= tolerance) - { - return Err(KernelError::Invalid(format!("{d} mm is as deep as the cylinder's radius ({r:.4} mm): it would fold the face over"))); - } - How::Exact(off) - } - None => { - let mut surfaces = Vec::new(); - for d in distances.iter().filter(|d| **d != 0.0) { - if !surfaces.iter().any(|(b, _)| *b == d.to_bits()) { - surfaces.push((d.to_bits(), fit(face, *d, tolerance)?)); - } - } - How::Fitted { surfaces, sign: if face.orientation() { 1.0 } else { -1.0 } } - } - }; - Ok(FaceMover { face: face.clone(), how }) - } - - /// Where the point `p` of the face goes at distance `d`. - pub(crate) fn point(&self, p: Vec3, d: f64) -> Result { - match &self.how { - How::Exact(off) => Ok(off.map(p, d)), - How::Fitted { .. } if d == 0.0 => Ok(p), - How::Fitted { sign, .. } => offset_point(&self.face.surface(), p, sign * d), - } - } - - /// An edge curve of the face moved by `d` (exact, or a cubic through moved samples). - pub(crate) fn curve(&self, c: &mt::Curve, d: f64) -> Result { - match &self.how { - How::Exact(off) => map_curve(off, c, d), - How::Fitted { .. } if d == 0.0 => Ok(c.clone()), - How::Fitted { .. } => { - let (t0, t1) = c.range_tuple(); - let pts: Vec = (0..EDGE_SAMPLES) - .map(|k| self.point(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / (EDGE_SAMPLES - 1) as f64)), d)) - .collect::>()?; - Ok(mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?)) - } - } - } - - /// The face's surface moved by `d` (absolute: the face's orientation is kept apart). - pub(crate) fn surface(&self, d: f64) -> Result { - match &self.how { - How::Exact(off) => map_surface(off, &self.face.surface(), d), - How::Fitted { .. } if d == 0.0 => Ok(self.face.surface()), - How::Fitted { surfaces, .. } => surfaces - .iter() - .find(|(b, _)| *b == d.to_bits()) - .map(|(_, s)| mt::Surface::BSplineSurface(s.clone())) - .ok_or_else(|| KernelError::Failed("offset: no surface fitted for that distance".into())), - } - } -} +use crate::body::Body; +use crate::{KernelError, Result}; /// The picked faces of `body` (indices in its face order) offset by `distance` along their -/// outward normals (negative: against them) into one surface body. `tolerance`: how far a -/// fitted face may stray from the true offset. A distance of zero copies the faces. The +/// outward normals (negative: against them) into one surface body. `tolerance`: how far apart +/// two offsets' edges may be and still be one edge. A distance of zero copies the faces. The /// history is over `[body]`: each face is the offset of the one it came from. pub fn offset_surface(body: &Body, faces: &[usize], distance: f64, tolerance: f64) -> Result { body.require_brep("offset")?; @@ -118,10 +21,10 @@ pub fn offset_surface(body: &Body, faces: &[usize], distance: f64, tolerance: f6 if !(tolerance.is_finite() && (1e-6..=10.0).contains(&tolerance)) { return Err(KernelError::Invalid("the offset tolerance must be 0.000001…10 mm".into())); } - let all: Vec = body.solid.face_iter().cloned().collect(); + let count = body.face_count(); let mut picked: Vec = Vec::new(); for &i in faces { - if i >= all.len() { + if i >= count { return Err(KernelError::Invalid("no such face".into())); } if !picked.contains(&i) { @@ -131,203 +34,14 @@ pub fn offset_surface(body: &Body, faces: &[usize], distance: f64, tolerance: f6 if picked.is_empty() { return Err(KernelError::Invalid("pick the faces to offset".into())); } - let chosen: Vec<(usize, &mt::Face)> = picked.iter().filter_map(|i| all.get(*i).map(|f| (*i, f))).collect(); - if distance == 0.0 { - let copy = surface_body(chosen.iter().map(|(_, f)| (*f).clone()).collect()); - return Ok(crate::record::rebuilt_body(copy, &[&body.solid], &crate::record::Rebuild::default())); - } - let size = body.size().max(1e-6); - let moves: Vec = chosen.iter().map(|(_, f)| FaceMover::new(f, &[distance], tolerance)).collect::>()?; - guard("offset surface", || { - let near = |a: Vec3, b: Vec3| a.dist(b) <= tolerance.max(size * 1e-7) * 4.0; - // Moved vertices and edges, shared while every face moves them to the same place. - let mut verts: HashMap> = HashMap::new(); - let mut edges: HashMap> = HashMap::new(); - let mut out = Vec::with_capacity(chosen.len()); - let mut history = Vec::with_capacity(chosen.len()); - for ((i, f), mv) in chosen.iter().zip(&moves) { - let map = |p: Vec3| mv.point(p, distance); - let mut vertex = |v: &mt::Vertex| -> Result { - let at = map(from_p3(v.point()))?; - let list = verts.entry(v.id()).or_default(); - if let Some(found) = list.iter().find(|m| near(from_p3(m.point()), at)) { - return Ok(found.clone()); - } - let nv = mt::Vertex::new(p3(at)); - list.push(nv.clone()); - Ok(nv) - }; - let mut wires = Vec::new(); - for w in f.absolute_boundaries() { - let mut wire = Vec::new(); - for e in w.edge_iter() { - let abs = if e.orientation() { e.clone() } else { e.inverse() }; - let (a, b) = (vertex(abs.front())?, vertex(abs.back())?); - let c = abs.curve(); - let (t0, t1) = c.range_tuple(); - let mid = map(from_p3(c.subs(0.5 * (t0 + t1))))?; - let list = edges.entry(abs.id()).or_default(); - let shared = list.iter().find(|m| { - m.front().id() == a.id() && m.back().id() == b.id() && { - let mc = m.curve(); - let (s0, s1) = mc.range_tuple(); - near(from_p3(mc.subs(0.5 * (s0 + s1))), mid) - } - }); - let moved = match shared { - Some(m) => m.clone(), - None => { - let curve = mv.curve(&c, distance)?; - let m = mt::Edge::new_unchecked(&a, &b, curve); - list.push(m.clone()); - m - } - }; - wire.push(if e.orientation() { moved } else { moved.inverse() }); - } - wires.push(mt::Wire::from(wire)); - } - let surface = mv.surface(distance)?; - let mut nf = mt::Face::new_unchecked(wires, surface); - if !f.orientation() { - nf.invert(); - } - out.push(nf); - history.push(crate::record::generated(vec![FaceOrigin::Offset { operand: 0, face: *i }])); - } - let operand = crate::record::Operand::new(body); - // Faces that close up (every face of a smooth closed body) make a solid, as Stitch makes - // one. - let shell = mt::Shell::from(out); - let result = match shell.shell_condition() { - mt::ShellCondition::Closed => match crate::body::Solid::try_new(vec![shell.clone()]) { - Ok(solid) => Body::new(solid)?, - Err(_) => surface_body(shell.into_iter().collect()), - }, - _ => surface_body(shell.into_iter().collect()), - }; - crate::record::attach(result, &[operand], Some(history), HashMap::new()) - }) -} - -/// The point `p` on `s` (absolute) moved by `d` along the surface's own normal there. -fn offset_point(s: &mt::Surface, p: Vec3, d: f64) -> Result { - let (u, v) = s.search_nearest_parameter(p3(p), None, 100).ok_or_else(|| KernelError::Failed("offset: a point off its face".into()))?; - let n = from_p3(mt::Point3::new(0.0, 0.0, 0.0) + s.normal(u, v)); - let n = - n.normalized().ok_or_else(|| KernelError::Invalid("the face has a point with no normal (a pole or a crease): not supported yet".into()))?; - Ok(from_p3(s.subs(u, v)) + n * d) -} - -/// The fitted offset of a face that is not a plane or a circular cylinder. -fn fit(f: &mt::Face, distance: f64, tolerance: f64) -> Result> { - let s = f.surface(); - let sign = if f.orientation() { 1.0 } else { -1.0 }; - let d = sign * distance; - // The face's span in its surface's parameters: its boundary, found on the surface. - let (mut u0, mut u1, mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY); - for e in f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()) { - let c = e.curve(); - let (t0, t1) = c.range_tuple(); - for k in 0..=16 { - let q = c.subs(t0 + (t1 - t0) * k as f64 / 16.0); - if let Some((u, v)) = s.search_nearest_parameter(q, None, 100) { - (u0, u1, v0, v1) = (u0.min(u), u1.max(u), v0.min(v), v1.max(v)); - } - } - } - if !(u0.is_finite() && u1 > u0 && v0.is_finite() && v1 > v0) { - return Err(KernelError::Failed("offset: the face's span on its surface".into())); - } - let at = |a: f64, b: f64| -> Result { - let (u, v) = (u0 + (u1 - u0) * a, v0 + (v1 - v0) * b); - let n = from_p3(mt::Point3::new(0.0, 0.0, 0.0) + s.normal(u, v)); - let n = n - .normalized() - .ok_or_else(|| KernelError::Invalid("the face has a point with no normal (a pole or a crease): not supported yet".into()))?; - Ok(from_p3(s.subs(u, v)) + n * d) - }; - // Folding over: moving a point by d along its normal shrinks lengths along a principal - // direction by 1 - d·k (k > 0 where the surface bends toward its normal); at zero the - // offset reaches the centre of curvature and folds. Refused with a margin, naming the - // tightest radius on that side. - let mut tightest: Option = None; - for i in 0..=START_GRID { - for j in 0..=START_GRID { - let (u, v) = (u0 + (u1 - u0) * i as f64 / START_GRID as f64, v0 + (v1 - v0) * j as f64 / START_GRID as f64); - let Some((k1, k2)) = principal_curvatures(&s, u, v) else { continue }; - for k in [k1, k2] { - if d * k > 0.0 && 1.0 - d * k <= 0.05 { - let r = 1.0 / k.abs(); - tightest = Some(tightest.map_or(r, |t: f64| t.min(r))); - } - } - } - } - if let Some(r) = tightest { - return Err(KernelError::Invalid(format!( - "the offset is as deep as the face's smallest concave radius on that side ({r:.4} mm): it would fold the face over" - ))); - } - let mut n = START_GRID; - loop { - let grid = Grid::from_fn([n + 1, n + 1], |i, j| at(i as f64 / n as f64, j as f64 / n as f64)) - .ok_or_else(|| KernelError::TooLarge("offset: the sample grid".into()))? - .try_map(|p| p)?; - let surface = interpolate_net(&grid).map(|fit| fit.surface).ok_or_else(|| KernelError::Failed("offset: fitting the surface".into()))?; - // Checked between the samples, where a fit strays most. - let mut worst = 0.0f64; - for i in 0..n { - for j in 0..n { - let (a, b) = ((i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); - worst = worst.max(from_p3(surface.subs(a, b)).dist(at(a, b)?)); - } - } - if worst <= tolerance { - // Re-parameterised to the face's own surface span, so boundary points found on the - // old surface map onto it. - return Ok(reparameterised(surface, (u0, u1), (v0, v1))); - } - if n >= MAX_GRID { - return Err(KernelError::Invalid(format!( - "the offset can't be fitted within {tolerance} mm (off by up to {worst:.4} mm): try a larger tolerance" - ))); - } - n = (n * 2).min(MAX_GRID); - } -} - -/// The principal curvatures of `s` at (u, v), signed: positive where the surface bends toward -/// its normal there. `None` where it has no normal. -fn principal_curvatures(s: &mt::Surface, u: f64, v: f64) -> Option<(f64, f64)> { - let (su, sv) = (s.uder(u, v), s.vder(u, v)); - let n = s.normal(u, v); - if !(n.magnitude2() > 0.5) { - return None; - } - let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv)); - let (l, m, nn) = (s.uuder(u, v).dot(n), s.uvder(u, v).dot(n), s.vvder(u, v).dot(n)); - let det = e * g - f * f; - if det.abs() < 1e-18 { - return None; - } - let k = (l * nn - m * m) / det; - let h = (e * nn - 2.0 * f * m + g * l) / (2.0 * det); - let disc = (h * h - k).max(0.0).sqrt(); - Some((h + disc, h - disc)) -} - -/// `s` (on [0, 1]²) with its knots stretched onto `u` × `v`. -fn reparameterised(s: mt::BSplineSurface, u: (f64, f64), v: (f64, f64)) -> mt::BSplineSurface { - let stretch = |kv: &mt::KnotVec, (a, b): (f64, f64)| mt::KnotVec::from(kv.iter().map(|k| a + (b - a) * k).collect::>()); - let (ku, kv) = (stretch(s.uknot_vec(), u), stretch(s.vknot_vec(), v)); - mt::BSplineSurface::new((ku, kv), s.control_points().clone()) + crate::surfaces::offset_faces(body, &picked, distance, tolerance) } #[cfg(test)] mod tests { use super::*; use crate::{box_solid, cylinder, measure, sphere}; + use solidcraft_geom::Vec3; fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-9) @@ -336,7 +50,7 @@ mod tests { crate::surfaces::nearest_face(b, p).unwrap() } fn edges(b: &Body) -> usize { - b.solid.edge_iter().map(|e| e.id()).collect::>().len() + b.edge_count() } /// Planes move exactly: a box's top 5 up is a 10 × 10 square at z = 15. Two faces across a @@ -372,7 +86,7 @@ mod tests { } /// A sphere (fitted faces) moved out by 2: area 4π·12², within the tolerance; moved in to - /// its centre, refused with its radius named. + /// its centre, refused. #[test] fn a_sphere_is_fitted_and_refused_past_its_centre() { let s = sphere(Vec3::ZERO, 10.0).unwrap(); @@ -384,8 +98,7 @@ mod tests { assert_eq!(edges(&out), edges(&s)); assert!(!out.is_surface(), "the offset sphere closes into a solid"); assert!(rel(measure(&out).unwrap().volume, 4.0 / 3.0 * std::f64::consts::PI * 1728.0) < 2e-3, "outward"); // (measured on meshes) - let e = offset_surface(&s, &all, -10.0, 0.01).unwrap_err().to_string(); - assert!(e.contains("10.0000"), "{e}"); + assert!(offset_surface(&s, &all, -10.0, 0.01).is_err()); } /// A doubly curved patch (a saddle, a B-spline height field) moved by 1.5: every point of the @@ -398,13 +111,13 @@ mod tests { let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt(); let rim = [Vec3::new(14.0, 0.0, z(14.0)), Vec3::new(0.0, 14.0, z(0.0)), Vec3::new(-14.0, 0.0, z(-14.0)), Vec3::new(0.0, -14.0, z(0.0))]; let cover = crate::patch_edges(&cup, &rim).unwrap(); - let face = cover.solid.face_iter().next().unwrap().clone(); + let record = crate::native::brep_record(&cover).unwrap(); + let s = solidcraft_geom::brep::eval::SurfaceEval::new(&record.faces[0].surface).unwrap(); let out = offset_surface(&cover, &[0], 1.5, 0.01).unwrap(); let mesh = out.mesh(&crate::MeshTolerance::chord(0.05)).unwrap(); - let s = face.surface(); for p in mesh.positions.iter().step_by(7) { - let (u, v) = s.search_nearest_parameter(p3(*p), None, 100).unwrap(); - let dist = from_p3(s.subs(u, v)).dist(*p); + let uv = s.project(*p, None, f64::INFINITY).unwrap(); + let dist = s.point(uv).unwrap().dist(*p); assert!((dist - 1.5).abs() < 0.03, "{dist} at {p:?}"); } } diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index a204b94897..0a059e7448 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -1,533 +1,621 @@ //! Surface bodies (open shells) and the operations that turn them into solids: Patch (a face //! filling a closed loop of edges — planar, or B-spline patches for a loop that is not — or a -//! planar sketch region), Stitch (surfaces sharing edges sewn -//! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept), -//! Thicken (a surface offset along its normals into a solid), copying faces out of a body, and -//! sheets swept from sketch curves (surface extrude, revolve, sweep and loft). +//! planar sketch region), Stitch (surfaces sharing edges sewn into one body, a solid when they +//! close), Thicken (a surface offset along its normals into a solid), copying, removing, turning +//! over and taking apart faces of a body, extending a planar surface, and sheets swept from sketch +//! curves (surface extrude, revolve, sweep and loft). +//! +//! Everything works on the body's schema 4 record. Taking faces out of a body, turning them over +//! and making a flat face are exact on the record ([`Parts`] puts any set of faces, edges and +//! vertices into a record's canonical order). Sweeps, stitching and offsets are SMLib's: each piece +//! of a sheet goes to SMLib on its own (a chain of a sweep, a column of a loft, a face of an +//! offset), the pieces' records are joined, and where pieces share edges SMLib stitches them. +//! +//! History is exact. A face taken from a body is that face (Kept), its edges the body's; a sheet's +//! wall comes from the profile segments its edges in the start plane lie along; a stitched or +//! offset face carries the tag of the face it was made from through SMLib. -use std::collections::HashMap; +use std::collections::BTreeSet; -use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter}; -use solidcraft_geom::{Plane, Region2, Seg2, UnitVec3, Vec2, Vec3}; -use truck_modeling as mt; +use solidcraft_geom::brep::eval::CurveEval; +use solidcraft_geom::brep::{self as b, Brep, Curve2}; +use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; -use crate::body::{Body, from_p3, p3}; -use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv}; -use crate::record::{self, Rebuild, Side, rebuilt_body}; -use crate::{KernelError, Result, guard}; +use crate::body::Body; +use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History}; +use crate::{KernelError, Result, record}; impl Body { - /// A surface body: some shell does not close. + /// A surface body: some shell does not close (an edge bounds a face on one side only). pub fn is_surface(&self) -> bool { - !self.is_mesh() && self.solid.boundaries().iter().any(|s| s.shell_condition() != mt::ShellCondition::Closed) + !self.is_mesh() && crate::native::brep_record(self).is_ok_and(|r| r.edges.iter().any(|e| e.uses.len() == 1)) } } -/// A body of faces as they are (no orientation fix: a surface has no inside). -pub(crate) fn surface_body(faces: Vec) -> Body { - surface_of(mt::Shell::from(faces)) +fn invalid(what: impl Into) -> KernelError { + KernelError::Invalid(what.into()) } -/// A body of a shell as it is. -fn surface_of(shell: mt::Shell) -> Body { - Body { - solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), - size: 0.0, - boundary_tol: None, - volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))), - coarse_missing: None, - mesh: None, - color: None, - paint: None, - splits: None, - face_sources: None, - history: None, - display_of: None, - meshes: None, - phased: false, - tolerances: None, - regularization_protected: false, +fn failed(what: impl Into) -> KernelError { + KernelError::Failed(what.into()) +} + +fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +fn a3(v: Vec3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +fn index(i: usize) -> Result { + u32::try_from(i).map_err(|_| KernelError::TooLarge("a record index".into())) +} + +// ---- Records of faces ------------------------------------------------------------------- + +/// Faces, edges and vertices to be put into one record: each face with whether its shell uses it +/// along its surface's natural normal, its loops naming `edges` and `vertices` by position here. +/// Edges' `uses` are ignored ([`Parts::canonical`] works them out). +#[derive(Clone, Debug, Default)] +pub(crate) struct Parts { + pub tolerance: f64, + pub vertices: Vec, + pub edges: Vec, + pub faces: Vec<(b::Face, bool)>, +} + +/// A record made by [`Parts::canonical`], with where each of its faces and edges came from +/// (`faces[i]`: the part face of record face i; `edges` likewise). Parts no face uses are left out. +pub(crate) struct Canonical { + pub brep: Brep, + pub faces: Vec, + pub edges: Vec, +} + +/// Where the parts of each record [`Parts::joined`] took begin: faces, edges, vertices. +type Offsets = Vec<[usize; 3]>; + +/// The record (by index) and its own index of a part numbered by `offsets` (`kind` 0 for faces, 1 +/// for edges). +fn owner(offsets: &Offsets, part: usize, kind: usize) -> Option<(usize, usize)> { + let k = offsets.iter().rposition(|o| o[kind] <= part)?; + Some((k, part - offsets.get(k)?[kind])) +} + +impl Parts { + /// Every face of `records` in one set of parts, each record's faces, edges and vertices after + /// the ones before it. + pub(crate) fn joined(records: &[&Brep]) -> Result<(Parts, Offsets)> { + let mut parts = Parts { tolerance: records.iter().map(|r| r.tolerance).fold(0.0, f64::max), ..Parts::default() }; + let mut offsets = Vec::with_capacity(records.len()); + for r in records { + offsets.push([parts.faces.len(), parts.edges.len(), parts.vertices.len()]); + let faces: Vec<(usize, bool)> = (0..r.faces.len()).map(|f| (f, false)).collect(); + parts.add(r, &faces)?; + } + Ok((parts, offsets)) + } + + /// The faces `picked` of `r` (each with whether to turn it over), on `r`'s own edges and + /// vertices. + pub(crate) fn of(r: &Brep, picked: &[(usize, bool)]) -> Result { + let mut parts = Parts { tolerance: r.tolerance, ..Parts::default() }; + parts.add(r, picked)?; + Ok(parts) + } + + /// Adds the faces `picked` of `r` with all of `r`'s edges and vertices, after the ones here. + fn add(&mut self, r: &Brep, picked: &[(usize, bool)]) -> Result<()> { + let (e0, v0) = (self.edges.len(), self.vertices.len()); + let sides = face_sides(r); + let shift = |i: u32, by: usize| index(i as usize + by); + self.vertices.extend(r.vertices.iter().cloned()); + for e in &r.edges { + let vertices = [shift(e.vertices[0], v0)?, shift(e.vertices[1], v0)?]; + self.edges.push(b::Edge { vertices, uses: Vec::new(), ..e.clone() }); + } + for &(f, turn) in picked { + let face = r.faces.get(f).ok_or_else(|| invalid(format!("no face {f}")))?; + let forward = sides.get(f).copied().unwrap_or(true) != turn; + let loops = face + .loops + .iter() + .map(|l| { + Ok(match l { + b::Loop::Edges { edges } => b::Loop::Edges { + edges: edges.iter().map(|u| Ok(b::OrientedEdge { edge: shift(u.edge, e0)?, ..u.clone() })).collect::>()?, + }, + b::Loop::Vertex { vertex } => b::Loop::Vertex { vertex: shift(*vertex, v0)? }, + }) + }) + .collect::>()?; + self.faces.push((b::Face { loops, ..face.clone() }, forward)); + } + Ok(()) + } + + /// The edges a face's loops use, in order. + fn uses_of(f: &b::Face) -> impl Iterator + '_ { + f.loops.iter().flat_map(|l| match l { + b::Loop::Edges { edges } => edges.iter().map(|u| u.edge).collect::>(), + b::Loop::Vertex { .. } => Vec::new(), + }) + } + + /// The faces in shells: faces joined by an edge are in one shell, shells in the order of their + /// first face, each shell's faces in part order. + fn shells(&self) -> Result>> { + let n = self.faces.len(); + let mut root: Vec = (0..n).collect(); + let find = |root: &[usize], mut i: usize| { + while let Some(&g) = root.get(i).filter(|g| **g != i) { + i = g; + } + i + }; + let mut first: Vec> = vec![None; self.edges.len()]; + for (fi, (f, _)) in self.faces.iter().enumerate() { + for e in Parts::uses_of(f) { + let slot = first.get_mut(e as usize).ok_or_else(|| invalid("an edge reference"))?; + match *slot { + None => *slot = Some(fi), + Some(other) => { + let (a, c) = (find(&root, other), find(&root, fi)); + if let Some(g) = root.get_mut(a.max(c)) { + *g = a.min(c); + } + } + } + } + } + let mut shells: Vec> = Vec::new(); + let mut shell_of: std::collections::HashMap = std::collections::HashMap::new(); + for fi in 0..n { + let s = *shell_of.entry(find(&root, fi)).or_insert_with(|| { + shells.push(Vec::new()); + shells.len() - 1 + }); + if let Some(list) = shells.get_mut(s) { + list.push(fi); + } + } + Ok(shells) + } + + /// The parts as one record in canonical order (docs/native-brep.md, "Canonical order"): shells + /// as [`Parts::shells`] groups them, a closed shell enclosing a region of its own; edges in + /// first appearance walking faces, loops and uses (backwards on a face used against its + /// natural normal), each edge's uses in that order; vertices in first appearance walking + /// edges, then vertex loops. + pub(crate) fn canonical(&self) -> Result { + if self.faces.is_empty() { + return Err(invalid("no faces")); + } + let shells = self.shells()?; + let order: Vec = shells.iter().flatten().copied().collect(); + let face = |fi: usize| self.faces.get(fi).ok_or_else(|| invalid("a face reference")); + let mut edge_new: Vec> = vec![None; self.edges.len()]; + let mut edges_old: Vec = Vec::new(); + let mut uses: Vec> = Vec::new(); + for (nf, &fi) in order.iter().enumerate() { + let (f, forward) = face(fi)?; + for (li, l) in f.loops.iter().enumerate() { + let b::Loop::Edges { edges } = l else { continue }; + let k = edges.len(); + for step in 0..k { + let p = if *forward { step } else { k - 1 - step }; + let Some(u) = edges.get(p) else { continue }; + let slot = edge_new.get_mut(u.edge as usize).ok_or_else(|| invalid("an edge reference"))?; + let ne = match *slot { + Some(ne) => ne, + None => { + let ne = index(edges_old.len())?; + *slot = Some(ne); + edges_old.push(u.edge as usize); + uses.push(Vec::new()); + ne + } + }; + let at = b::EdgeUseRef { face: index(nf)?, r#loop: index(li)?, position: index(p)?, upper: !u.forward }; + if let Some(list) = uses.get_mut(ne as usize) { + list.push(at); + } + } + } + } + let mut vertex_new: Vec> = vec![None; self.vertices.len()]; + let mut vertices: Vec = Vec::new(); + let mut visit = |v: u32, vertices: &mut Vec| -> Result { + let slot = vertex_new.get_mut(v as usize).ok_or_else(|| invalid("a vertex reference"))?; + if let Some(nv) = *slot { + return Ok(nv); + } + let nv = index(vertices.len())?; + *slot = Some(nv); + vertices.push(self.vertices.get(v as usize).cloned().ok_or_else(|| invalid("a vertex reference"))?); + Ok(nv) + }; + let mut edges = Vec::with_capacity(edges_old.len()); + for (old, uses) in edges_old.iter().zip(uses) { + let e = self.edges.get(*old).ok_or_else(|| invalid("an edge reference"))?; + let ends = [visit(e.vertices[0], &mut vertices)?, visit(e.vertices[1], &mut vertices)?]; + edges.push(b::Edge { vertices: ends, uses, ..e.clone() }); + } + let mut faces = Vec::with_capacity(order.len()); + for &fi in &order { + let (f, _) = face(fi)?; + let mut loops = Vec::with_capacity(f.loops.len()); + for l in &f.loops { + loops.push(match l { + b::Loop::Edges { edges } => b::Loop::Edges { + edges: edges + .iter() + .map(|u| { + let edge = edge_new.get(u.edge as usize).copied().flatten().ok_or_else(|| invalid("an edge reference"))?; + Ok(b::OrientedEdge { edge, ..u.clone() }) + }) + .collect::>()?, + }, + b::Loop::Vertex { vertex } => b::Loop::Vertex { vertex: visit(*vertex, &mut vertices)? }, + }); + } + faces.push(b::Face { loops, ..f.clone() }); + } + let mut numbered = 0usize; + let mut shell_records = Vec::with_capacity(shells.len()); + let mut regions = vec![b::Region { shells: Vec::new(), void: false }]; + for (s, members) in shells.iter().enumerate() { + let mut count: std::collections::HashMap = std::collections::HashMap::new(); + let mut list = Vec::with_capacity(members.len()); + for &fi in members { + let (f, forward) = face(fi)?; + for e in Parts::uses_of(f) { + *count.entry(e).or_default() += 1; + } + list.push(b::OrientedFace { face: index(numbered)?, forward: *forward }); + numbered += 1; + } + shell_records.push(b::Shell::Faces { faces: list, wires: Vec::new() }); + let s = index(s)?; + if let Some(infinite) = regions.first_mut() { + infinite.shells.push(s); + } + if count.values().all(|c| *c >= 2) { + regions.push(b::Region { shells: vec![s], void: false }); + } + } + let brep = Brep { version: b::VERSION, tolerance: self.tolerance, regions, shells: shell_records, faces, edges, vertices }; + brep.validate().map_err(|e| failed(format!("faces into a record: {e}")))?; + Ok(Canonical { brep, faces: order, edges: edges_old }) } } -/// A body from faces: a solid when they close, else a surface; with the history of the -/// operation that made the faces from `operands` as `rec` records. -pub(crate) fn body_of(faces: Vec, operands: &[&mt::Solid], rec: &Rebuild) -> Result { - let shell = mt::Shell::from(faces); - if shell.shell_condition() == mt::ShellCondition::Closed { - crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec) - } else { - Ok(rebuilt_body(surface_of(shell), operands, rec)) +/// Whether each face of `r` is used along its surface's natural normal (by its first oriented +/// face). +fn face_sides(r: &Brep) -> Vec { + let mut side = vec![None; r.faces.len()]; + for s in &r.shells { + if let b::Shell::Faces { faces, .. } = s { + for f in faces { + if let Some(slot) = side.get_mut(f.face as usize) { + slot.get_or_insert(f.forward); + } + } + } } + side.into_iter().map(|s| s.unwrap_or(true)).collect() } +/// The history of a record over `operands` operands: its faces' `faces`, each edge's origins as +/// `edge` gives them (from the edge's index, the faces it bounds, ascending, and `faces`). +fn record_history(r: &Brep, operands: usize, faces: Vec, edge: impl Fn(usize, &[usize], &[FaceProv]) -> Vec) -> History { + let between = r.edges.iter().map(|e| e.uses.iter().map(|u| u.face as usize).collect::>().into_iter().collect::>()); + let edges = between.enumerate().map(|(i, between)| EdgeProv { origins: edge(i, &between, &faces), faces: between }).collect(); + History { operands, faces, edges } +} + +/// The parameter box of a face's loops: the box of its pcurves' control points (each pcurve lies +/// within its control polygon). +fn uv_box(loops: &[b::Loop]) -> Option<[[f64; 2]; 2]> { + let mut bx = [[f64::INFINITY, f64::NEG_INFINITY]; 2]; + for l in loops { + let b::Loop::Edges { edges } = l else { continue }; + for u in edges { + let pts: Vec<[f64; 2]> = match &u.pcurve { + Curve2::Spline { points, .. } => points.clone(), + Curve2::RationalSpline { homogeneous, .. } => homogeneous.iter().map(|h| [h[0] / h[2], h[1] / h[2]]).collect(), + }; + for p in pts { + for (d, x) in bx.iter_mut().zip(p) { + d[0] = d[0].min(x); + d[1] = d[1].max(x); + } + } + } + } + bx.iter().all(|d| d[0].is_finite() && d[1] > d[0]).then_some(bx) +} + +/// A body of a record this module built, and whether it numbers its faces and edges as the +/// record does (so a history in the record's order is the body's). +fn record_body(r: &Brep) -> Result<(Body, bool)> { + imp::record_body(r) +} + +/// `body` with the history `h` over operands of `counts` faces and edges when its order is the +/// record's, else with none. +fn with_history(body: Body, same_order: bool, h: History, counts: &[(usize, usize)]) -> Result { + if same_order { body.with_history_counts(h, counts) } else { Ok(body.without_history()) } +} + +/// A surface body of the record `r` (faces another builder made) with no history. +pub(crate) fn surface_body(r: &Brep) -> Result { + record_body(r).map(|(body, _)| body.without_history()) +} + +/// The faces `picked` of `body` (its record `r`; each with whether to turn it over) as their own +/// body, each the body's face (Kept), every edge the body's. +fn taken(body: &Body, r: &Brep, picked: &[(usize, bool)]) -> Result { + let c = Parts::of(r, picked)?.canonical()?; + let faces = c.faces.iter().map(|p| picked.get(*p).map_or(0, |(f, _)| *f)).map(|f| record::from_face(0, f, Change::Kept)).collect(); + let h = record_history(&c.brep, 1, faces, |e, _, _| c.edges.get(e).map(|&edge| EdgeOrigin::Edge { operand: 0, edge }).into_iter().collect()); + let (result, same) = record_body(&c.brep)?; + with_history(result, same, h, &record::counts(&[body])) +} + +// ---- Patches ---------------------------------------------------------------------------- + /// A planar surface filling a sketch region. Its history has no operands: the face is new. pub fn patch_region(plane: &Plane, region: &Region2) -> Result { - let f = crate::build::face(plane, region)?; - let mut rec = Rebuild::default(); - rec.made(&f, Vec::new()); - Ok(rebuilt_body(surface_body(vec![f]), &[], &rec)) -} - -/// A chain's edge seeds numbered across all the chains: the segment at `i` of the chain that -/// starts at segment `offset` is segment `offset + i`. -fn numbered(seeds: &[(mt::EdgeID, usize)], offset: usize) -> Vec<(mt::EdgeID, Vec)> { - seeds.iter().map(|(id, i)| (*id, vec![offset + i])).collect() -} - -/// The faces truck swept from the edges `seeds`, each with the segments under the edge whose -/// column it lies in (none where the faces don't follow the pattern; see [`record::follow`]). -fn swept_sides(shell: &mt::Shell, seeds: &[(mt::EdgeID, Vec)], fixed: &[mt::VertexID]) -> Vec { - let provs = record::columns(shell.face_iter(), seeds, None, record::Column::ToEnd, fixed); - shell.face_iter().enumerate().map(|(k, f)| (f.clone(), provs.as_ref().and_then(|p| p.get(k).cloned().flatten()))).collect() -} - -/// A body of a sheet builder's faces: a surface, or a solid where they close; with the history -/// of a body built from the chains' segments (none unless every face's is known). -fn sheet_body(sides: Vec) -> Result { - let history: Option> = sides.iter().map(|(_, p)| p.clone()).collect(); - let shell = mt::Shell::from(sides.into_iter().map(|(f, _)| f).collect::>()); - if shell.shell_condition() == mt::ShellCondition::Closed { - return match crate::body::Solid::try_new(vec![shell.clone()]) { - Ok(solid) => record::attach(Body::new(solid)?, &[], history, Default::default()), - Err(_) => crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), &[], &Rebuild::default()), - }; - } - record::attach(surface_of(shell), &[], history, Default::default()) -} - -/// Most curve chains one surface extrude takes. -const MAX_CHAINS: usize = 10_000; - -/// A surface extruded from chains of sketch segments on `plane`: each chain (open, or closed -/// such as a profile's boundary) moved straight from `start` to `end` along the plane normal -/// (offsets in mm). The walls it sweeps form one surface body with no caps. Its history has no -/// operands: every face is new. -pub fn extrude_sheet(plane: &Plane, chains: &[Vec], start: f64, end: f64) -> Result { if !crate::build::frame_ok(plane) { - return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into())); + return Err(invalid("patch frame is not finite and orthonormal")); } - if !(start.is_finite() && end.is_finite() && start.abs() < 1e7 && end.abs() < 1e7) { - return Err(KernelError::Invalid("extrude distance is not finite".into())); - } - let (lo, hi) = if end >= start { (start, end) } else { (end, start) }; - if hi - lo < 1e-6 { - return Err(KernelError::Invalid("extrude distance is zero".into())); - } - if chains.is_empty() || chains.len() > MAX_CHAINS { - return Err(KernelError::Invalid("pick the curves or profiles to extrude".into())); - } - let base = plane.offset(lo); - let along = crate::body::v3(plane.normal() * (hi - lo)); - let mut sides = Vec::new(); - let mut offset = 0; - for chain in chains { - let (wire, seeds) = crate::build::chain_wire_seeds(&base, chain)?; - let walls: mt::Shell = guard("surface extrude", || Ok(mt::builder::tsweep(&wire, along)))?; - sides.extend(swept_sides(&walls, &numbered(&seeds, offset), &[])); - offset += chain.len(); - } - sheet_body(sides) + let tolerance = tolerance_of(region.outer.segs.iter().map(|s| plane.to_world(s.start()))); + let mut parts = Parts { tolerance, ..Parts::default() }; + // Seen from the plane's normal the outer loop runs counter-clockwise, holes clockwise. + let holes = region.holes.iter().map(|h| h.ccw().reversed()); + let loops = std::iter::once(region.outer.ccw()).chain(holes).map(|lp| sketch_loop(&mut parts, plane, &lp.segs)).collect::>>()?; + let domain = uv_box(&loops).ok_or_else(|| invalid("the region has no area"))?; + parts.faces.push((b::Face { surface: crate::freeform::plane_surface(plane), domain, loops }, true)); + let c = parts.canonical()?; + let h = record_history(&c.brep, 0, vec![record::generated(Vec::new())], |_, _, _| Vec::new()); + let (body, same) = record_body(&c.brep)?; + with_history(body, same, h, &[]) } -/// A surface revolved from chains of sketch segments on `plane` about the sketch line through -/// `axis_origin` along `axis_dir`: each chain turned from `start` through `sweep` radians (a -/// full turn at most), positive angles carrying it toward the sketch normal. A chain end on the -/// axis stays put there (a cone's apex, a sphere's pole). The walls form one surface body with -/// no caps, or a solid where they close (a full turn of a closed curve, or of a curve from the -/// axis back to it); its history has no operands: every face is new. -pub fn revolve_sheet(plane: &Plane, chains: &[Vec], axis_origin: Vec2, axis_dir: Vec2, start: f64, sweep: f64) -> Result { - use std::f64::consts::TAU; - if !crate::build::frame_ok(plane) || !axis_origin.is_finite() || !axis_dir.is_finite() { - return Err(KernelError::Invalid("revolve frame is not finite and orthonormal".into())); +/// A record tolerance for geometry spanning `points`: 10⁻⁶ of its size, at least 10⁻⁵ mm (the +/// kernel's working tolerance). +fn tolerance_of(points: impl Iterator) -> f64 { + let mut bx = solidcraft_geom::Aabb3::EMPTY; + for p in points { + bx.add(p); } - if !(start.is_finite() && sweep.is_finite() && start.abs() <= TAU && sweep.abs() <= TAU + 1e-9) { - return Err(KernelError::Invalid("revolve angles must be finite, a full turn at most".into())); - } - if sweep.abs() < 1e-6 { - return Err(KernelError::Invalid("revolve angle is zero".into())); - } - if chains.is_empty() || chains.len() > MAX_CHAINS { - return Err(KernelError::Invalid("pick the curves or profiles to revolve".into())); - } - let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?; - let side = |p: Vec2| d.cross(p - axis_origin); - let on_axis = |p: Vec2| side(p).abs() < 1e-7; - let (origin, n) = (plane.to_world(axis_origin), plane.normal()); - let axis = plane.dir_to_world(d).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?; - let mut sides = Vec::new(); - let mut offset = 0; - for chain in chains { - // On one side of the axis; the point farthest from it fixes the turning sense. - let pts: Vec = chain.iter().flat_map(|s| s.polyline(1e-3)).collect(); - if pts.iter().any(|p| side(*p) < -1e-7) && pts.iter().any(|p| side(*p) > 1e-7) { - return Err(KernelError::Invalid("the curves cross the revolve axis".into())); - } - let far = pts.iter().copied().max_by(|a, b| side(*a).abs().total_cmp(&side(*b).abs())).filter(|p| !on_axis(*p)); - let Some(far) = far else { return Err(KernelError::Invalid("the curves lie on the revolve axis".into())) }; - let radial = plane.to_world(far) - origin; - let a = if axis.cross(radial).dot(n) < 0.0 { -axis } else { axis }; - let (first, last) = match (chain.first(), chain.last()) { - (Some(f), Some(l)) => (f.start(), l.end()), - _ => return Err(KernelError::Invalid("an empty curve chain".into())), - }; - let closed = first.dist(last) <= 1e-6; - if closed && pts.iter().any(|p| on_axis(*p)) { - return Err(KernelError::Invalid("not supported yet: a closed curve touching the revolve axis".into())); - } - // The chain starts `start` round: its sketch frame turned there, so the wire's own edges - // are the ones swept (and its faces can be followed back to them). - let turn = |v: Vec3| crate::build::rotate_about(v, Vec3::ZERO, a, start); - let turned = Plane { origin: crate::build::rotate_about(plane.origin, origin, a, start), x: turn(plane.x), y: turn(plane.y) }; - let (wire, seeds) = crate::build::chain_wire_seeds(&turned, chain)?; - let fixed: Vec = [(on_axis(first), wire.front_vertex()), (on_axis(last), wire.back_vertex())] - .into_iter() - .filter_map(|(apex, v)| v.filter(|_| apex).map(|v| v.id())) - .collect(); - let walls: mt::Shell = guard("surface revolve", || { - Ok(match (on_axis(first), on_axis(last)) { - (true, _) => mt::builder::cone(&wire, crate::body::v3(a), mt::Rad(sweep)), - (false, true) => mt::builder::cone(&wire.inverse(), crate::body::v3(a), mt::Rad(sweep)), - (false, false) => mt::builder::rsweep(&wire, p3(origin), crate::body::v3(a), mt::Rad(sweep)), - }) - })?; - sides.extend(swept_sides(&walls, &numbered(&seeds, offset), &fixed)); - offset += chain.len(); - } - sheet_body(sides) + (bx.diagonal() * 1e-6).max(1e-5) } -/// A surface swept from chains of sketch segments on `plane` along a path that starts near -/// them. Along a path whose direction runs on across every joint, lines slide the chains and -/// arcs turn them, exactly; any other path (curves followed as polylines) goes through -/// [`sweep_sheet_points`]. The walls form one surface body with no caps (a solid where they -/// close); its history has no operands: every face is new. -pub fn sweep_sheet(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg]) -> Result { - use crate::PathSeg; - if !crate::build::frame_ok(plane) { - return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into())); +/// One closed loop of sketch segments on `plane` added to `parts` as an edge loop on the plane's +/// surface: one edge per segment, each segment's start a vertex. +fn sketch_loop(parts: &mut Parts, plane: &Plane, segs: &[Seg2]) -> Result { + if segs.is_empty() || segs.len() > 100_000 { + return Err(invalid("a loop needs 1…100000 curves")); } - if chains.is_empty() || chains.len() > MAX_CHAINS { - return Err(KernelError::Invalid("pick the curves or profiles to sweep".into())); + let v0 = parts.vertices.len(); + for s in segs { + parts.vertices.push(b::Vertex { point: a3(plane.to_world(s.start())), tolerance: parts.tolerance }); } - let path = &PathSeg::flattened(path)[..]; - if path.is_empty() || path.len() > 1000 { - return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); + let mut edges = Vec::with_capacity(segs.len()); + for (i, s) in segs.iter().enumerate() { + let (curve, pcurve) = crate::freeform::segment(plane, s)?; + let vertices = [index(v0 + i)?, index(v0 + (i + 1) % segs.len())?]; + let edge = index(parts.edges.len())?; + parts.edges.push(b::Edge { curve, interval: crate::freeform::INTERVAL, vertices, tolerance: parts.tolerance, uses: Vec::new() }); + edges.push(b::OrientedEdge { edge, forward: true, pcurve }); } - let ends: Vec<(Vec3, Vec3)> = - path.iter().map(PathSeg::tangents).collect::>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?; - if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) { - return sweep_sheet_points(plane, chains, &crate::build::path_polyline(path)?); - } - let mut sides = Vec::new(); - let mut offset = 0; - for chain in chains { - let (mut cur, seeds) = crate::build::chain_wire_seeds(plane, chain)?; - // Each edge's segments: the next stretch of the path starts from the edges the last one - // carried them onto, in the same order. - let mut seeds = numbered(&seeds, offset); - for seg in path { - let (part, map): (mt::Shell, Box Vec3>) = match *seg { - PathSeg::Line { a, b } => { - (guard("surface sweep", || Ok(mt::builder::tsweep(&cur, crate::body::v3(b - a))))?, Box::new(move |p| p + (b - a))) - } - PathSeg::Arc { center, axis, angle, .. } => { - let k = axis.normalized().ok_or_else(|| KernelError::Invalid("a path arc has no axis".into()))?; - let part = guard("surface sweep", || Ok(mt::builder::rsweep(&cur, p3(center), crate::body::v3(k), mt::Rad(angle))))?; - (part, Box::new(move |p| crate::build::rotate_about(p, center, k, angle))) - } - // The path was flattened: no curves are left. - PathSeg::Curve { .. } | PathSeg::Bezier(_) => return Err(KernelError::Invalid("a free-form path curve".into())), - }; - let next = far_wire(&part, &cur, map.as_ref())?; - sides.extend(swept_sides(&part, &seeds, &[])); - seeds = next.edge_iter().map(|e| e.id()).zip(seeds.into_iter().map(|(_, n)| n)).collect(); - cur = next; - } - offset += chain.len(); - } - sheet_body(sides) + Ok(b::Loop::Edges { edges }) } -/// A surface swept from chains of sketch segments on `plane` along a 3D polyline path: one smooth -/// B-spline wall per segment where the path has no corners, else ruled walls between the path's -/// points, mitred at the corners (see [`sweep_sheet`]). -pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result { - sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default()) -} - -/// [`sweep_sheet`] with sweep options. The default options are exactly [`sweep_sheet`]; any -/// other orientation, a twist or an end scale follow the path as a polyline through stations -/// (see [`sweep_sheet_points_with`]). -pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result { - if ctl.is_default() { - return sweep_sheet(plane, chains, path); - } - let path = &crate::PathSeg::flattened(path)[..]; - ctl.check()?; - if path.is_empty() || path.len() > 1000 { - return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); - } - if path.iter().any(|s| s.tangents().is_none()) { - return Err(KernelError::Invalid("a path segment has no direction".into())); - } - sweep_sheet_points_with(plane, chains, &crate::build::path_polyline(path)?, ctl) -} - -/// [`sweep_sheet_points`] with sweep options: orientation, twist and end scale. -pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec], path: &[Vec3], ctl: &crate::SweepControl) -> Result { - ctl.check()?; - if !crate::build::frame_ok(plane) { - return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into())); - } - if chains.is_empty() || chains.len() > MAX_CHAINS { - return Err(KernelError::Invalid("pick the curves or profiles to sweep".into())); - } - let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path, ctl)?)?; - // Smooth walls where the path has no corners, kept when their area agrees with the ruled - // walls' (a fit that went wrong falls back to those). - if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path, ctl) - && let Ok(smooth) = sheet_body(sides) - && let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled)) - && (a.area - b.area).abs() <= 0.02 * b.area.abs() - { - return Ok(smooth); - } - Ok(ruled) -} - -/// The far side of a sweep of the wire `start`: for each of its edges, in order and the same -/// way round, the edge of `swept` that `map` carries it onto (matched by its ends and middle). -fn far_wire(swept: &mt::Shell, start: &mt::Wire, map: &dyn Fn(Vec3) -> Vec3) -> Result { - let near = |p: Vec3, q: Vec3| p.dist(q) <= 1e-6 * (1.0 + p.len().max(q.len())); - let mid = |e: &mt::Edge| { - let c = e.oriented_curve(); - let (t0, t1) = c.range_tuple(); - from_p3(c.subs((t0 + t1) / 2.0)) - }; - let mut out = Vec::new(); - for e in start.edge_iter() { - let (a, b, m) = (map(from_p3(e.front().point())), map(from_p3(e.back().point())), map(mid(e))); - let found = swept.edge_iter().find_map(|f| { - let (fa, fb) = (from_p3(f.front().point()), from_p3(f.back().point())); - if !near(mid(&f), m) { - None - } else if near(fa, a) && near(fb, b) { - Some(f) - } else if near(fa, b) && near(fb, a) { - Some(f.inverse()) - } else { - None - } - }); - out.push(found.ok_or_else(|| KernelError::Failed("sweep: lost the curves along the path".into()))?); - } - Ok(out.into()) -} - -/// A surface lofted through two or more sections, each a chain of sketch segments on its plane, -/// all open or all closed: ruled between two sections, one smooth face per segment through -/// three or more. Closed sections are matched as the solid loft matches them; open ones are -/// split to the same number of segments (longest first) and each turned to run the way the one -/// before it does. Walls only, no caps; the history has no operands: every face is new. -pub fn loft_sheet(sections: &[(Plane, Vec)]) -> Result { - loft_sheet_with_rails(sections, &[], crate::RailFit::Scale) -} - -/// [`loft_sheet`] following one or two guide rails (polylines from the first section to the last, -/// through every section's curve; see [`crate::loft_rails`]), `fit` saying how they reshape the -/// sections between. Without rails, the plain sheet loft. -pub fn loft_sheet_with_rails(sections: &[(Plane, Vec)], rails: &[Vec], fit: crate::RailFit) -> Result { - if sections.len() < 2 || sections.len() > 50 { - return Err(KernelError::Invalid("a loft needs 2…50 sections".into())); - } - let closed: Vec = sections - .iter() - .map(|(_, c)| match (c.first(), c.last()) { - (Some(f), Some(l)) if c.len() <= 4096 => Ok(l.end().dist(f.start()) <= 1e-6), - _ => Err(KernelError::Invalid("a loft section has no curves, or too many".into())), - }) - .collect::>()?; - let all_closed = closed.iter().all(|c| *c); - if !all_closed && closed.iter().any(|c| *c) { - return Err(KernelError::Invalid("the loft sections must be all open or all closed".into())); - } - for (plane, chain) in sections { - // Checks the frame, the numbers and that each chain runs end to end. - if !crate::build::frame_ok(plane) { - return Err(KernelError::Invalid("a loft section's frame is not finite and orthonormal".into())); - } - crate::build::chain_wire(plane, chain)?; - } - let (wires, sources, reversed) = if all_closed { - let loops: Vec<(Plane, solidcraft_geom::Loop2)> = sections.iter().map(|(p, c)| (*p, solidcraft_geom::Loop2 { segs: c.clone() })).collect(); - crate::build::loft_wires(&loops)? - } else { - let n = sections.iter().map(|(_, c)| c.len()).max().unwrap_or(0); - let mut wires = Vec::with_capacity(sections.len()); - let mut sources = Vec::with_capacity(sections.len()); - let mut prev: Option<(Vec3, Vec3)> = None; - // Segments are numbered through the sections in the order given. - let mut offset = 0; - for (plane, chain) in sections { - // Each piece with the segment it is (part of). - let mut segs: Vec<(Seg2, usize)> = chain.iter().enumerate().map(|(i, s)| (*s, offset + i)).collect(); - offset += chain.len(); - // Split the longest segment in half until the counts match. - while segs.len() < n { - let Some(i) = segs.iter().enumerate().max_by(|a, b| a.1.0.length().total_cmp(&b.1.0.length())).map(|(i, _)| i) else { break }; - let (s, k) = segs[i]; - let (a, b) = s.split_half(); - segs.splice(i..=i, [(a, k), (b, k)]); - } - let ends = |c: &[(Seg2, usize)]| c.first().zip(c.last()).map(|(f, l)| (plane.to_world(f.0.start()), plane.to_world(l.0.end()))); - if let (Some((ps, pe)), Some((s, e))) = (prev, ends(&segs)) - && s.dist(ps) + e.dist(pe) > s.dist(pe) + e.dist(ps) - { - segs = segs.iter().rev().map(|(s, k)| (s.reversed(), *k)).collect(); - } - prev = ends(&segs); - let pieces: Vec = segs.iter().map(|(s, _)| *s).collect(); - wires.push(crate::build::uniform_chain_wire(plane, &pieces, false)?); - sources.push(segs.iter().map(|(_, k)| vec![*k]).collect::>()); - } - (wires, sources, false) - }; - let mut planes: Vec = sections.iter().map(|(p, _)| *p).collect(); - if reversed { - planes.reverse(); - } - let faces = guard("surface loft", || { - let all = crate::loft_rails::railed_wires(&planes, &wires, all_closed, rails, fit)?; - // A plain loft falls back to ruled faces where the smooth one fails; a railed one has to - // follow its rails. - let smooth = if all.len() >= 3 { Some(crate::build::smooth_loft_faces(&all, &sources, all_closed)) } else { None }; - match smooth { - Some(Ok(f)) => Ok(f), - Some(Err(e)) if !rails.is_empty() => Err(e), - _ => crate::build::ruled_loft_faces(&wires, &sources), - } - })?; - sheet_body(faces) -} - -/// The edge of a body nearest a point (by samples along each edge). -pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option { - let mut best: Option<(f64, mt::Edge)> = None; - for e in body.solid.edge_iter() { - let c = e.curve(); - let (t0, t1) = c.range_tuple(); +/// The edge of a body nearest a point (by samples along each edge), within 5% of the body's size: +/// its index in the body's edge order. +pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option { + let r = crate::native::brep_record(body).ok()?; + let mut best: Option<(f64, usize)> = None; + for (i, e) in r.edges.iter().enumerate() { + let Ok(c) = CurveEval::new(&e.curve) else { continue }; + let [t0, t1] = e.interval; for k in 0..=32 { - let d = from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 32.0)).dist(p); - if best.as_ref().is_none_or(|(bd, _)| d < *bd) { - best = Some((d, e.clone())); + let Some(q) = c.point(t0 + (t1 - t0) * k as f64 / 32.0) else { continue }; + let d = q.dist(p); + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, i)); } } } best.filter(|(d, _)| *d <= body.size() * 0.05 + 1e-6).map(|(_, e)| e) } +/// A chain of a body's edges: each edge (its index in the body's edge order) and whether the +/// chain runs along it. +pub(crate) type Chain = Vec<(usize, bool)>; + /// A surface filling a closed loop of a body's edges (each picked by a point on it): one face on -/// the body's own edges, planar when the loop is certainly planar ([`crate::plane_fit`]), else a -/// B-spline height field over the loop's plane ([`crate::fill`]; loops that do not bound one are -/// refused). Its history is over `[body]`: the face is new, its edges the body's own. +/// the body's own edges, planar when every edge's curve lies in one plane by its own definition +/// (its control points, or its circle's frame), else a B-spline patch ([`crate::fill`]; loops that +/// do not bound one are refused). Its history is over `[body]`: the face is new, its edges the +/// body's own. pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result { body.require_brep("patch")?; if at.is_empty() || at.len() > 10_000 { - return Err(KernelError::Invalid("pick the edges of a closed loop".into())); + return Err(invalid("pick the edges of a closed loop")); } - guard("patch", || { - let chain = picked_loop(body, at)?; - let Some(face) = crate::plane_fit::planar_face(&chain, crate::fill::planarity_tolerance(body))? else { - // Not certainly planar: a fitted surface. - return crate::fill::fill_loop(body, &chain); - }; - // A new face on the body's own edges. - let mut rec = Rebuild::default(); - rec.made(&face, Vec::new()); - Ok(rebuilt_body(surface_body(vec![face]), &[&body.solid], &rec)) - }) + let r = crate::native::brep_record(body)?; + let chain = picked_loop(body, at)?; + let Some(plane) = chain_plane(&r, &chain, crate::fill::planarity_tolerance(body))? else { + return crate::fill::fill_loop(body, &chain); + }; + let surface = crate::freeform::plane_surface(&plane); + let mut parts = Parts { tolerance: r.tolerance, vertices: r.vertices.clone(), ..Parts::default() }; + parts.edges = r.edges.iter().map(|e| b::Edge { uses: Vec::new(), ..e.clone() }).collect(); + let mut uses = Vec::with_capacity(chain.len()); + for &(e, forward) in &chain { + let edge = r.edges.get(e).ok_or_else(|| invalid("an edge reference"))?; + uses.push(b::pcurve::EdgeUse { + edge: index(e)?, + curve: &edge.curve, + interval: edge.interval, + forward, + tolerance: edge.tolerance, + given: None, + }); + } + let pcurves = b::pcurve::pcurves(&surface, &[uses], r.tolerance).map_err(|e| failed(format!("patch: {e}")))?; + let edges = chain + .iter() + .zip(pcurves.into_iter().flatten()) + .map(|(&(e, forward), pcurve)| Ok(b::OrientedEdge { edge: index(e)?, forward, pcurve })) + .collect::>>()?; + let loops = vec![b::Loop::Edges { edges }]; + let domain = uv_box(&loops).ok_or_else(|| invalid("the loop encloses no area"))?; + parts.faces.push((b::Face { surface, domain, loops }, true)); + let c = parts.canonical()?; + let h = record_history(&c.brep, 1, vec![record::generated(Vec::new())], |e, _, _| { + c.edges.get(e).map(|&edge| EdgeOrigin::Edge { operand: 0, edge }).into_iter().collect() + }); + let (result, same) = record_body(&c.brep)?; + with_history(result, same, h, &record::counts(&[body])) } -/// The edges of `body` picked by points on them, chained into one closed loop (each turned to -/// run along it). Call it under [`guard`]: sampling an intersection curve can fail in truck. -pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result> { +/// The plane every curve of `chain` lies in by its definition, within `tol` (lines and splines by +/// their control points, circles and ellipses by their frames), its axes turned so that the chain +/// runs counter-clockwise about its normal; `None` when there is none. +fn chain_plane(r: &Brep, chain: &Chain, tol: f64) -> Result> { + let edge = |e: usize| r.edges.get(e).ok_or_else(|| invalid("an edge reference")); + let mut points: Vec = Vec::new(); + let mut axes: Vec<(Vec3, Vec3)> = Vec::new(); + for &(e, _) in chain { + match &edge(e)?.curve { + b::Curve::Line { start, end } => points.extend([v3(*start), v3(*end)]), + b::Curve::Polyline { points: p } | b::Curve::Spline { points: p, .. } => points.extend(p.iter().map(|q| v3(*q))), + b::Curve::RationalSpline { homogeneous, .. } => { + points.extend(homogeneous.iter().map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]))) + } + b::Curve::Circle { frame, .. } | b::Curve::Ellipse { frame, .. } => { + points.push(v3(frame.origin)); + axes.push((v3(frame.origin), v3(frame.x_axis).cross(v3(frame.y_axis)))); + } + b::Curve::SurfaceIntersection { .. } => return Ok(None), + } + } + let Some(&p0) = points.first() else { return Ok(None) }; + // The normal: a circle's axis, else the widest cross product of the points about the first. + let normal = axes.first().map(|(_, n)| *n).or_else(|| { + let far = points.iter().copied().max_by(|a, c| a.dist(p0).total_cmp(&c.dist(p0)))?; + points.iter().map(|q| (far - p0).cross(*q - p0)).max_by(|a, c| a.len().total_cmp(&c.len())) + }); + let Some(n) = normal.and_then(|n| n.normalized()) else { return Ok(None) }; + let on = |q: Vec3| (q - p0).dot(n).abs() <= tol; + if !points.iter().all(|q| on(*q)) || axes.iter().any(|(c, a)| !on(*c) || a.cross(n).len() > 1e-9 * a.len().max(1.0)) { + return Ok(None); + } + // Which way round the chain runs about `n`: the turn of points along it. + let mut ring: Vec = Vec::new(); + for &(e, forward) in chain { + let x = edge(e)?; + let c = CurveEval::new(&x.curve).map_err(|err| failed(format!("patch: {err}")))?; + let [t0, t1] = x.interval; + for k in 0..16 { + let f = k as f64 / 16.0; + ring.push(c.point(if forward { t0 + (t1 - t0) * f } else { t1 - (t1 - t0) * f }).ok_or_else(|| failed("patch: an edge has no point"))?); + } + } + let turn: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).map(|(a, c)| (*a - p0).cross(*c - p0).dot(n)).sum(); + let n = if turn < 0.0 { n * -1.0 } else { n }; + let x = (if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).cross(n).normalized().ok_or_else(|| failed("patch plane"))?; + Ok(Plane::new(p0, x, n.cross(x))) +} + +/// The edges of `body` picked by points on them, chained into one closed loop (each turned to run +/// along it). +pub(crate) fn picked_loop(body: &Body, at: &[Vec3]) -> Result { match picked_chain(body, at)? { (chain, true) => Ok(chain), - (_, false) => Err(KernelError::Invalid("the edges do not form a closed loop".into())), + (_, false) => Err(invalid("the edges do not form a closed loop")), } } -/// The edges of `body` picked by points on them, chained end to end (each turned to run along -/// the chain), and whether the chain closes: a closed loop starts with the last edge picked; an -/// open chain runs from the free end met first in pick order. Edges that branch, or form more -/// than one chain, are refused. Call it under [`guard`], as [`picked_loop`]. -pub(crate) fn picked_chain(body: &Body, at: &[Vec3]) -> Result<(Vec, bool)> { - let mut edges: Vec = Vec::new(); +/// The edges of `body` picked by points on them, chained end to end (each turned to run along the +/// chain), and whether the chain closes: a closed loop starts with the last edge picked; an open +/// chain runs from the free end met first in pick order. Edges that branch, or form more than one +/// chain, are refused. +pub(crate) fn picked_chain(body: &Body, at: &[Vec3]) -> Result<(Chain, bool)> { + let r = crate::native::brep_record(body)?; + let mut edges: Vec = Vec::new(); for p in at { - let e = edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?; - if !edges.iter().any(|x| x.id() == e.id()) { + let e = edge_near(body, *p).ok_or_else(|| invalid(format!("no edge at {p:?}")))?; + if !edges.contains(&e) { edges.push(e); } } - let mut uses: HashMap = HashMap::new(); - for e in &edges { - *uses.entry(e.front().id()).or_default() += 1; - *uses.entry(e.back().id()).or_default() += 1; + let ends = |e: usize| r.edges.get(e).map(|x| x.vertices); + let mut uses: std::collections::HashMap = std::collections::HashMap::new(); + for &e in &edges { + for v in ends(e).ok_or_else(|| invalid("an edge reference"))? { + *uses.entry(v).or_default() += 1; + } } if uses.values().any(|n| *n > 2) { - return Err(KernelError::Invalid("the edges branch: pick one chain".into())); + return Err(invalid("the edges branch: pick one chain")); } - let free = |v: &mt::Vertex| uses.get(&v.id()) == Some(&1); - let open_end = edges.iter().find_map(|e| [e.front(), e.back()].into_iter().find(|v| free(v)).cloned()); + let open_end = edges.iter().find_map(|&e| ends(e)?.into_iter().find(|v| uses.get(v) == Some(&1))); let mut pool = edges; - let (first, closed) = match &open_end { + let (first, closed) = match open_end { Some(v) => { - let k = pool.iter().position(|e| e.front().id() == v.id() || e.back().id() == v.id()); - let e = k.map(|k| pool.remove(k)).ok_or_else(|| KernelError::Invalid("no edges".into()))?; - (if e.front().id() == v.id() { e } else { e.inverse() }, false) + let k = pool.iter().position(|&e| ends(e).is_some_and(|x| x.contains(&v))).ok_or_else(|| invalid("no edges"))?; + let e = pool.remove(k); + ((e, ends(e).is_some_and(|x| x[0] == v)), false) } - None => (pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?, true), + None => ((pool.pop().ok_or_else(|| invalid("no edges"))?, true), true), }; - let start = first.front().id(); - let mut at_v = first.back().clone(); + let head = |(e, forward): (usize, bool)| ends(e).map(|x| if forward { x[1] } else { x[0] }); + let start = ends(first.0).map(|x| if first.1 { x[0] } else { x[1] }); + let mut at_v = head(first); let mut chain = vec![first]; - while !(closed && at_v.id() == start) { - let Some(k) = pool.iter().position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id()) else { break }; + while !(closed && at_v == start) { + let Some(v) = at_v else { break }; + let Some(k) = pool.iter().position(|&e| ends(e).is_some_and(|x| x.contains(&v))) else { break }; let e = pool.remove(k); - let e = if e.front().id() == at_v.id() { e } else { e.inverse() }; - at_v = e.back().clone(); - chain.push(e); + let link = (e, ends(e).is_some_and(|x| x[0] == v)); + at_v = head(link); + chain.push(link); } if !pool.is_empty() { let what = if closed { "the edges form more than one loop" } else { "the edges form more than one chain" }; - return Err(KernelError::Invalid(what.into())); + return Err(invalid(what)); } Ok((chain, closed)) } -/// Faces of a body copied out as a surface body (faces picked by points on them). Its history is -/// over `[body]`: the faces and their edges are the body's. -pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result { - body.require_brep("copy faces")?; - let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); - let mut chosen = Vec::new(); - for p in at { - // The face whose mesh passes nearest the point. - let i = nearest_face(body, *p)?; - if !chosen.contains(&i) { - chosen.push(i); +// ---- Faces of a body -------------------------------------------------------------------- + +/// Index of the face nearest a point (over its mesh triangles). +pub(crate) fn nearest_face(body: &Body, p: Vec3) -> Result { + let m = body.mesh(&crate::MeshTolerance::chord(body.size() * 2e-3))?; + let mut best: Option<(f64, u32)> = None; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([a, b, c]) = m.tri(t) else { continue }; + let d = ((a + b + c) / 3.0).dist(p).min(a.dist(p)).min(b.dist(p)).min(c.dist(p)); + if best.is_none_or(|(x, _)| d < x) { + best = Some((d, *f)); } } - let copied = surface_body(chosen.into_iter().filter_map(|i| faces.get(i).map(|f| (*f).clone())).collect()); - Ok(rebuilt_body(copied, &[&body.solid], &Rebuild::default())) + best.map(|(_, f)| f as usize).ok_or_else(|| invalid("no face there")) } -/// The faces picked by points on them (each once, in the order picked); every face when no -/// point is given. +/// The faces picked by points on them (each once, in the order picked); every face when no point +/// is given. fn picked_faces(body: &Body, at: &[Vec3]) -> Result> { if at.len() > 10_000 { - return Err(KernelError::Invalid("too many faces picked".into())); + return Err(invalid("too many faces picked")); } if at.is_empty() { return Ok((0..body.face_count()).collect()); @@ -542,663 +630,825 @@ fn picked_faces(body: &Body, at: &[Vec3]) -> Result> { Ok(chosen) } -/// A body's faces taken apart: each picked face (by points on it; every face when none are -/// given) its own surface body, and the faces left, if any, one surface body. A solid comes -/// apart into surfaces. Histories are over `[body]`: each face is the body's. -pub fn unstitch(body: &Body, at: &[Vec3]) -> Result<(Vec, Option)> { - body.require_brep("unstitch")?; - let chosen = picked_faces(body, at)?; - let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); - if faces.len() < 2 { - return Err(KernelError::Invalid("the body has one face: nothing to unstitch".into())); +/// Faces of a body copied out as a surface body (faces picked by points on them). Its history is +/// over `[body]`: the faces and their edges are the body's. +pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result { + body.require_brep("copy faces")?; + if at.is_empty() { + return Err(invalid("pick the faces to copy")); } - let piece = |picked: Vec| rebuilt_body(surface_body(picked), &[&body.solid], &Rebuild::default()); - let pieces: Vec = chosen.iter().filter_map(|i| faces.get(*i)).map(|f| piece(vec![(*f).clone()])).collect(); - let rest: Vec = faces.iter().enumerate().filter(|(i, _)| !chosen.contains(i)).map(|(_, f)| (*f).clone()).collect(); - Ok((pieces, (!rest.is_empty()).then(|| piece(rest)))) + let chosen = picked_faces(body, at)?; + let r = crate::native::brep_record(body)?; + taken(body, &r, &chosen.into_iter().map(|f| (f, false)).collect::>()) } -/// A surface body with the picked faces (by points on them; every face when none are given) -/// turned over: their normals point the other way. A solid is refused (its faces face out). -/// The history is over `[body]`: each face is the body's, turned over or not. +/// A body's faces taken apart: each picked face (by points on it; every face when none are given) +/// its own surface body, and the faces left, if any, one surface body. A solid comes apart into +/// surfaces. Histories are over `[body]`: each face is the body's. +pub fn unstitch(body: &Body, at: &[Vec3]) -> Result<(Vec, Option)> { + body.require_brep("unstitch")?; + let r = crate::native::brep_record(body)?; + if r.faces.len() < 2 { + return Err(invalid("the body has one face: nothing to unstitch")); + } + let chosen = picked_faces(body, at)?; + let pieces = chosen.iter().map(|f| taken(body, &r, &[(*f, false)])).collect::>>()?; + let rest: Vec<(usize, bool)> = (0..r.faces.len()).filter(|f| !chosen.contains(f)).map(|f| (f, false)).collect(); + let rest = if rest.is_empty() { None } else { Some(taken(body, &r, &rest)?) }; + Ok((pieces, rest)) +} + +/// A surface body with the picked faces (by points on them; every face when none are given) turned +/// over: their normals point the other way. A solid is refused (its faces face out). The history +/// is over `[body]`: each face is the body's, turned over or not. pub fn reverse_faces(body: &Body, at: &[Vec3]) -> Result { body.require_brep("reverse normal")?; if !body.is_surface() { - return Err(KernelError::Invalid("a solid's faces face out: unstitch it into surfaces first".into())); + return Err(invalid("a solid's faces face out: unstitch it into surfaces first")); } let chosen = picked_faces(body, at)?; - let mut rec = Rebuild::default(); - let faces: Vec = body - .solid - .face_iter() - .enumerate() - .map(|(i, f)| { - if !chosen.contains(&i) { - return f.clone(); - } - let turned = f.inverse(); - rec.whole(&turned, f); - turned - }) - .collect(); - Ok(rebuilt_body(surface_body(faces), &[&body.solid], &rec)) + let r = crate::native::brep_record(body)?; + taken(body, &r, &(0..r.faces.len()).map(|f| (f, chosen.contains(&f))).collect::>()) } -/// A body with some of its faces (indices in its face order) taken away and the gap left open: -/// a surface body of the faces left. Deleting every face is refused. The history is over -/// `[body]`: each face left is the body's. +/// A body with some of its faces (indices in its face order) taken away and the gap left open: a +/// surface body of the faces left. Deleting every face is refused. The history is over `[body]`: +/// each face left is the body's. pub fn remove_faces(body: &Body, faces: &[usize]) -> Result { body.require_brep("Delete Face")?; if faces.is_empty() { - return Err(KernelError::Invalid("no faces given".into())); + return Err(invalid("no faces given")); } - let left: Vec = body.solid.face_iter().enumerate().filter(|(i, _)| !faces.contains(i)).map(|(_, f)| f.clone()).collect(); + let r = crate::native::brep_record(body)?; + let left: Vec<(usize, bool)> = (0..r.faces.len()).filter(|f| !faces.contains(f)).map(|f| (f, false)).collect(); if left.is_empty() { - return Err(KernelError::Invalid("that would delete every face of the body".into())); + return Err(invalid("that would delete every face of the body")); } - Ok(rebuilt_body(surface_body(left), &[&body.solid], &Rebuild::default())) + taken(body, &r, &left) } -/// Index of the face nearest a point (over its mesh triangles). -pub(crate) fn nearest_face(body: &Body, p: Vec3) -> Result { - let m = body.mesh(&crate::MeshTolerance::chord(body.size() * 2e-3))?; - let mut best: Option<(f64, u32)> = None; - for (t, f) in m.triangles.iter().zip(&m.tri_face) { - let Some([a, b, c]) = m.tri(t) else { continue }; - let d = ((a + b + c) / 3.0).dist(p).min(a.dist(p)).min(b.dist(p)).min(c.dist(p)); - if best.is_none_or(|(x, _)| d < x) { - best = Some((d, *f)); +/// `h`, a history over one record whose faces and edges came from the parts `c` numbers, carried +/// to the records `offsets` took them from: each face origin through `face`, each edge origin to +/// the record edge it was (`None` where an origin has no record behind it). +fn carried(h: &History, c: &Canonical, offsets: &Offsets, operands: usize, face: impl Fn(usize, usize) -> FaceOrigin) -> Option { + let back = |o: FaceOrigin| -> std::result::Result { + match o { + FaceOrigin::Face { face: f, .. } => c.faces.get(f).and_then(|p| owner(offsets, *p, 0)).map(|(k, i)| face(k, i)).ok_or(()), + other => Ok(other), } - } - best.map(|(_, f)| f as usize).ok_or_else(|| KernelError::Invalid("no face there".into())) + }; + let faces = h + .faces + .iter() + .map(|f| Some(FaceProv { origins: f.origins.iter().map(|o| back(*o).ok()).collect::>()?, change: f.change })) + .collect::>>()?; + let edges = h + .edges + .iter() + .map(|e| { + let origins = e + .origins + .iter() + .map(|o| match o { + EdgeOrigin::Edge { edge, .. } => { + let (operand, edge) = c.edges.get(*edge).and_then(|p| owner(offsets, *p, 1))?; + Some(EdgeOrigin::Edge { operand, edge }) + } + EdgeOrigin::Meet { a, b } => Some(EdgeOrigin::Meet { a: a.try_map(back).ok()?, b: b.try_map(back).ok()? }), + }) + .collect::>()?; + Some(EdgeProv { faces: e.faces.clone(), origins }) + }) + .collect::>>()?; + Some(History { operands, faces, edges }) } -/// Sew surface bodies along edges that coincide (within `tol`): one body, a solid when the -/// faces close up. Faces are turned so that neighbours agree. The result's history is over -/// `bodies`: each face the one it was sewn from, each edge the edges sewn into it. +/// Sew surface bodies along edges that coincide (within `tol`; the bodies' working tolerance when +/// not positive): one body, a solid when the faces close up. SMLib turns the faces so that +/// neighbours agree (outward on a solid). The result's history is over `bodies`: each face the one +/// it was sewn from, each edge the edges sewn into it. pub fn stitch(bodies: &[&Body], tol: f64) -> Result { if bodies.is_empty() { - return Err(KernelError::Invalid("nothing to stitch".into())); + return Err(invalid("nothing to stitch")); } for b in bodies { b.require_brep("stitch")?; } let size = bodies.iter().map(|b| b.size()).fold(0.0, f64::max); let tol = if tol.is_finite() && tol > 0.0 { tol } else { size * 1e-6 + 1e-6 }; - guard("stitch", || { - let faces: Vec = bodies.iter().flat_map(|b| b.solid.face_iter().cloned().collect::>()).collect(); - // Shared vertices by position, shared edges by their ends and middle. - let mut verts: Vec = Vec::new(); - let mut vmap: HashMap = HashMap::new(); - let mut canon = |v: &mt::Vertex, verts: &mut Vec| -> mt::Vertex { - if let Some(c) = vmap.get(&v.id()) { - return c.clone(); - } - let p = v.point(); - let c = match verts.iter().find(|u| u.point().distance(p) <= tol) { - Some(u) => u.clone(), - None => { - verts.push(v.clone()); - v.clone() - } - }; - vmap.insert(v.id(), c.clone()); - c - }; - let mut edges: Vec<(mt::Edge, mt::Point3)> = Vec::new(); - let mut out = Vec::with_capacity(faces.len()); - let mut rec = Rebuild::default(); - for f in &faces { - let mut wires = Vec::new(); - for w in f.absolute_boundaries() { - let mut nw = Vec::new(); - for e in w.edge_iter() { - let abs = if e.orientation() { e.clone() } else { e.inverse() }; - let (a, b) = (canon(abs.front(), &mut verts), canon(abs.back(), &mut verts)); - let c = abs.curve(); - let (t0, t1) = c.range_tuple(); - let mid = c.subs(0.5 * (t0 + t1)); - let found = edges.iter().find_map(|(x, m)| { - if m.distance(mid) > tol * 10.0 { - return None; - } - if x.front().id() == a.id() && x.back().id() == b.id() { - Some(x.clone()) - } else if x.front().id() == b.id() && x.back().id() == a.id() { - Some(x.inverse()) - } else { - None - } - }); - let ne = match found { - Some(x) => x, - None => { - let x = mt::Edge::new_unchecked(&a, &b, c); - edges.push((x.clone(), mid)); - x - } - }; - rec.piece(&ne, &abs); - nw.push(if e.orientation() { ne } else { ne.inverse() }); - } - wires.push(mt::Wire::from(nw)); - } - let mut nf = mt::Face::new_unchecked(wires, f.surface()); - if !f.orientation() { - nf.invert(); - } - rec.whole(&nf, f); - out.push(nf); - } - // Neighbours run their shared edges opposite ways. - let mut uses: HashMap> = HashMap::new(); - for (i, f) in out.iter().enumerate() { - for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) { - uses.entry(e.id()).or_default().push((i, e.orientation())); - } - } - let mut flip: Vec> = vec![None; out.len()]; - for s in 0..out.len() { - if flip[s].is_some() { - continue; - } - flip[s] = Some(false); - let mut stack = vec![s]; - while let Some(i) = stack.pop() { - let fl = flip[i].unwrap_or(false); - let Some(f) = out.get(i) else { continue }; - for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) { - let d = e.orientation() != fl; - for &(j, dj) in uses.get(&e.id()).into_iter().flatten() { - if j != i && flip[j].is_none() { - flip[j] = Some(dj == d); - stack.push(j); - } - } - } - } - } - let out: Vec = out.into_iter().zip(flip).map(|(f, fl)| if fl == Some(true) { f.inverse() } else { f }).collect(); - let operands: Vec<&mt::Solid> = bodies.iter().map(|b| &*b.solid).collect(); - body_of(out, &operands, &rec) - }) + let records = bodies.iter().map(|b| crate::native::brep_record(b)).collect::>>()?; + let (parts, offsets) = Parts::joined(&records.iter().collect::>())?; + let c = parts.canonical()?; + let (body, h) = imp::stitched(&c.brep, tol)?; + let h = h.and_then(|h| carried(&h, &c, &offsets, bodies.len(), |operand, face| FaceOrigin::Face { operand, face })); + Ok(body.with_checked_history(h, &record::counts(bodies))) } -/// How a face moves along its normal: a plane slides, a circular cylinder changes radius. -#[derive(Clone, Copy)] -pub(crate) enum Offset { - Plane { n: Vec3 }, - Cylinder { o: Vec3, axis: UnitVec3, r: f64, out: f64 }, -} - -impl Offset { - pub(crate) fn of(f: &mt::Face) -> Option { - match crate::splitface::analytic_field(&f.oriented_surface())? { - crate::splitface::Analytic::Plane { n } => Some(Offset::Plane { n }), - crate::splitface::Analytic::Cylinder { o, axis, r, out } => Some(Offset::Cylinder { o, axis, r, out }), - } - } - pub(crate) fn map(&self, p: Vec3, d: f64) -> Vec3 { - match *self { - Offset::Plane { n } => p + n * d, - // Scaled about the axis (control points of a NURBS circle stay exact). - Offset::Cylinder { o, axis, r, out } => { - let v = p - o; - let radial = v - axis * v.dot(axis); - p + radial * (out * d / r) - } - } - } -} - -fn map_pt(off: &Offset, p: mt::Point3, d: f64) -> mt::Point3 { - p3(off.map(from_p3(p), d)) -} - -/// A curve moved by an offset (control points mapped; exact for lines, and for circles square -/// to a cylinder's axis or in a plane). -pub(crate) fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result { - if let Some(moved) = map_control_points(c, |p| off.map(p, d)) { - return Ok(moved); - } - // Anything else: a cubic through moved samples. - let (t0, t1) = c.range_tuple(); - let pts: Vec = (0..=32).map(|k| off.map(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 32.0)), d)).collect(); - Ok(mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?)) -} - -/// A line, B-spline or NURBS curve with its control points mapped by `f` (the exact image of -/// the curve under an affine `f`); `None` for a curve without control points (an -/// intersection curve). -pub(crate) fn map_control_points(c: &mt::Curve, f: impl Fn(Vec3) -> Vec3) -> Option { - let map = |p: mt::Point3| p3(f(from_p3(p))); - Some(match c { - mt::Curve::Line(l) => mt::Curve::Line(mt::Line(map(l.0), map(l.1))), - mt::Curve::BSplineCurve(b) => { - let pts: Vec = b.control_points().iter().map(|p| map(*p)).collect(); - mt::Curve::BSplineCurve(mt::BSplineCurve::new(b.knot_vec().clone(), pts)) - } - mt::Curve::NurbsCurve(n) => { - let nb = n.non_rationalized(); - let pts: Vec = nb - .control_points() - .iter() - .map(|h| { - let w = h.w; - let q = map(mt::Point3::new(h.x / w, h.y / w, h.z / w)); - mt::Vector4::new(q.x * w, q.y * w, q.z * w, w) - }) - .collect(); - mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(nb.knot_vec().clone(), pts))) - } - mt::Curve::IntersectionCurve(_) => return None, - }) -} - -/// A surface moved by an offset (control points mapped; exact for planes and NURBS cylinders). -pub(crate) fn map_surface(off: &Offset, s: &mt::Surface, d: f64) -> Result { - Ok(match s { - mt::Surface::Plane(pl) => { - let (o, a, b) = (pl.origin(), pl.u_axis(), pl.v_axis()); - let o2 = map_pt(off, o, d); - mt::Surface::Plane(mt::Plane::new(o2, o2 + a, o2 + b)) - } - mt::Surface::BSplineSurface(b) => { - let rows: Vec> = b.control_points().iter().map(|r| r.iter().map(|p| map_pt(off, *p, d)).collect()).collect(); - mt::Surface::BSplineSurface(mt::BSplineSurface::new((b.uknot_vec().clone(), b.vknot_vec().clone()), rows)) - } - mt::Surface::NurbsSurface(n) => { - let nb = n.non_rationalized(); - let rows: Vec> = nb - .control_points() - .iter() - .map(|r| { - r.iter() - .map(|h| { - let w = h.w; - let q = map_pt(off, mt::Point3::new(h.x / w, h.y / w, h.z / w), d); - mt::Vector4::new(q.x * w, q.y * w, q.z * w, w) - }) - .collect() - }) - .collect(); - mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new((nb.uknot_vec().clone(), nb.vknot_vec().clone()), rows))) - } - _ => return Err(KernelError::Invalid("not supported yet: thickening this kind of surface".into())), - }) -} - -/// How far Thicken's fitted faces may stray from the true offset (mm). -const THICKEN_TOLERANCE: f64 = 0.01; - /// A surface body thickened into a solid: offset along the faces' normals by `thickness` -/// (negative: against them), or by half each way when `symmetric`. Planes and circular -/// cylinders move exactly, other faces onto fitted surfaces (see `surface_offset`); a thickness -/// that would fold a face over is refused. Neighbouring faces must meet smoothly (their offsets -/// agree). +/// (negative: against them), or by half each way when `symmetric`, by SMLib's thicken +/// ([`crate::offset_backend::thicken`]). pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result { body.require_brep("thicken")?; - #[cfg(feature = "smlib")] - if crate::backend()? == crate::Backend::Smlib { - return crate::offset_backend::thicken(body, thickness, symmetric); - } - if !(thickness.is_finite() && thickness.abs() > 1e-9) { - return Err(KernelError::Invalid("thickness must be non-zero".into())); - } - let (d0, d1) = if symmetric { (-thickness / 2.0, thickness / 2.0) } else { (0.0, thickness) }; - let (lo, hi) = (d0.min(d1), d0.max(d1)); - let size = body.size(); - let tol = size * 1e-7 + 1e-9; - guard("thicken", || { - let faces: Vec = body.solid.face_iter().cloned().collect(); - if faces.is_empty() { - return Err(KernelError::Invalid("nothing to thicken".into())); - } - // Planes and circular cylinders move exactly; other faces onto surfaces fitted within the - // offset tolerance (Offset Surface's). - let offs: Vec = - faces.iter().map(|f| crate::surface_offset::FaceMover::new(f, &[lo, hi], THICKEN_TOLERANCE)).collect::>()?; - let near = |a: Vec3, b: Vec3| a.dist(b) <= tol * 1e3; - // Each side's copies of every vertex and edge. Faces that move one to the same place share - // the copy (they meet smoothly there); across a sharp edge each face has its own. - let mut vcopies: [HashMap>; 2] = [HashMap::new(), HashMap::new()]; - let mut ecopies: [HashMap>; 2] = [HashMap::new(), HashMap::new()]; - // Each face's copies, by side: (face, vertex) → vertex, (face, edge) → edge. - let mut vof: [HashMap<(usize, mt::VertexID), mt::Vertex>; 2] = [HashMap::new(), HashMap::new()]; - let mut eof: [HashMap<(usize, mt::EdgeID), mt::Edge>; 2] = [HashMap::new(), HashMap::new()]; - // The faces using each edge, in order. - let mut users: HashMap> = HashMap::new(); - let mut abs_edges: HashMap = HashMap::new(); - for (fi, (f, off)) in faces.iter().zip(&offs).enumerate() { - for e in f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()) { - let abs = if e.orientation() { e.clone() } else { e.inverse() }; - let list = users.entry(abs.id()).or_default(); - if !list.contains(&fi) { - list.push(fi); - } - abs_edges.entry(abs.id()).or_insert_with(|| abs.clone()); - for (side, d) in [lo, hi].into_iter().enumerate() { - for v in [abs.front(), abs.back()] { - if vof[side].contains_key(&(fi, v.id())) { - continue; - } - let at = off.point(from_p3(v.point()), d)?; - let list = vcopies[side].entry(v.id()).or_default(); - let copy = match list.iter().find(|c| near(from_p3(c.point()), at)) { - Some(c) => c.clone(), - None => { - let c = mt::Vertex::new(p3(at)); - list.push(c.clone()); - c - } - }; - vof[side].insert((fi, v.id()), copy); - } - let (Some(a), Some(b)) = (vof[side].get(&(fi, abs.front().id())).cloned(), vof[side].get(&(fi, abs.back().id())).cloned()) else { - continue; - }; - let c = abs.curve(); - let (t0, t1) = c.range_tuple(); - let mid = off.point(from_p3(c.subs(0.5 * (t0 + t1))), d)?; - let list = ecopies[side].entry(abs.id()).or_default(); - let shared = list.iter().find(|m| { - m.front().id() == a.id() && m.back().id() == b.id() && { - let mc = m.curve(); - let (s0, s1) = mc.range_tuple(); - near(from_p3(mc.subs(0.5 * (s0 + s1))), mid) - } - }); - let copy = match shared { - Some(m) => m.clone(), - None => { - let m = mt::Edge::new_unchecked(&a, &b, off.curve(&c, d)?); - list.push(m.clone()); - m - } - }; - eof[side].insert((fi, abs.id()), copy); - } - } - } - // Lines joining a vertex's copy on one side to its copy on the other, once each. - let mut risers: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new(); - let mut riser = |a: &mt::Vertex, b: &mt::Vertex| -> mt::Edge { - risers.entry((a.id(), b.id())).or_insert_with(|| mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())))).clone() - }; - let moved_face = |fi: usize, f: &mt::Face, off: &crate::surface_offset::FaceMover, side: usize, d: f64| -> Result { - let wires: Vec = f - .absolute_boundaries() - .iter() - .map(|w| { - w.edge_iter() - .filter_map(|e| { - let abs_id = if e.orientation() { e.id() } else { e.inverse().id() }; - let m = eof[side].get(&(fi, abs_id))?.clone(); - Some(if e.orientation() { m } else { m.inverse() }) - }) - .collect::>() - .into() - }) - .collect(); - let mut nf = mt::Face::new_unchecked(wires, off.surface(d)?); - if !f.orientation() { - nf.invert(); - } - Ok(nf) - }; - let mut out = Vec::new(); - // History (one operand, the surface): a copy at no distance is the face itself, a copy - // further out its offset, a wall is made along the edge it stands on, a corner patch from - // the sharp edges that meet at its corner. - let operand = crate::record::Operand::new(body); - let edge_index = &operand.edges; - let mut history = Vec::new(); - let copy = |i: usize, d: f64| { - if d == 0.0 { - crate::record::from_face(0, i, Change::Kept) - } else { - crate::record::generated(vec![FaceOrigin::Offset { operand: 0, face: i }]) - } - }; - let edge_origin = |id: &mt::EdgeID| -> Result { - let edge = edge_index.get(id).copied().ok_or_else(|| KernelError::Failed("thicken: an edge outside the surface".into()))?; - Ok(FaceOrigin::Edge { operand: 0, edge }) - }; - for (i, (f, off)) in faces.iter().zip(&offs).enumerate() { - // The lower copy faces back, the upper one forward. - out.push(moved_face(i, f, off, 0, lo)?.inverse()); - out.push(moved_face(i, f, off, 1, hi)?); - history.extend([copy(i, lo), copy(i, hi)]); - // Walls along free edges, outward across the edge. - for w in f.boundaries() { - for e in w.edge_iter() { - let abs_id = if e.orientation() { e.id() } else { e.inverse().id() }; - if users.get(&abs_id).map_or(0, Vec::len) != 1 { - continue; - } - let (Some(le), Some(he)) = (eof[0].get(&(i, abs_id)), eof[1].get(&(i, abs_id))) else { continue }; - let (le, he) = if e.orientation() { (le.clone(), he.clone()) } else { (le.inverse(), he.inverse()) }; - let (sa, sb) = (riser(le.front(), he.front()), riser(le.back(), he.back())); - // A → B low, B → B' up, B' → A' high back, A' → A down. - let wire: mt::Wire = vec![le.clone(), sb, he.inverse(), sa.inverse()].into(); - let ruled = mt::builder::homotopy(&le, &he); - let surf = ruled.surface(); - // The wall's outward normal: across the edge, away from the face. - let c = le.oriented_curve(); - let (t0, t1) = c.range_tuple(); - let tm = 0.5 * (t0 + t1); - let p = c.subs(tm); - let tangent = c.der(tm); - let n = - f.oriented_surface().search_nearest_parameter(p, None, 50).map(|(u, v)| f.oriented_surface().normal(u, v)).unwrap_or(tangent); - let outward = tangent.cross(n); - let sn = surf.search_nearest_parameter(p, None, 50).map(|(u, v)| surf.normal(u, v)).unwrap_or(outward); - let wall = if sn.dot(outward) >= 0.0 { - mt::Face::new_unchecked(vec![wire], surf) - } else { - let mut x = mt::Face::new_unchecked(vec![wire.inverse()], surf); - x.invert(); - x - }; - out.push(wall); - history.push(crate::record::generated(vec![edge_origin(&abs_id)?])); - } - } - } - // Sharp edges: where the two faces' copies of an edge part on a side, a wall closes the - // gap; where they would cross (an inside corner on that side), refused. - let mut sharp_at: HashMap> = HashMap::new(); - let mut gaps = Vec::new(); - for (id, fs) in &users { - let [fa, fb] = fs.as_slice() else { continue }; - let Some(abs) = abs_edges.get(id) else { continue }; - for (side, d) in [lo, hi].into_iter().enumerate() { - let (Some(ea), Some(eb)) = (eof[side].get(&(*fa, *id)), eof[side].get(&(*fb, *id))) else { continue }; - if ea.id() == eb.id() { - continue; - } - // Which way the faces bend across the edge: each lies behind the other's normal - // (convex toward the normals) or in front of it. - let c = abs.curve(); - let (t0, t1) = c.range_tuple(); - let p = from_p3(c.subs(0.5 * (t0 + t1))); - let normal = |fi: usize| -> Option { - let s = faces.get(fi)?.oriented_surface(); - let (u, v) = s.search_nearest_parameter(p3(p), None, 50)?; - Some(from_p3(mt::Point3::new(0.0, 0.0, 0.0) + s.normal(u, v))) - }; - let centre = |fi: usize| -> Option { - let pts: Vec = - faces.get(fi)?.absolute_boundaries().iter().flat_map(|w| w.vertex_iter().map(|v| from_p3(v.point()))).collect(); - Some(pts.iter().fold(Vec3::ZERO, |a, q| a + *q) * (1.0 / pts.len().max(1) as f64)) - }; - let (Some(na), Some(ca), Some(cb)) = (normal(*fa), centre(*fa), centre(*fb)) else { - return Err(KernelError::Failed("thicken: the faces at a sharp edge".into())); - }; - // Convex toward the normals: the other face falls away behind this one's normal. - let convex = na.dot(cb - p) < 0.0 && normal(*fb).is_some_and(|nb| nb.dot(ca - p) < 0.0); - if convex != (d > 0.0) { - return Err(KernelError::Invalid( - "not supported yet: thickening into an inside corner across a sharp edge (try the other side, or symmetric off)".into(), - )); - } - gaps.push((*id, side, ea.clone(), eb.clone())); - for v in [abs.front(), abs.back()] { - let list = sharp_at.entry(v.id()).or_default(); - if !list.contains(id) { - list.push(*id); - } - } - } - } - // Each gap wall runs along one face's copy of the edge and back along the other's, joined - // at the ends by lines between the two copies of each end vertex. It is turned so that it - // crosses the first face's copy the other way from that face. - let mut links: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new(); - let mut link = |a: &mt::Vertex, b: &mt::Vertex| -> mt::Edge { - if let Some(e) = links.get(&(b.id(), a.id())) { - return e.inverse(); - } - links.entry((a.id(), b.id())).or_insert_with(|| mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())))).clone() - }; - let used_forward = |faces: &[mt::Face], e: &mt::Edge| -> Option { - faces - .iter() - .flat_map(|f| f.boundaries().into_iter().flat_map(|w| w.edge_iter().cloned().collect::>())) - .find(|x| x.id() == e.id()) - .map(|x| x.front().id() == e.front().id()) - }; - for (id, _side, ea, eb) in gaps { - let wire: mt::Wire = vec![ea.clone(), link(ea.back(), eb.back()), eb.inverse(), link(eb.front(), ea.front())].into(); - let surf = mt::builder::homotopy(&ea, &eb).surface(); - let wall = mt::Face::new_unchecked(vec![wire], surf); - let wall = if used_forward(&out, &ea).unwrap_or(false) { wall.inverse() } else { wall }; - out.push(wall); - history.push(crate::record::generated(vec![edge_origin(&id)?])); - } - // Corners: whatever is left open is closed by plane patches (where three faces meet at a - // sharp corner, a triangle), each from the sharp edges meeting there. - let open = mt::Shell::from(out.clone()).extract_boundaries(); - for w in open { - let patch = mt::builder::try_attach_plane(&[w.inverse()]) - .map_err(|_| KernelError::Invalid("not supported yet: thickening a corner where the gap does not close flat".into()))?; - let mut origins = Vec::new(); - for v in w.vertex_iter() { - for (vid, list) in &sharp_at { - let at = vcopies.iter().flat_map(|m| m.get(vid).into_iter().flatten()).any(|c| c.id() == v.id()); - if at { - for e in list { - let o = edge_origin(e)?; - if !origins.contains(&o) { - origins.push(o); - } - } - } - } - } - if origins.is_empty() { - return Err(KernelError::Failed("the thickened surface does not close".into())); - } - out.push(patch); - history.push(crate::record::generated(origins)); - } - let shell = mt::Shell::from(out); - if shell.shell_condition() != mt::ShellCondition::Closed { - return Err(KernelError::Failed("the thickened surface does not close".into())); - } - // The edges copied at no distance are the surface's own edges, rebuilt. - let mut copies = HashMap::new(); - for (side, d) in [lo, hi].into_iter().enumerate() { - if d != 0.0 { - continue; - } - for (id, list) in &ecopies[side] { - if let Some(&edge) = edge_index.get(id) { - for c in list { - copies.insert(c.id(), EdgeOrigin::Edge { operand: 0, edge }); - } - } - } - } - crate::record::attach(Body::new(mt::Solid::new_unchecked(vec![shell]))?, &[operand], Some(history), copies) - }) + imp::thicken(body, thickness, symmetric) } -/// A planar surface extended: the chosen straight edges (picked by points on them) move -/// outward in the face's plane by `distance`, their neighbours lengthened to meet them. The -/// result's history is over `[body]`: the face and each edge the one it was moved from. +/// The faces `picked` of `body` (indices in its face order) offset one by one by SMLib, each onto +/// its surface's offset by `distance` along its normal, and stitched where their offsets meet +/// within `tolerance` (neighbours meeting smoothly stay joined; across a sharp edge they come +/// apart): see [`crate::offset_surface`]. A distance of zero copies the faces. Each face is the +/// offset of the one it came from. +pub(crate) fn offset_faces(body: &Body, picked: &[usize], distance: f64, tolerance: f64) -> Result { + let r = crate::native::brep_record(body)?; + if distance == 0.0 { + return taken(body, &r, &picked.iter().map(|f| (*f, false)).collect::>()); + } + let moved = picked.iter().map(|f| imp::offset_face(&r, *f, distance)).collect::>>()?; + let (parts, offsets) = Parts::joined(&moved.iter().collect::>())?; + let c = parts.canonical()?; + let (result, h) = imp::stitched(&c.brep, tolerance.max(c.brep.tolerance))?; + let offset = |k: usize, _: usize| FaceOrigin::Offset { operand: 0, face: picked.get(k).copied().unwrap_or(0) }; + let faces = h + .and_then(|h| carried(&h, &c, &offsets, 1, offset)) + .map(|h| h.faces.into_iter().map(|f| FaceProv { change: Change::Generated, ..f }).collect::>()); + let rec = crate::native::brep_record(&result)?; + let h = faces.map(|faces| record_history(&rec, 1, faces, |_, between, faces| record::meeting(between, faces))); + Ok(result.with_checked_history(h, &record::counts(&[body]))) +} + +/// A planar surface extended: the chosen straight edges (picked by points on them) move outward in +/// the face's plane by `distance`, their neighbours lengthened to meet them. The result's history +/// is over `[body]`: the face is the body's face rebuilt, each edge the one it was moved from. pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result { body.require_brep("extend")?; if !(distance.is_finite() && distance > 0.0) { - return Err(KernelError::Invalid("the extension distance must be positive".into())); + return Err(invalid("the extension distance must be positive")); } - let faces: Vec = body.solid.face_iter().cloned().collect(); - let [face] = faces.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface of more than one face".into())) }; - let Some(crate::splitface::Analytic::Plane { n }) = crate::splitface::analytic_field(&face.oriented_surface()) else { - return Err(KernelError::Invalid("not supported yet: extending a surface that is not planar".into())); + let r = crate::native::brep_record(body)?; + let [face] = r.faces.as_slice() else { return Err(invalid("not supported yet: extending a surface of more than one face")) }; + let (origin, du, dv) = match face.surface { + b::Surface::Plane { origin, u_axis, v_axis } => (v3(origin), v3(u_axis), v3(v_axis)), + b::Surface::PlaneThrough { origin, u_point, v_point } => (v3(origin), v3(u_point) - v3(origin), v3(v_point) - v3(origin)), + _ => return Err(invalid("not supported yet: extending a surface that is not planar")), }; - let chosen: Vec = at + // The outer loop runs counter-clockwise about the surface's natural normal. + let n = du.cross(dv).normalized().ok_or_else(|| invalid("a planar face with no normal"))?; + let [b::Loop::Edges { edges: rim }] = face.loops.as_slice() else { return Err(invalid("not supported yet: extending a surface with holes")) }; + let chosen: Vec = at.iter().map(|p| edge_near(body, *p).ok_or_else(|| invalid(format!("no edge at {p:?}")))).collect::>()?; + let m = rim.len(); + // Each edge as a line (point, direction along the loop), moved outward when chosen. + let mut lines = Vec::with_capacity(m); + for u in rim { + let e = r.edges.get(u.edge as usize).ok_or_else(|| invalid("an edge reference"))?; + let b::Curve::Line { start, end } = e.curve else { return Err(invalid("not supported yet: extending a surface with curved edges")) }; + let at_t = |t: f64| v3(start) + (v3(end) - v3(start)) * t; + let (a, z) = if u.forward { (at_t(e.interval[0]), at_t(e.interval[1])) } else { (at_t(e.interval[1]), at_t(e.interval[0])) }; + let d = (z - a).normalized().ok_or_else(|| invalid("a zero-length edge"))?; + // Outward: right of the edge seen along the normal (the face is on its left). + let shift = if chosen.contains(&(u.edge as usize)) { distance } else { 0.0 }; + lines.push((a + d.cross(n) * shift, d)); + } + // Corners: where consecutive lines meet. + let meet = |(p, d): (Vec3, Vec3), (q, e): (Vec3, Vec3)| -> Option { + let c = d.cross(e); + let cc = c.dot(c); + (cc >= 1e-24).then(|| p + d * ((q - p).cross(e).dot(c) / cc)) + }; + let mut parts = Parts { tolerance: r.tolerance, ..Parts::default() }; + for i in 0..m { + let (Some(prev), Some(cur)) = (lines.get((i + m - 1) % m), lines.get(i)) else { continue }; + parts.vertices.push(b::Vertex { point: a3(meet(*prev, *cur).unwrap_or(cur.0)), tolerance: r.tolerance }); + } + for i in 0..m { + let (Some(a), Some(z)) = (parts.vertices.get(i), parts.vertices.get((i + 1) % m)) else { continue }; + let curve = b::Curve::Line { start: a.point, end: z.point }; + parts.edges.push(b::Edge { + curve, + interval: [0.0, 1.0], + vertices: [index(i)?, index((i + 1) % m)?], + tolerance: r.tolerance, + uses: Vec::new(), + }); + } + let uses = parts + .edges .iter() - .map(|p| edge_near(body, *p).map(|e| e.id()).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))) - .collect::>()?; - guard("extend", || { - let bounds = face.boundaries(); - let [outer] = bounds.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface with holes".into())) }; - let edges: Vec = outer.edge_iter().cloned().collect(); - let m = edges.len(); - // Each edge as a line (point, direction) moved outward when chosen. - let mut lines = Vec::with_capacity(m); - for e in &edges { - let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point())); - if !matches!(e.curve(), mt::Curve::Line(_)) { - return Err(KernelError::Invalid("not supported yet: extending a surface with curved edges".into())); - } - let d = (b - a).normalized().ok_or_else(|| KernelError::Invalid("a zero-length edge".into()))?; - // Outward: right of the edge seen along the normal (the face is on its left). - let out = d.cross(n); - let shift = if chosen.contains(&e.id()) || chosen.contains(&e.inverse().id()) { distance } else { 0.0 }; - lines.push((a + out * shift, d)); + .enumerate() + .map(|(i, e)| { + Ok(b::pcurve::EdgeUse { edge: index(i)?, curve: &e.curve, interval: e.interval, forward: true, tolerance: e.tolerance, given: None }) + }) + .collect::>>()?; + let surface = b::Surface::Plane { origin: a3(origin), u_axis: a3(du), v_axis: a3(dv) }; + let pcurves = b::pcurve::pcurves(&surface, &[uses], r.tolerance).map_err(|e| failed(format!("extend: {e}")))?; + let edges = pcurves + .into_iter() + .flatten() + .enumerate() + .map(|(i, pcurve)| Ok(b::OrientedEdge { edge: index(i)?, forward: true, pcurve })) + .collect::>>()?; + let loops = vec![b::Loop::Edges { edges }]; + let domain = uv_box(&loops).ok_or_else(|| invalid("the extended face has no area"))?; + let forward = face_sides(&r).first().copied().unwrap_or(true); + parts.faces.push((b::Face { surface, domain, loops }, forward)); + let c = parts.canonical()?; + let h = record_history(&c.brep, 1, vec![record::from_face(0, 0, Change::Modified)], |e, _, _| { + c.edges.get(e).and_then(|p| rim.get(*p)).map(|u| EdgeOrigin::Edge { operand: 0, edge: u.edge as usize }).into_iter().collect() + }); + let (result, same) = record_body(&c.brep)?; + with_history(result, same, h, &record::counts(&[body])) +} + +// ---- Sheets swept from sketch curves ------------------------------------------------------ + +/// Most curve chains one surface sweep takes. +const MAX_CHAINS: usize = 10_000; + +/// A sketch segment with its number across all the chains (chain after chain). +type Numbered = (Seg2, usize); + +/// The chains' segments, numbered on across the chains. +fn numbered(chains: &[Vec]) -> Vec> { + let mut next = 0; + chains + .iter() + .map(|c| { + c.iter() + .map(|s| { + next += 1; + (*s, next - 1) + }) + .collect() + }) + .collect() +} + +fn check_chains(chains: &[Vec], what: &str) -> Result<()> { + if chains.is_empty() || chains.len() > MAX_CHAINS { + return Err(invalid(format!("pick the curves or profiles to {what}"))); + } + for c in chains { + if c.is_empty() || c.len() > 4096 { + return Err(invalid("a curve chain has no curves, or too many")); } - // Corners: where consecutive lines meet. - let meet = |(p, d): (Vec3, Vec3), (q, e): (Vec3, Vec3)| -> Option { - let w = q - p; - let c = d.cross(e); - let cc = c.dot(c); - if cc < 1e-24 { - return None; - } - Some(p + d * (w.cross(e).dot(c) / cc)) - }; - let mut corners = Vec::with_capacity(m); - for i in 0..m { - let (Some(prev), Some(cur)) = (lines.get((i + m - 1) % m), lines.get(i)) else { continue }; - let p = meet(*prev, *cur).unwrap_or(cur.0); - corners.push(mt::Vertex::new(p3(p))); + if c.iter().any(|s| !(s.start().is_finite() && s.end().is_finite())) { + return Err(invalid("a curve chain is not finite")); } - let mut rec = Rebuild::default(); - let wire: mt::Wire = (0..m) - .filter_map(|i| { - let (a, b, old) = (corners.get(i)?, corners.get((i + 1) % m)?, edges.get(i)?); - let e = mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point()))); - rec.piece(&e, old); - Some(e) + if c.windows(2).any(|w| w[0].end().dist(w[1].start()) > 1e-6) { + return Err(invalid("a curve chain has a gap: its curves must run end to end")); + } + } + Ok(()) +} + +/// The faces of the sheet `r` swept from `pieces` that start on `start`: each face comes from the +/// pieces its edges lying in `start` run along (the edges' middles on the pieces' curves), none +/// where no edge does. +fn swept_from(r: &Brep, start: &Plane, pieces: &[Numbered]) -> Vec { + let mut from: Vec> = vec![BTreeSet::new(); r.faces.len()]; + for e in &r.edges { + let Some(mid) = CurveEval::new(&e.curve).ok().and_then(|c| c.point(0.5 * (e.interval[0] + e.interval[1]))) else { continue }; + let near = 4.0 * e.tolerance.max(r.tolerance); + if start.height(mid).abs() > near { + continue; + } + let o = mid - start.origin; + let q = Vec2::new(o.dot(start.x), o.dot(start.y)); + for (s, number) in pieces { + if s.point_at(s.closest_param(q)).dist(q) <= near { + for u in &e.uses { + if let Some(set) = from.get_mut(u.face as usize) { + set.insert(*number); + } + } + } + } + } + from.iter().map(|set| if set.is_empty() { record::generated(Vec::new()) } else { record::profile(set.iter()) }).collect() +} + +/// A sheet of SMLib's pieces (each a record with its faces' history), joined into one record and, +/// with `stitch`, sewn where pieces share edges (at ten times the pieces' tolerance). +fn sheet_of(pieces: Vec<(Brep, Vec)>, stitch: bool) -> Result { + let records: Vec<&Brep> = pieces.iter().map(|(r, _)| r).collect(); + let (parts, offsets) = Parts::joined(&records)?; + let c = parts.canonical()?; + let prov = |part: usize| owner(&offsets, part, 0).and_then(|(k, i)| pieces.get(k)?.1.get(i).cloned()); + let faces: Option> = c.faces.iter().map(|p| prov(*p)).collect(); + if !stitch { + let (body, same) = record_body(&c.brep)?; + return record::attach(body, &[], faces.filter(|_| same), Default::default()); + } + let (body, h) = imp::stitched(&c.brep, c.brep.tolerance * 10.0)?; + // A stitched face keeps the tag of the joined record's face it was. + let sewn = h.zip(faces).and_then(|(h, faces)| { + h.faces + .iter() + .map(|f| match f.origins.as_slice() { + [FaceOrigin::Face { face, .. }] => faces.get(*face).cloned(), + _ => None, }) - .collect::>() - .into(); - let mut f = mt::builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("extend: {e}")))?; - if f.oriented_surface().normal(0.0, 0.0).dot(crate::body::v3(n)) < 0.0 { - f = f.inverse(); + .collect::>>() + }); + record::attach(body, &[], sewn, Default::default()) +} + +/// A surface extruded from chains of sketch segments on `plane`: each chain (open, or closed such +/// as a profile's boundary) moved straight from `start` to `end` along the plane normal (offsets in +/// mm). The walls it sweeps form one surface body with no caps. Its history has no operands: +/// every face is new, from the segments it was swept from. +pub fn extrude_sheet(plane: &Plane, chains: &[Vec], start: f64, end: f64) -> Result { + if !crate::build::frame_ok(plane) { + return Err(invalid("extrude frame is not finite and orthonormal")); + } + if !(start.is_finite() && end.is_finite() && start.abs() < 1e7 && end.abs() < 1e7) { + return Err(invalid("extrude distance is not finite")); + } + let (lo, hi) = if end >= start { (start, end) } else { (end, start) }; + if hi - lo < 1e-6 { + return Err(invalid("extrude distance is zero")); + } + check_chains(chains, "extrude")?; + let base = plane.offset(lo); + let n = plane.normal().normalized().ok_or_else(|| invalid("extrude frame has no normal"))?; + let pieces = numbered(chains) + .into_iter() + .map(|chain| { + let r = imp::extruded(&base, &chain, n, hi - lo)?; + let h = swept_from(&r, &base, &chain); + Ok((r, h)) + }) + .collect::>>()?; + sheet_of(pieces, false) +} + +/// A surface revolved from chains of sketch segments on `plane` about the sketch line through +/// `axis_origin` along `axis_dir`: each chain turned from `start` through `sweep` radians (a full +/// turn at most), positive angles carrying it toward the sketch normal. A chain end on the axis +/// stays put there (a cone's apex, a sphere's pole). The walls form one surface body with no caps, +/// or a solid where they close (a full turn of a closed curve, or of a curve from the axis back to +/// it); its history has no operands: every face is new. +pub fn revolve_sheet(plane: &Plane, chains: &[Vec], axis_origin: Vec2, axis_dir: Vec2, start: f64, sweep: f64) -> Result { + use std::f64::consts::TAU; + if !crate::build::frame_ok(plane) || !axis_origin.is_finite() || !axis_dir.is_finite() { + return Err(invalid("revolve frame is not finite and orthonormal")); + } + if !(start.is_finite() && sweep.is_finite() && start.abs() <= TAU && sweep.abs() <= TAU + 1e-9) { + return Err(invalid("revolve angles must be finite, a full turn at most")); + } + if sweep.abs() < 1e-6 { + return Err(invalid("revolve angle is zero")); + } + check_chains(chains, "revolve")?; + let d = axis_dir.normalized().ok_or_else(|| invalid("revolve axis has no direction"))?; + let side = |p: Vec2| d.cross(p - axis_origin); + let on_axis = |p: Vec2| side(p).abs() < 1e-7; + let (origin, n) = (plane.to_world(axis_origin), plane.normal()); + let axis = plane.dir_to_world(d).normalized().ok_or_else(|| invalid("revolve frame has no axis"))?; + let mut pieces = Vec::with_capacity(chains.len()); + for chain in numbered(chains) { + // On one side of the axis; the point farthest from it fixes the turning sense. + let pts: Vec = chain.iter().flat_map(|(s, _)| s.polyline(1e-3)).collect(); + if pts.iter().any(|p| side(*p) < -1e-7) && pts.iter().any(|p| side(*p) > 1e-7) { + return Err(invalid("the curves cross the revolve axis")); } - rec.face(&f, face); - Ok(rebuilt_body(surface_body(vec![f]), &[&body.solid], &rec)) - }) + let far = pts.iter().copied().max_by(|a, c| side(*a).abs().total_cmp(&side(*c).abs())).filter(|p| !on_axis(*p)); + let Some(far) = far else { return Err(invalid("the curves lie on the revolve axis")) }; + let a = if axis.cross(plane.to_world(far) - origin).dot(n) < 0.0 { axis * -1.0 } else { axis }; + let (Some((first, _)), Some((last, _))) = (chain.first(), chain.last()) else { return Err(invalid("an empty curve chain")) }; + if first.start().dist(last.end()) <= 1e-6 && pts.iter().any(|p| on_axis(*p)) { + return Err(invalid("not supported yet: a closed curve touching the revolve axis")); + } + // The chain starts `start` round: its sketch frame turned there. + let turn = |v: Vec3| crate::build::rotate_about(v, Vec3::ZERO, a, start); + let turned = Plane { origin: crate::build::rotate_about(plane.origin, origin, a, start), x: turn(plane.x), y: turn(plane.y) }; + let (k, angle) = if sweep < 0.0 { (a * -1.0, -sweep) } else { (a, sweep) }; + let r = imp::revolved(&turned, &chain, origin, k, angle.min(TAU))?; + let h = swept_from(&r, &turned, &chain); + pieces.push((r, h)); + } + sheet_of(pieces, false) +} + +/// A surface swept from chains of sketch segments on `plane` along a path that starts near them, +/// turning with it (SMLib's curve sweep: along lines an extrusion, round arcs about one axis a +/// revolution, exactly). A path whose direction jumps at a joint (a corner) is not supported yet. +/// The walls form one surface body with no caps; its history has no operands: every face is new. +pub fn sweep_sheet(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg]) -> Result { + sweep_sheet_with(plane, chains, path, &crate::SweepControl::default()) +} + +/// [`sweep_sheet`] with sweep options: orientation, twist, end scale and guide rails place the +/// chains at stations along the path by [`crate::sweep_path`]'s laws (each section the chain's +/// exact image) for SMLib to skin the walls through. +pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result { + ctl.check()?; + if !crate::build::frame_ok(plane) { + return Err(invalid("sweep frame is not finite and orthonormal")); + } + check_chains(chains, "sweep")?; + if path.is_empty() || path.len() > 1000 { + return Err(invalid("the path needs 1…1000 segments")); + } + let ends: Vec<(Vec3, Vec3)> = + path.iter().map(crate::PathSeg::tangents).collect::>().ok_or_else(|| invalid("a path segment has no direction"))?; + if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) { + return Err(imp::refuse("surface sweep along a path with corners")); + } + let pieces = numbered(chains) + .into_iter() + .map(|chain| { + let r = if ctl.is_default() { imp::swept(plane, &chain, path)? } else { imp::swept_stations(plane, &chain, path, ctl)? }; + let h = swept_from(&r, plane, &chain); + Ok((r, h)) + }) + .collect::>>()?; + sheet_of(pieces, false) +} + +/// A surface swept from chains of sketch segments on `plane` along a 3D polyline path: along the +/// lines between its points, as [`sweep_sheet`] sweeps them. +pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result { + sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default()) +} + +/// [`sweep_sheet_points`] with sweep options (see [`sweep_sheet_with`]). +pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec], path: &[Vec3], ctl: &crate::SweepControl) -> Result { + let pts = crate::sweep_path::clean(path)?; + let segs: Vec = pts.windows(2).map(|w| crate::PathSeg::Line { a: w[0], b: w[1] }).collect(); + sweep_sheet_with(plane, chains, &segs, ctl) +} + +/// A surface lofted through two or more sections, each a chain of sketch segments on its plane, +/// all open or all closed: ruled between two sections, smooth through three or more. Closed +/// sections are matched as the solid loft matches them; open ones are split to the same number of +/// segments (longest first) and each turned to run the way the one before it does. Walls only, no +/// caps; the history has no operands: every face is new. +pub fn loft_sheet(sections: &[(Plane, Vec)]) -> Result { + loft_sheet_with_rails(sections, &[], crate::RailFit::Scale) +} + +/// [`loft_sheet`] following one or two guide rails (polylines from the first section to the last, +/// through every section's curve; see [`crate::loft_rails`]), `fit` saying how they reshape the +/// sections between. Without rails, the plain sheet loft. +/// +/// The k-th segments of every section make a column: SMLib lofts each column on its own (one wall +/// from the column's segments), and the columns are stitched along the edges they share. +pub fn loft_sheet_with_rails(sections: &[(Plane, Vec)], rails: &[Vec], fit: crate::RailFit) -> Result { + if sections.len() < 2 || sections.len() > 50 { + return Err(invalid("a loft needs 2…50 sections")); + } + let closed: Vec = sections + .iter() + .map(|(_, c)| match (c.first(), c.last()) { + (Some(f), Some(l)) if c.len() <= 4096 => Ok(l.end().dist(f.start()) <= 1e-6), + _ => Err(invalid("a loft section has no curves, or too many")), + }) + .collect::>()?; + let all_closed = closed.iter().all(|c| *c); + if !all_closed && closed.iter().any(|c| *c) { + return Err(invalid("the loft sections must be all open or all closed")); + } + for (plane, chain) in sections { + if !crate::build::frame_ok(plane) { + return Err(invalid("a loft section's frame is not finite and orthonormal")); + } + check_chains(std::slice::from_ref(chain), "loft")?; + } + let matched = if all_closed { closed_sections(sections)? } else { open_sections(sections)? }; + let lofted = crate::loft_rails::railed(&matched, all_closed, rails, fit)?; + let count = matched.first().map_or(0, |s| s.curves.len()); + let pieces = (0..count) + .map(|k| { + let column: Vec<&solidcraft_geom::bspline::CurveN<4>> = lofted.iter().filter_map(|s| s.get(k)).collect(); + let r = imp::lofted(&column)?; + let numbers: BTreeSet = matched.iter().filter_map(|s| s.numbers.get(k)).flatten().copied().collect(); + let h = vec![record::profile(numbers.iter()); r.faces.len()]; + Ok((r, h)) + }) + .collect::>>()?; + sheet_of(pieces, true) +} + +/// Closed sections matched as the solid loft matches them ([`crate::build::loft_numbered_loops`]), +/// in loft order. +fn closed_sections(sections: &[(Plane, Vec)]) -> Result> { + let loops: Vec<(Plane, solidcraft_geom::Loop2)> = sections.iter().map(|(p, c)| (*p, solidcraft_geom::Loop2 { segs: c.clone() })).collect(); + let reversed = crate::build::loft_reversed(&loops)?; + let numbered = loops.iter().map(|(p, _)| *p).zip(crate::numbered::number_loops(loops.iter().map(|(_, l)| l))).collect::>(); + crate::build::loft_numbered_loops(&numbered, reversed)? + .iter() + .map(|(plane, l)| crate::loft_rails::Section::of(plane, &l.lp.segs, (0..l.lp.segs.len()).map(|i| l.numbers(i).to_vec()).collect())) + .collect() +} + +/// Open sections split to the same number of segments (the longest in half until the counts +/// match), each turned to run the way the one before it does. +fn open_sections(sections: &[(Plane, Vec)]) -> Result> { + let n = sections.iter().map(|(_, c)| c.len()).max().unwrap_or(0); + let mut out = Vec::with_capacity(sections.len()); + let mut prev: Option<(Vec3, Vec3)> = None; + let mut offset = 0; + for (plane, chain) in sections { + // Each piece with the segment it is (part of). + let mut segs: Vec<(Seg2, usize)> = chain.iter().enumerate().map(|(i, s)| (*s, offset + i)).collect(); + offset += chain.len(); + while segs.len() < n { + let Some(i) = segs.iter().enumerate().max_by(|a, c| a.1.0.length().total_cmp(&c.1.0.length())).map(|(i, _)| i) else { break }; + let Some(&(s, k)) = segs.get(i) else { break }; + let (a, c) = s.split_half(); + segs.splice(i..=i, [(a, k), (c, k)]); + } + let ends = |c: &[(Seg2, usize)]| c.first().zip(c.last()).map(|(f, l)| (plane.to_world(f.0.start()), plane.to_world(l.0.end()))); + if let (Some((ps, pe)), Some((s, e))) = (prev, ends(&segs)) + && s.dist(ps) + e.dist(pe) > s.dist(pe) + e.dist(ps) + { + segs = segs.iter().rev().map(|(s, k)| (s.reversed(), *k)).collect(); + } + prev = ends(&segs); + let pieces: Vec = segs.iter().map(|(s, _)| *s).collect(); + out.push(crate::loft_rails::Section::of(plane, &pieces, segs.iter().map(|(_, k)| vec![*k]).collect())?); + } + Ok(out) +} + +/// SMLib's side of this module. +#[cfg(feature = "smlib")] +mod imp { + use solidcraft_smlib::sweeps::{Affine, Curve, Ends, SweepFrame}; + use solidcraft_smlib::{Brep as SmBrep, CheckLevel}; + + use super::*; + + fn sm(what: &'static str) -> impl Fn(solidcraft_smlib::Error) -> KernelError { + move |e| failed(format!("SMLib {what}: {e}")) + } + + pub(super) fn refuse(what: &str) -> KernelError { + crate::smlib_sweeps::refuse(what) + } + + /// A result SMLib's own check accepts, as its record. + fn checked(r: &SmBrep, what: &'static str) -> Result { + let check = r.check(CheckLevel::All).map_err(sm(what))?; + if !check.is_valid() { + return Err(failed(format!("SMLib {what}: SMLib's check refuses the result: {check}"))); + } + r.to_native().map_err(|e| failed(format!("SMLib {what}: {e}"))) + } + + pub(super) fn record_body(r: &Brep) -> Result<(Body, bool)> { + let (brep, order) = crate::native::smlib_record_ordered(r)?; + let (body, kept) = crate::native::body_from_smlib_ordered(&brep)?; + Ok((body, order.is_some() && kept.is_some())) + } + + /// The record `r` sewn by SMLib at `tol` (`smlib_stitch`), with its history over `[r]` (faces + /// and edges by their index in `r`); none where the orders cannot be matched. + pub(super) fn stitched(r: &Brep, tol: f64) -> Result<(Body, Option)> { + let (brep, order) = crate::native::smlib_record_ordered(r)?; + let tagged = brep.tagged(0).map_err(sm("stitch"))?; + let (sewn, _closed) = tagged.stitch(tol).map_err(sm("stitch"))?; + checked(&sewn, "stitch")?; + let (body, kept) = crate::native::body_from_smlib_ordered(&sewn)?; + let h = match (order, kept) { + (Some(o), Some(k)) => sewn.history().ok().and_then(|raw| raw.history(&[o], &k).ok()), + _ => None, + }; + Ok((body, h)) + } + + pub(super) fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result { + crate::offset_backend::thicken(body, thickness, symmetric) + } + + /// The face `f` of `r` alone, offset by `d` along its normal (`smlib_offset_sheet`). + pub(super) fn offset_face(r: &Brep, f: usize, d: f64) -> Result { + let one = Parts::of(r, &[(f, false)])?.canonical()?; + let sheet = SmBrep::from_record(&one.brep, true).map_err(sm("offset"))?; + checked(&sheet.offset_sheet(d).map_err(sm("offset"))?, "offset") + } + + /// A sketch segment on `plane` as SMLib's curve: an arc as SMLib's own circle, anything else + /// as its exact record curve. + fn curve(plane: &Plane, s: &Seg2) -> Result { + if let Seg2::Arc { center, radius, start, sweep } = *s { + let n = plane.normal().normalized().ok_or_else(|| invalid("profile plane"))?; + // Clockwise in the plane is counter-clockwise about the other normal. + let (axis, a0) = if sweep >= 0.0 { (n, start) } else { (n * -1.0, -start) }; + let turn = sweep.abs().min(std::f64::consts::TAU); + return Ok(Curve::Arc { center: a3(plane.to_world(center)), axis: a3(axis), reference: a3(plane.x), radius, start: a0, end: a0 + turn }); + } + record_curve(&crate::freeform::segment(plane, s)?.0) + } + + /// A record line or (rational) B-spline as SMLib's curve. + fn record_curve(c: &b::Curve) -> Result { + let degree = |knots: &[f64], n: usize| knots.len().checked_sub(n + 1).ok_or_else(|| invalid("spline degree")); + Ok(match c { + b::Curve::Line { start, end } => Curve::Line { start: *start, end: *end }, + b::Curve::Spline { knots, points } => Curve::Nurbs { + degree: degree(knots, points.len())?, + points: points.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect(), + knots: knots.clone(), + }, + b::Curve::RationalSpline { knots, homogeneous } => Curve::Nurbs { + degree: degree(knots, homogeneous.len())?, + points: homogeneous.iter().map(|h| [h[0] / h[3], h[1] / h[3], h[2] / h[3], h[3]]).collect(), + knots: knots.clone(), + }, + _ => return Err(invalid("a profile curve SMLib's sweeps do not take")), + }) + } + + fn curves(plane: &Plane, chain: &[Numbered]) -> Result> { + chain.iter().map(|(s, _)| curve(plane, s)).collect() + } + + pub(super) fn extruded(base: &Plane, chain: &[Numbered], n: Vec3, distance: f64) -> Result { + checked(&SmBrep::extrude(&curves(base, chain)?, a3(n), distance, 0.0, false).map_err(sm("surface extrude"))?, "surface extrude") + } + + pub(super) fn revolved(plane: &Plane, chain: &[Numbered], origin: Vec3, axis: Vec3, angle: f64) -> Result { + checked(&SmBrep::revolve(&curves(plane, chain)?, a3(origin), a3(axis), angle, false).map_err(sm("surface revolve"))?, "surface revolve") + } + + /// A path segment as SMLib's curve. + fn path_curve(s: &crate::PathSeg) -> Result { + Ok(match *s { + crate::PathSeg::Line { a, b } => Curve::Line { start: a3(a), end: a3(b) }, + crate::PathSeg::Arc { a, center, axis, angle } => { + let k = axis.normalized().ok_or_else(|| invalid("a path arc has no axis"))?; + let (k, turn) = if angle < 0.0 { (k * -1.0, -angle) } else { (k, angle) }; + let r = a - center; + let reference = r.normalized().ok_or_else(|| invalid("a path arc has no radius"))?; + Curve::Arc { center: a3(center), axis: a3(k), reference: a3(reference), radius: r.len(), start: 0.0, end: turn } + } + crate::PathSeg::Curve { plane, seg } => curve(&plane, &seg)?, + crate::PathSeg::Cubic { p } => Curve::Nurbs { + degree: 3, + points: p.iter().map(|q| [q.x, q.y, q.z, 1.0]).collect(), + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + }, + }) + } + + pub(super) fn swept(plane: &Plane, chain: &[Numbered], path: &[crate::PathSeg]) -> Result { + let route = path.iter().map(path_curve).collect::>>()?; + checked(&SmBrep::sweep(&curves(plane, chain)?, &route, SweepFrame::Follow, false).map_err(sm("surface sweep"))?, "surface sweep") + } + + /// Fewest and most stations of a sweep that twists, scales or follows rails. + const MIN_STATIONS: usize = 16; + const MAX_STATIONS: usize = 2048; + + /// The chain carried through stations along `path` placed by [`crate::sweep_path`]'s laws for + /// `ctl` (a station per degree the path and the twist turn), SMLib skinning its walls through + /// the sections with open ends (`smlib_sweep_stations`). + pub(super) fn swept_stations(plane: &Plane, chain: &[Numbered], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result { + let turn: f64 = path.iter().map(turn_of).sum::() + ctl.twist.abs(); + let rail_points = ctl.rails.iter().map(Vec::len).max().unwrap_or(0); + let n = ((turn / 1f64.to_radians()).ceil() as usize).max(rail_points.saturating_sub(1)).clamp(MIN_STATIONS, MAX_STATIONS); + let (pts, tangents) = samples(path, n)?; + let st = crate::sweep_path::controlled_stations(plane, &pts, &tangents, &[], ctl)?; + let s0 = st.first().map(|s| s.at).ok_or_else(|| invalid("sweep path"))?; + let maps: Vec = st + .iter() + .map(|s| { + let [a, b, c] = s.m; + Affine { m: [a3(a), a3(b), a3(c)], t: a3(s.at - s.apply(s0)) } + }) + .collect(); + checked(&SmBrep::sweep_stations(&curves(plane, chain)?, &maps, Ends::Open).map_err(sm("surface sweep"))?, "surface sweep") + } + + /// How far a path segment turns (radians). + fn turn_of(s: &crate::PathSeg) -> f64 { + match *s { + crate::PathSeg::Line { .. } => 0.0, + crate::PathSeg::Arc { angle, .. } => angle.abs(), + _ => { + let pts: Vec = std::iter::once(s.start()).chain(s.points_after_start(1e-3)).collect(); + pts.windows(3) + .filter_map(|w| Some(((w[1] - w[0]).normalized()?, (w[2] - w[1]).normalized()?))) + .map(|(u, v)| u.cross(v).len().atan2(u.dot(v))) + .sum() + } + } + } + + /// `n + 1` points spread along `path` (evenly by length from segment to segment, evenly in each + /// segment's parameter within it), with the path's unit tangent at each. + fn samples(path: &[crate::PathSeg], n: usize) -> Result<(Vec, Vec)> { + let bad = || invalid("a path segment has no direction"); + let lengths: Vec = path.iter().map(length).collect(); + let total: f64 = lengths.iter().sum(); + if !(total > 1e-12) { + return Err(invalid("the sweep path has no length")); + } + let (mut pts, mut tangents) = (Vec::with_capacity(n + 1), Vec::with_capacity(n + 1)); + for i in 0..=n { + let mut s = total * i as f64 / n as f64; + let mut k = 0; + while k + 1 < path.len() && s > lengths.get(k).copied().unwrap_or(0.0) { + s -= lengths.get(k).copied().unwrap_or(0.0); + k += 1; + } + let f = (s / lengths.get(k).copied().unwrap_or(1.0).max(1e-300)).clamp(0.0, 1.0); + let (p, t) = path.get(k).and_then(|seg| at(seg, f)).ok_or_else(bad)?; + pts.push(p); + tangents.push(t); + } + Ok((pts, tangents)) + } + + fn length(s: &crate::PathSeg) -> f64 { + match *s { + crate::PathSeg::Line { a, b } => a.dist(b), + crate::PathSeg::Arc { a, center, angle, .. } => a.dist(center) * angle.abs(), + _ => { + let pts: Vec = std::iter::once(s.start()).chain(s.points_after_start(1e-4)).collect(); + pts.windows(2).map(|w| w[0].dist(w[1])).sum() + } + } + } + + /// The point `f` (0…1) of the way along a segment's parameter and its unit tangent there. + fn at(s: &crate::PathSeg, f: f64) -> Option<(Vec3, Vec3)> { + match *s { + crate::PathSeg::Line { a, b } => Some((a.lerp(b, f), (b - a).normalized()?)), + crate::PathSeg::Arc { a, center, axis, angle } => { + let k = axis.normalized()?; + let p = crate::build::rotate_about(a, center, k, angle * f); + Some((p, (k.cross(p - center) * angle.signum()).normalized()?)) + } + crate::PathSeg::Curve { plane, seg } => Some((plane.to_world(seg.point_at(f)), plane.dir_to_world(seg.derivative(f)).normalized()?)), + crate::PathSeg::Cubic { p: [p0, p1, p2, p3] } => { + let g = 1.0 - f; + let p = p0 * (g * g * g) + p1 * (3.0 * g * g * f) + p2 * (3.0 * g * f * f) + p3 * (f * f * f); + let d = (p1 - p0) * (3.0 * g * g) + (p2 - p1) * (6.0 * g * f) + (p3 - p2) * (3.0 * f * f); + Some((p, d.normalized()?)) + } + } + } + + /// One column of a loft: its curve in each section (and station) in loft order, skinned by + /// SMLib with no caps (`smlib_loft`), ruled between two, cubic through four or more. + pub(super) fn lofted(column: &[&solidcraft_geom::bspline::CurveN<4>]) -> Result { + let sections: Vec> = column + .iter() + .map(|c| { + let points = c.ctrl.iter().map(|h| [h[0] / h[3], h[1] / h[3], h[2] / h[3], h[3]]).collect(); + vec![Curve::Nurbs { degree: c.degree, points, knots: c.knots.clone() }] + }) + .collect(); + let degree = u32::try_from(column.len().saturating_sub(1).clamp(1, 3)).unwrap_or(3); + checked(&SmBrep::loft(§ions, degree, false).map_err(sm("surface loft"))?, "surface loft") + } +} + +#[cfg(not(feature = "smlib"))] +mod imp { + use super::*; + + fn not_built() -> KernelError { + refuse("surfaces: this build has no SMLib (feature `smlib`)") + } + pub(super) fn refuse(what: &str) -> KernelError { + crate::smlib_sweeps::refuse(what) + } + pub(super) fn record_body(_: &Brep) -> Result<(Body, bool)> { + Err(not_built()) + } + pub(super) fn stitched(_: &Brep, _: f64) -> Result<(Body, Option)> { + Err(not_built()) + } + pub(super) fn thicken(_: &Body, _: f64, _: bool) -> Result { + Err(not_built()) + } + pub(super) fn offset_face(_: &Brep, _: usize, _: f64) -> Result { + Err(not_built()) + } + pub(super) fn extruded(_: &Plane, _: &[Numbered], _: Vec3, _: f64) -> Result { + Err(not_built()) + } + pub(super) fn revolved(_: &Plane, _: &[Numbered], _: Vec3, _: Vec3, _: f64) -> Result { + Err(not_built()) + } + pub(super) fn swept(_: &Plane, _: &[Numbered], _: &[crate::PathSeg]) -> Result { + Err(not_built()) + } + pub(super) fn swept_stations(_: &Plane, _: &[Numbered], _: &[crate::PathSeg], _: &crate::SweepControl) -> Result { + Err(not_built()) + } + pub(super) fn lofted(_: &[&solidcraft_geom::bspline::CurveN<4>]) -> Result { + Err(not_built()) + } } #[cfg(test)] mod tests { use super::*; use crate::{box_solid, cylinder, measure, trim}; - use solidcraft_geom::{Loop2, Vec2}; + use solidcraft_geom::Loop2; fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-9) @@ -1320,8 +1570,7 @@ mod tests { let bent = vec![line[0], Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 5.0) }]; let two = sweep_sheet_points(&Plane::XY, &[bent], &arc).unwrap(); assert_eq!(two.face_count(), 2); - let edges: std::collections::HashSet<_> = two.solid.edge_iter().map(|e| e.id()).collect(); - assert_eq!(edges.len(), 7, "the walls share their seam"); + assert_eq!(two.edge_count(), 7, "the walls share their seam"); } #[test] @@ -1494,7 +1743,7 @@ mod tests { let want = 300.0 + 15.0 + 1.0 / 6.0; let v = measure(&t).unwrap().volume; assert!(rel(v, want) < 1e-9, "{v} vs {want}"); - assert!(Body::new(t.solid.as_ref().clone()).is_ok()); + assert!(t.validity().is_empty(), "{:?}", t.validity()); assert!(thicken(&corner, -1.0, false).is_err()); assert!(thicken(&corner, 1.0, true).is_err(), "half of it goes into the inside corner"); }