Offset works on splines, ellipses and conics (and chains through them): the offset curve becomes a fit spline within a few hundredths of a millimetre

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 03:55:48 +00:00
1 parent 205ddfcf27
commit 457ed7fed7
2 files changed
+96 -17

No files matched your search

+68 -17
View File
@@ -642,10 +642,7 @@ fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result<Value> {
/// Curves of the connected chain through `start` (joined where exactly two curves meet), in
/// order, with whether each is walked from its stored start (`a`) to its end (`b`).
fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool) {
let ends = |c: usize| match sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => Some((*a, *b)),
_ => None,
};
let ends = |c: usize| sk.curves.get(c).and_then(|c| c.kind.ends());
let Some((s0, s1)) = ends(start) else { return (vec![(start, true)], true) };
let next = |from: usize, at: usize, used: &[usize]| -> Option<usize> {
let users: Vec<usize> = pool.iter().copied().filter(|c| ends(*c).is_some_and(|(a, b)| a == at || b == at)).collect();
@@ -694,11 +691,13 @@ fn chain(sk: &Sketch, start: usize, pool: &[usize]) -> (Vec<(usize, bool)>, bool
}
/// An offset piece: shape walked in chain direction, offset by `d` to the left.
#[derive(Clone, Copy)]
#[derive(Clone)]
enum Piece {
Line(Vec2, Vec2),
/// Centre point index, centre, radius, start point, end point (walk direction), ccw walk.
Arc(usize, Vec2, f64, Vec2, Vec2, bool),
/// A free-form curve's offset, sampled in walk direction (made into a fit spline).
Free(Vec<Vec2>),
}
fn offset_piece(sk: &Sketch, c: usize, forward: bool, d: f64) -> Option<Piece> {
@@ -723,14 +722,46 @@ fn offset_piece(sk: &Sketch, c: usize, forward: bool, d: f64) -> Option<Piece> {
let (s, e) = if forward { (at(pa), at(pb)) } else { (at(pb), at(pa)) };
Some(Piece::Arc(ci, cc, nr, s, e, forward))
}
_ => None,
CurveKind::Circle { .. } => None,
// Free-form: offset the curve's samples along their normals.
_ => {
let mut poly = sk.polyline(c);
if !forward {
poly.reverse();
}
let n = poly.len();
let pts: Vec<Vec2> = (0..n)
.filter_map(|i| {
let (a, b) = (poly.get(i.saturating_sub(1))?, poly.get((i + 1).min(n - 1))?);
let t = (*b - *a).normalized()?;
Some(*poly.get(i)? + t.perp() * d)
})
.collect();
(pts.len() >= 2).then_some(Piece::Free(pts))
}
}
}
fn piece_shape(p: &Piece) -> Shape {
match *p {
Piece::Line(a, b) => Shape::Line { a, b },
Piece::Arc(_, c, r, ..) => Shape::Round { c, r, start: 0.0, sweep: TAU },
match p {
Piece::Line(a, b) => Shape::Line { a: *a, b: *b },
Piece::Arc(_, c, r, ..) => Shape::Round { c: *c, r: *r, start: 0.0, sweep: TAU },
Piece::Free(v) => Shape::Line { a: v.first().copied().unwrap_or_default(), b: v.last().copied().unwrap_or_default() },
}
}
/// Distance from `q` to an offset piece.
fn piece_dist(p: &Piece, q: Vec2) -> f64 {
match p {
Piece::Free(v) => v
.windows(2)
.map(|w| {
let (a, b) = (w[0], w[1]);
let t = if (b - a).len2() > 0.0 { ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0) } else { 0.0 };
q.dist(a + (b - a) * t)
})
.fold(f64::INFINITY, f64::min),
_ => piece_shape(p).dist(q),
}
}
@@ -776,7 +807,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
Some(q) => {
let dist = |dd: f64| {
offset_piece(sk, first, order.iter().find(|x| x.0 == first).is_none_or(|x| x.1), dd)
.map(|pc| piece_shape(&pc).dist(q))
.map(|pc| piece_dist(&pc, q))
.unwrap_or(f64::MAX)
};
if dist(d0.abs()) <= dist(-d0.abs()) { d0.abs() } else { -d0.abs() }
@@ -786,13 +817,15 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let pieces: Vec<Piece> =
order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::<Option<_>>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?;
let n = pieces.len();
let start_of = |p: &Piece| match *p {
Piece::Line(a, _) => a,
Piece::Arc(_, _, _, s, _, _) => s,
let start_of = |p: &Piece| match p {
Piece::Line(a, _) => *a,
Piece::Arc(_, _, _, s, _, _) => *s,
Piece::Free(v) => v.first().copied().unwrap_or_default(),
};
let end_of = |p: &Piece| match *p {
Piece::Line(_, b) => b,
Piece::Arc(_, _, _, _, e, _) => e,
let end_of = |p: &Piece| match p {
Piece::Line(_, b) => *b,
Piece::Arc(_, _, _, _, e, _) => *e,
Piece::Free(v) => v.last().copied().unwrap_or_default(),
};
// Junction points between consecutive pieces.
let joint = |x: &Piece, y: &Piece| -> Vec2 {
@@ -801,6 +834,9 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
return e;
}
let guess = (e + s0) * 0.5;
if matches!(x, Piece::Free(_)) || matches!(y, Piece::Free(_)) {
return guess;
}
intersections(&piece_shape(x), &piece_shape(y)).into_iter().min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))).unwrap_or(guess)
};
let mut joints: Vec<Vec2> = Vec::new();
@@ -815,7 +851,22 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
for (k, pc) in pieces.iter().enumerate() {
let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] });
let (orig, _) = order[k];
match *pc {
match pc.clone() {
Piece::Free(v) => {
// A fit spline through a few of the samples, ending at the joints.
let step = (v.len() / 24).max(1);
let mut idx = vec![pa];
for q in v.iter().skip(step).step_by(step) {
if q.dist(*v.last().unwrap_or(q)) > 1e-6 {
idx.push(sk.add_point(*q, None)?);
}
}
idx.push(pb);
idx.dedup();
if idx.len() >= 2 {
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3 }, None)?);
}
}
Piece::Line(..) => {
let l = sk.add_line_pts(pa, pb, None)?;
cons.push(add_c(sk, ConstraintKind::Parallel { a: orig, b: l })?);
@@ -411,3 +411,31 @@ fn fillet_a_line_and_an_arc() {
let d = c.dist(Vec2::new(30.0, 0.0));
assert!((d - 12.0).abs() < 1e-6, "{d} {c:?}");
}
#[test]
fn offset_free_form_curves() {
let mut s = new_sketch();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let made = ids(&r["curves"]);
assert_eq!(made.len(), 1, "{r}");
let sk = sketch(&s);
let orig = sk.polyline(sk.curve_index(&sp).unwrap());
let off = sk.polyline(sk.curve_index(&made[0]).unwrap());
let dist = |q: Vec2| {
orig.windows(2)
.map(|w| {
let (a, b) = (w[0], w[1]);
let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0);
q.dist(a + (b - a) * t)
})
.fold(f64::INFINITY, f64::min)
};
for q in off.iter().step_by(4) {
assert!((dist(*q) - 2.0).abs() < 0.08, "{q:?} {}", dist(*q));
}
// An ellipse grows by the offset all round.
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
}