Sketch projection: a free-form edge comes in as the one exact spline it is

A projected spline edge of several Bezier spans came into the sketch as one
curve per span (#1508), so a G2 round's bottom edge was 16 curves and a path
naming the corner named one span of it (#3604). The kernel now reads such an
edge (a spline, or an elliptical arc of several spans) as the one clamped
B-spline it is over the edge's interval, its knots and weights its own, and
the sketch takes it as one control spline whose ends sit on the edge's
vertices. Planar regions still read edges span by span. The 3604 case builds
with its original path and leaves KNOWN_FAILING.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 23:06:40 -04:00
committed by jyviko
parent b16e8a5423
commit e6abea8097
6 changed files with 213 additions and 14 deletions
+35 -4
View File
@@ -503,11 +503,33 @@ fn sketch_curves(ss: &crate::eval::SolvedSketch, ci: usize) -> Vec<WireCurve> {
}
/// 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<Option<Vec<LinkGeom>>> {
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<LinkGeom> {
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) {
@@ -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}");
}
+6 -5
View File
@@ -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<Option<Vec<solidcraft_geom::Seg2>>> {
/// 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<Option<Vec<crate::PlanarPiece>>> {
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()
}
+1
View File
@@ -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};
+136 -4
View File
@@ -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<Vec<Seg2>> {
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<f64>,
pub points: Vec<Vec2>,
pub weights: Vec<f64>,
}
/// 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<Vec<PlanarPiece>> {
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<PlanarSpline> {
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<HPoint>, work: &mut usize| -> Option<(usize, Vec<f64>, Vec<HPoint>)> {
let degree = knots.len().checked_sub(ctrl.len() + 1)?;
if !(1..=MAX_DEGREE).contains(&degree) || 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<HPoint> = 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<f64>, Vec<HPoint>)) -> Option<(usize, Vec<f64>, Vec<HPoint>)> {
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<Vec<Seg2>> {
if !tol.is_finite() || tol <= 0. {
return None;
@@ -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