diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 6b22d6c82c..ac13214506 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -503,11 +503,33 @@ fn sketch_curves(ss: &crate::eval::SolvedSketch, ci: usize) -> Vec { } /// Edges `edges` of `body` (indices into its mesh's `edges`) on `plane` as their exact curves: -/// lines, circles and arcs, and the Bézier pieces of a free-form curve. `None` for an edge that -/// doesn't lie in a plane parallel to `plane` or isn't read exactly (a mesh body's): it is +/// lines, circles and arcs, and a free-form curve as the one spline it is, its knots and weights +/// its own. `None` for an edge that doesn't lie in a plane parallel to `plane`, isn't read +/// exactly (a mesh body's) or is a spline no sketch holds (past degree 9 or 500 points): it is /// fitted to its polyline instead. fn exact_edges(body: &crate::ModelBody, plane: &Plane, edges: &[usize]) -> Vec>> { - body.body.planar_edges(plane, edges).into_iter().map(|segs| segs?.iter().map(link_geom).collect()).collect() + body.body + .planar_edges(plane, edges) + .into_iter() + .map(|pieces| { + pieces? + .iter() + .map(|piece| match piece { + crate::kernel::PlanarPiece::Seg(s) => link_geom(s), + crate::kernel::PlanarPiece::Spline(s) => link_spline(s), + }) + .collect() + }) + .collect() +} + +/// A free-form edge's B-spline as the control spline that is the same curve, with its own knots +/// and weights. `None` for one a sketch can't hold. +fn link_spline(s: &crate::kernel::PlanarSpline) -> Option { + let degree = u8::try_from(s.degree).ok()?; + let nurbs = solidcraft_sketch::Nurbs { knots: s.knots.clone(), weights: s.weights.clone() }; + nurbs.check(s.points.len(), degree).ok()?; + Some(LinkGeom::Spline { pts: s.points.clone(), control: true, degree, periodic: false, nurbs: Some(nurbs) }) } /// `edges` of `body` projected on `plane`: each exactly ([`exact_edges`]) or fitted to its @@ -549,7 +571,16 @@ fn at_vertices(body: &crate::ModelBody, mesh: &Mesh, plane: &Plane, e: usize, g: for v in [plane.to_local(*first), plane.to_local(*last)] { let mut best: Option<(f64, &mut Vec2)> = None; for piece in g.iter_mut() { - if let LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } = piece { + let ends = match piece { + LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } => Some((a, b)), + // A control spline's end points are its ends. + LinkGeom::Spline { pts, control: true, periodic: false, .. } => match pts.as_mut_slice() { + [a, .., b] => Some((a, b)), + _ => None, + }, + _ => None, + }; + if let Some((a, b)) = ends { for p in [a, b] { let d = p.dist(v); if d <= tol && best.as_ref().is_none_or(|(bd, _)| d < *bd) { diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index 543112cff8..fbb4ceb0a6 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -936,3 +936,38 @@ fn intersect_on_a_top_face_gives_its_outline() { let ends = outline(&intersect_curves(&block, json!({"plane": "XY", "offset": 20})).unwrap()); assert_eq!(ends, vec![[0.0, 0.0, 0.0, 30.0], [0.0, 0.0, 40.0, 0.0], [0.0, 30.0, 40.0, 30.0], [40.0, 0.0, 40.0, 30.0]]); } + +/// A free-form edge projects as the one exact spline it is, its knots and weights its own: the +/// bottom face of a 40 x 20 x 10 box whose upright edges have G2 rounds (each round's bottom +/// edge a B-spline of several spans) comes into its sketch as four lines and four splines, one a +/// corner, each meeting the lines at their ends (#3604, where each span was a curve of its own). +/// The profile they bound, extruded the box's height, is the box: its sides are upright. +#[test] +fn a_free_form_edge_projects_as_one_spline_with_its_own_knots() { + use solidcraft_sketch::CurveKind; + let volume = |s: &Session, body: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(body).unwrap().body).unwrap().volume; + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"corner":[0,0,0],"length":40,"width":20,"height":10,"body_name":"B"})); + run(&mut s, "solid.fillet", json!({"body":"B","edges":[[0,0,5],[40,0,5],[40,20,5],[0,20,5]],"radius":5,"type":"g2"})); + let id = run(&mut s, "sketch.create", json!({"plane":{"face":[20,10,0]},"project_edges":true,"name":"Bottom"}))["sketch"].as_u64().unwrap(); + run(&mut s, "sketch.finish", json!({})); + let st = s.model.state(); + let sketch = &st.sketch(id).unwrap().sketch; + let lines = sketch.curves.iter().filter(|c| matches!(c.kind, CurveKind::Line { .. })).count(); + let splines: Vec<_> = sketch.curves.iter().filter(|c| matches!(c.kind, CurveKind::Spline { .. })).collect(); + assert_eq!((lines, splines.len(), sketch.curves.len()), (4, 4, 8), "{:?}", sketch.curves); + for c in &splines { + let CurveKind::Spline { pts, control: true, nurbs: Some(nurbs), .. } = &c.kind else { panic!("{:?}", c.kind) }; + assert_eq!(nurbs.knots.len(), pts.len() + 4, "a cubic's knots"); + let (a, b) = c.kind.ends().unwrap(); + for end in [a, b] { + let meets = sketch.curves.iter().filter(|o| matches!(o.kind, CurveKind::Line { a, b } if a == end || b == end)).count(); + assert_eq!(meets, 1, "{} end {end}", c.id); + } + } + drop(st); + let rounded = volume(&s, "B"); + run(&mut s, "solid.extrude", json!({"sketch":"Bottom","distance":10,"operation":"new","body_name":"P"})); + let prism = volume(&s, "P"); + assert!((prism - rounded).abs() < rounded * 1e-6, "{prism} vs {rounded}"); +} diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 211ab4859d..502595bff4 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -1287,10 +1287,11 @@ impl Body { } /// Edges `indices` (record edge indices) in `plane`'s coordinates, exactly: lines, circular - /// arcs, and free-form curves as the Bézier spans they are, for a sketch to project them. - /// `None` for an edge that doesn't lie in a plane parallel to `plane`, isn't read exactly or - /// isn't there, and for every edge of a mesh body. - pub fn planar_edges(&self, plane: &solidcraft_geom::Plane, indices: &[usize]) -> Vec>> { + /// arcs, single free-form segments, and a free-form curve of several spans as the one + /// B-spline it is, for a sketch to project them. `None` for an edge that doesn't lie in a + /// plane parallel to `plane`, isn't read exactly or isn't there, and for every edge of a mesh + /// body. + pub fn planar_edges(&self, plane: &solidcraft_geom::Plane, indices: &[usize]) -> Vec>> { if self.is_mesh() { return vec![None; indices.len()]; } @@ -1299,7 +1300,7 @@ impl Body { .iter() .map(|i| { let mut work = 262_144usize; - crate::prism_curve::Run::of(&self.record, *i, true).and_then(|run| crate::prism_curve::projected(&run, plane, tol, &mut work)) + crate::prism_curve::Run::of(&self.record, *i, true).and_then(|run| crate::prism_curve::projected_curves(&run, plane, tol, &mut work)) }) .collect() } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 6c457444ce..461fed2fa4 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -210,6 +210,7 @@ pub use point_path::{ pub use polybool::faceted_boolean; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean}; +pub use prism_curve::{PlanarPiece, PlanarSpline}; pub use prism_measure::{PrismSection, certified_prism_equivalent, certified_prism_section}; pub use repair::{Hole, RebuiltFace, RefusedFace, RepairOptions, RepairReport, SewnGap, repair}; pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching}; diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs index 508ef1f060..4b4e47aa0a 100644 --- a/crates/kernel/src/prism_curve.rs +++ b/crates/kernel/src/prism_curve.rs @@ -299,14 +299,146 @@ pub(crate) fn segment(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> O read(run, plane, tol, work, false) } -/// `run` in `plane` exactly, for a sketch to project it: as [`segment`], and a curve of several -/// Bézier spans (a chain of a font's curves, a spline through points) as one segment per span, -/// each the line, conic or Bézier it is. `None` when the edge doesn't lie in a plane parallel to -/// `plane` or isn't read exactly. +/// `run` in `plane` exactly as segments: as [`segment`], and a curve of several Bézier spans +/// (a chain of a font's curves, a spline through points) as one segment per span, each the +/// line, conic or Bézier it is, for a planar region's outline. `None` when the edge doesn't lie +/// in a plane parallel to `plane` or isn't read exactly. pub(crate) fn projected(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> Option> { read(run, plane, tol, work, true) } +/// A free-form curve of several Bézier spans in a plane, as the one B-spline it is: its degree, +/// clamped knots, control points in the plane's coordinates and weights (none: a polynomial +/// B-spline). Its first and last control points are its ends. +#[derive(Clone, Debug, PartialEq)] +pub struct PlanarSpline { + pub degree: usize, + pub knots: Vec, + pub points: Vec, + pub weights: Vec, +} + +/// One piece of an edge read exactly in a plane: a line, arc or single free-form segment, or a +/// free-form curve of several spans as one [`PlanarSpline`]. +#[derive(Clone, Debug, PartialEq)] +pub enum PlanarPiece { + Seg(Seg2), + Spline(PlanarSpline), +} + +/// `run` in `plane` exactly, for a sketch to project it: as [`segment`], and a curve of several +/// Bézier spans (a spline edge, an elliptical arc past a quarter turn, a chain of a font's +/// curves) as the one B-spline it is, its knots and weights its own. `None` when the edge +/// doesn't lie in a plane parallel to `plane` or isn't read exactly. +pub(crate) fn projected_curves(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> Option> { + if let Some(segs) = read(run, plane, tol, work, false) { + return Some(segs.into_iter().map(PlanarPiece::Seg).collect()); + } + one_spline(run, plane, tol, work).map(|s| vec![PlanarPiece::Spline(s)]) +} + +/// `run`'s spline (or ellipse) over its interval, in the direction it runs, as one clamped +/// B-spline in `plane`: the piece of the curve between the interval's ends (Boehm insertion, +/// exact up to rounding), its control points read in the plane. `None` when a control point lies +/// off the level of the curve's start (the curve is not in a plane parallel to `plane`), the +/// ends miss the run's ends, or the work budget runs out. +fn one_spline(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> Option { + if !tol.is_finite() || tol <= 0. || run.interval[1] <= run.interval[0] { + return None; + } + charge(work, 1)?; + let [t0, t1] = run.interval; + let clamped = |knots: &[f64], ctrl: Vec, work: &mut usize| -> Option<(usize, Vec, Vec)> { + let degree = knots.len().checked_sub(ctrl.len() + 1)?; + if !(1..=MAX_DEGREE).contains(°ree) || ctrl.len() > MAX_CONTROLS || knots.len() > 8192 { + return None; + } + if knots.iter().any(|t| !t.is_finite()) || knots.windows(2).any(|w| w[1] < w[0]) || ctrl.iter().any(|p| !p.is_finite()) { + return None; + } + charge(work, ctrl.len().checked_mul(degree + 1)?.checked_mul(2)?)?; + let (k, c) = bspline::segment(degree, knots, &ctrl, t0, t1).ok()?; + Some((degree, k, c)) + }; + // In the curve's own direction, then the way the run goes. + let (degree, knots, ctrl) = match run.curve { + Curve::Spline { knots, points } => along(run, clamped(knots, points.iter().map(|p| HPoint::weighted(v3(*p), 1.0)).collect(), work)?)?, + Curve::RationalSpline { knots, homogeneous } => { + along(run, clamped(knots, homogeneous.iter().map(|p| HPoint { x: p[0], y: p[1], z: p[2], w: p[3] }).collect(), work)?)? + } + // Rational quadratic spans, already the way the run goes, joined: each joint knot twice. + Curve::Ellipse { .. } => { + let spans = spans_of(run, work)?; + let mut knots = vec![0.0; 3]; + let mut ctrl: Vec = Vec::new(); + for (i, span) in spans.iter().enumerate() { + let [p0, p1, p2] = span.as_slice() else { return None }; + if let Some(last) = ctrl.last() { + if (last.w - p0.w).abs() > 1e-12 * last.w.abs().max(p0.w.abs()) { + return None; + } + } else { + ctrl.push(*p0); + } + ctrl.extend([*p1, *p2]); + knots.extend([(i + 1) as f64; 2]); + } + knots.push(spans.len() as f64); + (2, knots, ctrl) + } + _ => return None, + }; + let max_w = ctrl.iter().map(|p| p.w).fold(0., f64::max); + if !(max_w.is_finite() && max_w > 0.) { + return None; + } + let (Some(first), Some(last)) = (ctrl.first().and_then(|p| p.project()), ctrl.last().and_then(|p| p.project())) else { return None }; + let level = plane.height(first); + let (a, b) = (plane.to_local(first), plane.to_local(last)); + if first.dist(run.ends[0]) > tol || last.dist(run.ends[1]) > tol { + return None; + } + let mut points = Vec::with_capacity(ctrl.len()); + let mut weights = Vec::with_capacity(ctrl.len()); + for p in &ctrl { + if p.w / max_w < 1e-12 { + return None; + } + let v = p.project()?; + let error = (v.x.abs() + v.y.abs() + v.z.abs() + plane.origin.len()) * f64::EPSILON * 256.; + if (plane.height(v) - level).abs() + error > tol || error > tol * 0.1 { + return None; + } + let local = plane.to_local(v); + if !(local.x.is_finite() && local.y.is_finite()) { + return None; + } + points.push(local); + weights.push(p.w / max_w); + } + // The ends exactly where the curve ends. + if let Some(p) = points.first_mut() { + *p = a; + } + if let Some(p) = points.last_mut() { + *p = b; + } + if weights.iter().all(|w| (w - 1.).abs() <= 1e-12) { + weights.clear(); + } + Some(PlanarSpline { degree, knots, points, weights }) +} + +/// A clamped B-spline `(degree, knots, control points)` run the way `run` goes: as it is +/// forward, its knots mirrored and its control points reversed back. +fn along(run: &Run, (degree, knots, ctrl): (usize, Vec, Vec)) -> Option<(usize, Vec, Vec)> { + if run.forward { + return Some((degree, knots, ctrl)); + } + let sum = knots.first()? + knots.last()?; + Some((degree, knots.iter().rev().map(|k| sum - k).collect(), ctrl.into_iter().rev().collect())) +} + fn read(run: &Run, plane: &Plane, tol: f64, work: &mut usize, each_span: bool) -> Option> { if !tol.is_finite() || tol <= 0. { return None; diff --git a/tests/kernel-cases/booleans/KNOWN_FAILING b/tests/kernel-cases/booleans/KNOWN_FAILING index a71ceae247..bd9b142582 100644 --- a/tests/kernel-cases/booleans/KNOWN_FAILING +++ b/tests/kernel-cases/booleans/KNOWN_FAILING @@ -2,4 +2,3 @@ # rig gate (.github/workflows/kernel-rig.yml) fails on a case that fails and is not listed here, # and on a listed case that passes: a fix removes its line in the same pull request. # Baseline: develop 5ada4fc8a with SMLib 90aabff (the run on 60b1c6a, aarch64 Linux). -3604-cut-with-g2-swept-tool-along-projected-edge step 12 (solid.sweep): the path curves are not connected end to end