Exact free-form profiles: geom Seg2 gains cubic Béziers and conics (rational quadratics: exact length, area, split, tangents); sketch ellipses, conics, text, fit splines and control splines up to degree 3 enter profiles as exact segments wherever a loop uses a whole segment, and the kernel builds them as B-spline / NURBS edges (an extruded ellipse is three faces with the exact volume)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 02:16:00 +00:00
1 parent 8b4ec13d3b
commit 31030ed008
13 files changed
+447 -31

No files matched your search

+76
View File
@@ -163,6 +163,53 @@ pub fn end_curvature(f: &dyn Fn(f64) -> Vec2, at_end: bool) -> Vec2 {
(d2 - t * t.dot(d2)) / l2
}
/// The cubic Bézier pieces of a fit spline (exact: each span is a cubic Hermite).
pub fn fit_beziers(p: &[Vec2]) -> Vec<[Vec2; 4]> {
fit_spans(p).into_iter().map(|(p0, p1, t0, t1)| [p0, p0 + t0 / 3.0, p1 - t1 / 3.0, p1]).collect()
}
/// The Bézier pieces (each `degree + 1` points) of the clamped uniform B-spline over `ctrl`
/// (Boehm knot insertion until every interior knot has multiplicity `degree`).
pub fn bspline_beziers(ctrl: &[Vec2], degree: usize) -> Vec<Vec<Vec2>> {
let n = ctrl.len();
let k = degree.min(n.saturating_sub(1)).max(1);
let spans = n - k;
let mut knots = vec![0.0; k + 1];
for i in 1..spans {
knots.push(i as f64 / spans as f64);
}
knots.extend(std::iter::repeat_n(1.0, k + 1));
let mut pts = ctrl.to_vec();
for s in 1..spans {
let u = s as f64 / spans as f64;
for _ in 1..k {
// Span holding u (the last knot ≤ u).
let Some(span) = knots.iter().rposition(|x| *x <= u + 1e-15) else { break };
let mut q = Vec::with_capacity(pts.len() + 1);
for i in 0..=pts.len() {
let p = if i + k <= span {
pts.get(i).copied()
} else if i > span {
pts.get(i - 1).copied()
} else {
let (lo, hi) = (knots.get(i).copied().unwrap_or(0.0), knots.get(i + k).copied().unwrap_or(1.0));
let a = if hi - lo > 1e-15 { (u - lo) / (hi - lo) } else { 0.0 };
match (pts.get(i.wrapping_sub(1)), pts.get(i)) {
(Some(x), Some(y)) => Some(*x * (1.0 - a) + *y * a),
_ => None,
}
};
if let Some(p) = p {
q.push(p);
}
}
knots.insert(span + 1, u);
pts = q;
}
}
pts.windows(k + 1).step_by(k).map(|w| w.to_vec()).collect()
}
/// Polyline of a spline through (`control == false`) or over (`control`) the points.
pub fn spline_polyline(p: &[Vec2], control: bool, degree: u8) -> Vec<Vec2> {
if p.len() < 2 {
@@ -244,6 +291,35 @@ mod tests {
assert!(end_curvature(&|t| spline_point(&p, false, 3, t), false).len() < 1e-3);
}
#[test]
fn spline_bezier_pieces_match_the_spline() {
let p = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 5.0), Vec2::new(20.0, -3.0), Vec2::new(30.0, 8.0), Vec2::new(35.0, 0.0)];
let bez = |c: &[Vec2], t: f64| -> Vec2 {
// de Casteljau.
let mut v = c.to_vec();
while v.len() > 1 {
v = v.windows(2).map(|w| w[0] * (1.0 - t) + w[1] * t).collect();
}
v[0]
};
for degree in [2usize, 3] {
let pieces = bspline_beziers(&p, degree);
assert_eq!(pieces.len(), p.len() - degree, "degree {degree}");
let spans = pieces.len();
for (i, c) in pieces.iter().enumerate() {
for t in [0.0, 0.3, 0.7, 1.0] {
let g = (i as f64 + t) / spans as f64;
let want = spline_point(&p, true, degree as u8, g);
assert!(bez(c, t).dist(want) < 1e-9, "degree {degree} piece {i} t {t}: {:?} vs {want:?}", bez(c, t));
}
}
}
for (i, c) in fit_beziers(&p).iter().enumerate() {
let g = (i as f64 + 0.4) / 4.0;
assert!(bez(c, 0.4).dist(spline_point(&p, false, 3, g)) < 1e-9);
}
}
#[test]
fn control_spline_is_clamped_and_conic_hits_its_ends() {
let p = [Vec2::new(0.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0)];
+3 -1
View File
@@ -18,7 +18,9 @@ mod solver;
mod text;
mod wire;
pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, end_curvature, spline_end_tangent, spline_point, spline_polyline};
pub use curves::{
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, spline_end_tangent, spline_point, spline_polyline,
};
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError};
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
+46
View File
@@ -720,6 +720,52 @@ impl Sketch {
}
}
/// Exact boundary segments of a free-form curve: ellipses as four conics, conics, fit
/// splines and control splines up to degree 3 as Béziers. None for other curves (and for
/// higher-degree splines, which stay polylines).
pub fn exact_segs(&self, ci: usize) -> Option<Vec<Seg2>> {
let cu = self.curves.get(ci)?;
let pt = |i: &usize| self.point(*i);
match &cu.kind {
CurveKind::Ellipse { c, m, r } => {
let (c, m) = (pt(c)?, pt(m)?);
let u = m - c;
let v = u.perp().normalized()? * *r;
let w = std::f64::consts::FRAC_1_SQRT_2;
let corners = [c + u, c + v, c - u, c - v];
Some(
(0..4)
.map(|i| {
let (a, b) = (corners[i], corners[(i + 1) % 4]);
Seg2::Conic { a, apex: a + b - c, b, w }
})
.collect(),
)
}
CurveKind::Conic { a, b, apex, rho } => Some(vec![Seg2::Conic { a: pt(a)?, apex: pt(apex)?, b: pt(b)?, w: rho / (1.0 - rho).max(1e-9) }]),
CurveKind::Spline { pts, control: false, .. } => {
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
Some(crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect())
}
CurveKind::Spline { pts, control: true, degree } if *degree <= 3 => {
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1);
Some(
crate::curves::bspline_beziers(&p, k)
.into_iter()
.filter_map(|c| match c[..] {
[a, b] => Some(Seg2::Line { a, b }),
[a, x, b] => Some(Seg2::Conic { a, apex: x, b, w: 1.0 }),
[p0, p1, p2, p3] => Some(Seg2::Cubic { p0, p1, p2, p3 }),
_ => None,
})
.collect(),
)
}
_ => None,
}
}
/// Polyline of a free-form curve (ellipse, spline, conic); other curves: their segments'
/// polylines.
pub fn polyline(&self, ci: usize) -> Vec<Vec2> {
+79 -9
View File
@@ -46,8 +46,12 @@ struct Edge {
u: usize,
v: usize,
id: String,
/// A piece of an exact free-form segment: (curve, segment, piece, pieces).
tag: Option<Piece>,
}
type Piece = (usize, usize, usize, usize);
/// Parameter of `p` at an end of the shape (lines: 0 or 1), for intersections at end points.
fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
match *sh {
@@ -68,6 +72,41 @@ fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
}
}
/// Put exact free-form segments back where a loop runs over all the pieces of one, in order.
fn restore_exact(segs: Vec<Seg2>, tags: &[Option<(Piece, bool)>], exact: &std::collections::HashMap<usize, Vec<Seg2>>) -> Vec<Seg2> {
let n = segs.len();
if n == 0 || tags.len() != n || tags.iter().all(Option::is_none) {
return segs;
}
let key = |i: usize| tags.get(i % n).copied().flatten().map(|((ci, k, _, _), fwd)| (ci, k, fwd));
// Start where a run begins so no run wraps around.
let r = (0..n).find(|i| key(*i) != key(*i + n - 1)).unwrap_or(0);
let mut out = Vec::with_capacity(n);
let mut i = 0;
while i < n {
let k0 = key(r + i);
let mut j = i + 1;
while j < n && key(r + j) == k0 && k0.is_some() {
j += 1;
}
let run: Vec<usize> = (i..j).map(|x| (r + x) % n).collect();
let whole = match (k0, tags.get(run[0]).copied().flatten()) {
(Some((ci, k, fwd)), Some(((_, _, _, cnt), _))) if run.len() == cnt => {
let order: Vec<usize> = run.iter().filter_map(|x| tags.get(*x).copied().flatten().map(|((_, _, p, _), _)| p)).collect();
let want: Vec<usize> = if fwd { (0..cnt).collect() } else { (0..cnt).rev().collect() };
if order == want { exact.get(&ci).and_then(|e| e.get(k)).map(|s| if fwd { *s } else { s.reversed() }) } else { None }
}
_ => None,
};
match whole {
Some(s) => out.push(s),
None => out.extend(run.iter().filter_map(|x| segs.get(*x).copied())),
}
i = j;
}
out
}
/// A uniform grid over a set of boxes (about one box per cell), for neighbour queries.
struct Grid {
lo: Vec2,
@@ -122,6 +161,11 @@ fn curvature(s: &Seg2) -> f64 {
match *s {
Seg2::Line { .. } => 0.0,
Seg2::Arc { radius, sweep, .. } => sweep.signum() / radius.max(1e-12),
Seg2::Cubic { .. } | Seg2::Conic { .. } => {
let (d1, h) = (s.derivative(0.0), 1e-5);
let d2 = (s.derivative(h) - d1) / h;
d1.cross(d2) / d1.len().powi(3).max(1e-300)
}
}
}
@@ -145,6 +189,8 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let tol = MERGE_TOL * 10.0;
// Analytic shapes of the profile curves.
let mut shapes: Vec<(usize, Shape)> = Vec::new();
let mut tags: Vec<Option<Piece>> = Vec::new();
let mut exact: std::collections::HashMap<usize, Vec<Seg2>> = std::collections::HashMap::new();
for (ci, c) in sk.curves.iter().enumerate() {
if c.construction || c.centerline {
continue;
@@ -167,15 +213,33 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
shapes.push((ci, Shape::Round { c: center, r: radius, start, sweep }));
}
}
// Free-form curves take part as their polylines.
_ => {
for s in sk.segs(ci) {
if let Seg2::Line { a, b } = s {
shapes.push((ci, Shape::Line { a, b }));
// Free-form curves take part as polylines of their exact segments; loops that use
// a whole segment get the exact one back.
_ => match sk.exact_segs(ci) {
Some(ex) => {
for (k, seg) in ex.iter().enumerate() {
let poly = seg.polyline(1e-3);
let n = poly.len().saturating_sub(1);
for (j, w) in poly.windows(2).enumerate() {
if w[0].dist(w[1]) > MERGE_TOL {
shapes.push((ci, Shape::Line { a: w[0], b: w[1] }));
tags.resize(shapes.len() - 1, None);
tags.push(Some((ci, k, j, n)));
}
}
}
exact.insert(ci, ex);
}
None => {
for s in sk.segs(ci) {
if let Seg2::Line { a, b } = s {
shapes.push((ci, Shape::Line { a, b }));
}
}
}
}
},
}
tags.resize(shapes.len(), None);
if shapes.len() > MAX_EDGES {
break;
}
@@ -344,11 +408,13 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
let mut ps = vec![a];
ps.extend(ts.iter().map(|t| sh.point(*t)));
ps.push(b);
// Pieces split by other curves no longer stand for their whole piece.
let tag = if ts.is_empty() { tags.get(k).copied().flatten() } else { None };
for w in ps.windows(2) {
let (p, q) = (w[0], w[1]);
if p.dist(q) > MERGE_TOL {
let (u, v) = (node(p), node(q));
edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone() });
edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag });
}
}
}
@@ -371,7 +437,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
}
let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 };
let (u, v) = (node(seg.start()), node(seg.end()));
edges.push(Edge { seg, u, v, id: id.clone() });
edges.push(Edge { seg, u, v, id: id.clone(), tag: None });
}
}
}
@@ -441,6 +507,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
}
let mut segs = Vec::new();
let mut ids = Vec::new();
let mut ptags: Vec<Option<(Piece, bool)>> = Vec::new();
let mut h = start;
let mut ok = true;
for _ in 0..=edges.len() * 2 {
@@ -454,6 +521,9 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
segs.push(seg);
if let Some(e) = edges.get(h / 2) {
ids.push(e.id.clone());
ptags.push(e.tag.map(|t| (t, h.is_multiple_of(2))));
} else {
ptags.push(None);
}
let back = (-seg.end_tangent()).angle();
let twin = h ^ 1;
@@ -496,7 +566,7 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
if !ok || h != start {
continue;
}
let lp = Loop2 { segs };
let lp = Loop2 { segs: restore_exact(segs, &ptags, &exact) };
if lp.signed_area() > 1e-9 {
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0);
loops.push((lp, ids, c));
+1 -1
View File
@@ -159,7 +159,7 @@ impl Sketch {
CurveKind::Circle { c, r } => Some(Shape::Round { c: self.point(c)?, r, start: 0.0, sweep: TAU }),
CurveKind::Arc { .. } => match self.segs(ci).into_iter().next()? {
Seg2::Arc { center, radius, start, sweep } => Some(Shape::Round { c: center, r: radius, start, sweep }),
Seg2::Line { .. } => None,
_ => None,
},
_ => None,
}