EXTEND, JOIN and LENGTHEN on elliptical arcs and splines (part of #466) (#469)

Squash of PR #469 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:41:45 -04:00
1 parent 10f7a9b2a3
commit ea0f866c7c
9 files changed
+1392 -123

No files matched your search

+3 -106
View File
@@ -535,103 +535,14 @@ pub fn kind_distance(k: &EntityKind, p: Vec2) -> f64 {
// ---------------- splines ----------------
/// Insert knot `t` once (Boehm), in homogeneous coordinates.
fn insert_knot(sp: &Spline, t: f64) -> Option<Spline> {
if !sp.is_valid() {
return None;
}
let p = sp.degree;
let n = sp.control.len();
let w = |i: usize| if sp.weights.is_empty() { 1.0 } else { sp.weights.get(i).copied().unwrap_or(1.0) };
// Span k with knots[k] <= t < knots[k+1].
let (lo, hi) = sp.domain();
let t = t.clamp(lo, hi);
let mut k = p;
for i in p..n {
if sp.knots.get(i).copied()? <= t {
k = i;
}
}
let mut ctrl: Vec<(Vec2, f64)> = Vec::with_capacity(n + 1);
for i in 0..=n {
let pt = if i + p <= k {
let c = sp.control.get(i).copied()?;
(c * w(i), w(i))
} else if i > k {
let c = sp.control.get(i - 1).copied()?;
(c * w(i - 1), w(i - 1))
} else {
let ki = sp.knots.get(i).copied()?;
let kip = sp.knots.get(i + p).copied()?;
let a = if (kip - ki).abs() < 1e-300 { 0.0 } else { (t - ki) / (kip - ki) };
let c0 = sp.control.get(i - 1).copied()?;
let c1 = sp.control.get(i).copied()?;
let (h0, w0) = (c0 * w(i - 1), w(i - 1));
let (h1, w1) = (c1 * w(i), w(i));
(h0 * (1.0 - a) + h1 * a, w0 * (1.0 - a) + w1 * a)
};
ctrl.push(pt);
}
let mut knots = sp.knots.clone();
knots.insert(k + 1, t);
let rational = !sp.weights.is_empty();
Some(Spline {
degree: p,
knots,
control: ctrl.iter().map(|(h, w)| if w.abs() > 1e-300 { *h / *w } else { *h }).collect(),
weights: if rational { ctrl.iter().map(|(_, w)| *w).collect() } else { Vec::new() },
fit: Vec::new(),
closed: false,
fit_opts: Default::default(),
})
}
/// Split a spline at parameter `t` into (left, right). `None` at the ends.
pub fn split_spline(sp: &Spline, t: f64) -> Option<(Spline, Spline)> {
let (lo, hi) = sp.domain();
if !(t > lo + 1e-12 && t < hi - 1e-12) {
return None;
}
let mut s = sp.clone();
s.fit.clear();
let mult = |s: &Spline| s.knots.iter().filter(|k| (**k - t).abs() < 1e-12).count();
let mut guard = 0;
while mult(&s) < s.degree && guard < 64 {
s = insert_knot(&s, t)?;
guard += 1;
}
let p = s.degree;
// Knots equal to t now have multiplicity p; first index of t:
let first = s.knots.iter().position(|k| (*k - t).abs() < 1e-12)?;
// Left: control points 0..first (count = first), knots 0..first+p ... + t
let left_n = first;
let mut lk: Vec<f64> = s.knots.get(..first)?.to_vec();
lk.extend(std::iter::repeat_n(t, p + 1));
let lc = s.control.get(..left_n)?.to_vec();
let lw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(..left_n)?.to_vec() };
let right_start = first.checked_sub(1)?;
let mut rk = vec![t; p + 1];
rk.extend(s.knots.get(first + p..)?.iter().copied());
let rc = s.control.get(right_start..)?.to_vec();
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(right_start..)?.to_vec() };
let l = Spline { degree: p, knots: lk, control: lc, weights: lw, ..Spline::default() };
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, ..Spline::default() };
(l.is_valid() && r.is_valid()).then_some((l, r))
sp.split_at(t)
}
/// The part of a spline between parameters `a < b`.
pub fn sub_spline(sp: &Spline, a: f64, b: f64) -> Option<Spline> {
let (lo, hi) = sp.domain();
let mut s = sp.clone();
s.fit.clear();
s.closed = false;
if b < hi - 1e-12 {
s = split_spline(&s, b)?.0;
}
if a > lo + 1e-12 {
s = split_spline(&s, a)?.1;
}
Some(s)
sp.sub_between(a, b)
}
/// Parameter of the point on the spline nearest `p`.
@@ -662,21 +573,7 @@ pub fn spline_param_of(sp: &Spline, p: Vec2) -> f64 {
/// Reverse a spline's direction (control, weights, fit points and knots).
pub fn reverse_spline(sp: &Spline) -> Spline {
let (lo, hi) = (sp.knots.first().copied().unwrap_or(0.0), sp.knots.last().copied().unwrap_or(1.0));
Spline {
degree: sp.degree,
knots: sp.knots.iter().rev().map(|k| lo + hi - k).collect(),
control: sp.control.iter().rev().copied().collect(),
weights: sp.weights.iter().rev().copied().collect(),
fit: sp.fit.iter().rev().copied().collect(),
closed: sp.closed,
// The end tangents swap ends and turn round.
fit_opts: cadcraft_geom::FitOptions {
start_tangent: sp.fit_opts.end_tangent.map(|t| -t),
end_tangent: sp.fit_opts.start_tangent.map(|t| -t),
..sp.fit_opts
},
}
sp.reversed()
}
/// Reverse an LWPOLYLINE's vertex order, keeping its shape (bulges and widths move with segments).
+14 -4
View File
@@ -1392,6 +1392,9 @@ fn run_breakat(s: &mut Session, p: &Value) -> Result<Value> {
/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines).
pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
if let Some(h) = super::modify2::join_curves(s, hs)? {
return Ok(Some(h));
}
let d = s.doc()?;
let mut segs: Vec<Segment> = Vec::new();
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
@@ -1536,10 +1539,17 @@ impl SelectThen {
}
Op::Join => {
match join(s, &self.objs) {
Ok(_) => {
// Objects outside the joined chain are kept, so count only the ones JOIN consumed.
let n = s.doc().map(|d| self.objs.iter().filter(|h| d.entity(**h).is_none()).count()).unwrap_or(0);
s.echo(format!("{n} objects joined into 1 polyline"))
Ok(h) => {
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
// (and the source object an ellipse or spline join keeps).
let d = s.doc()?;
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
Some(EntityKind::Spline(_)) => "spline",
Some(EntityKind::Ellipse(_)) => "ellipse",
_ => "polyline",
};
s.echo(format!("{n} objects joined into 1 {what}"))
}
Err(e) => s.echo(e.to_string()),
}
+14 -3
View File
@@ -14,6 +14,9 @@ use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod curve_edits;
pub(crate) use curve_edits::join_curves;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("pedit", "Polyline", run_pedit)
@@ -830,11 +833,13 @@ fn measure_len(k: &EntityKind) -> Option<(f64, Option<f64>)> {
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
Some((g.len(), Some(g.sweep())))
}
EntityKind::Ellipse(e) => Some((ellipse_of(e).length(), None)),
EntityKind::Spline(sp) if sp.is_valid() => Some((sp.length(), None)),
_ => Chain::of(k).map(|c| (c.len(), None)),
}
}
/// Lengthen a line, arc or open polyline at the end nearest `pick`.
/// Lengthen a line, arc, open polyline, elliptical arc or open spline at the end nearest `pick`.
pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
let too_short = || other("Lengthening would make the object zero length or negative.");
match k {
@@ -919,7 +924,9 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
}
Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() }))
}
_ => Err(other("Select a line, arc or open polyline.")),
EntityKind::Ellipse(e) => curve_edits::lengthen_ellipse(e, pick, mode),
EntityKind::Spline(sp) => curve_edits::lengthen_spline(sp, pick, mode),
_ => Err(other("Select a line, arc, open polyline, elliptical arc or open spline.")),
}
}
@@ -2059,6 +2066,9 @@ pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<
ts.sort_by(f64::total_cmp);
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let tp = curves::spline_param_of(sp, pick);
if sp.closed {
return curve_edits::trim_closed_spline(sp, &ts, tp);
}
let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max);
let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min);
if lo == lo_d && hi == hi_d {
@@ -2081,7 +2091,7 @@ pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<
}
}
/// Extend objects that modify.rs does not handle itself (elliptical arcs).
/// Extend objects that modify.rs does not handle itself (elliptical arcs and open splines).
pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<EntityKind> {
match k {
EntityKind::Ellipse(e) => {
@@ -2105,6 +2115,7 @@ pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Resul
let t = t.ok_or_else(|| other("Object does not intersect an edge."))?;
Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } }))
}
EntityKind::Spline(sp) => curve_edits::extend_spline(sp, cut, pick),
_ => Err(other("Cannot extend that object.")),
}
}
@@ -0,0 +1,311 @@
//! EXTEND, LENGTHEN, JOIN and closed-spline TRIM for elliptical arcs and splines (#466).
//!
//! The curve mathematics (knot insertion, joining, tangent extensions, arc length) lives in
//! `cadcraft_geom::Spline` / `cadcraft_geom::Ellipse`; this module applies it to entities.
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Ellipse, Line, Polyline, Segment, Spline, TAU, Vec2, intersect_ext, norm_angle};
use super::{LenMode, curves, ellipse_kind, ellipse_of, other, set_kind};
use crate::{Result, Session};
/// EXTEND a spline's end nearest `pick` along its end tangent to the nearest boundary edge.
pub(super) fn extend_spline(sp: &Spline, cut: &[Segment], pick: Vec2) -> Result<EntityKind> {
if sp.closed {
return Err(other("Cannot extend a closed spline."));
}
if !sp.is_valid() {
return Err(other("Invalid spline."));
}
let (lo, hi) = sp.domain();
let at_end = pick.dist(sp.eval(hi)) <= pick.dist(sp.eval(lo));
let (end, dir) = if at_end { (sp.eval(hi), sp.unit_tangent(hi)) } else { (sp.eval(lo), -sp.unit_tangent(lo)) };
if dir == Vec2::ZERO {
return Err(other("The spline has no direction at that end."));
}
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let d = cut.iter().flat_map(|c| intersect_ext(&ray, c, false)).map(|x| (x - end).dot(dir)).filter(|d| *d > 1e-9).fold(f64::INFINITY, f64::min);
if !d.is_finite() {
return Err(other("Object does not intersect an edge."));
}
sp.extended(at_end, d).map(EntityKind::Spline).ok_or_else(|| other("Cannot extend that spline."))
}
/// TRIM a closed spline at the hits `ts` (sorted parameters) around the pick parameter `tp`:
/// the piece between the two hits around the pick goes, the rest stays as one open spline.
pub(super) fn trim_closed_spline(sp: &Spline, ts: &[f64], tp: f64) -> Result<Vec<EntityKind>> {
let (Some(&first), Some(&last)) = (ts.first(), ts.last()) else {
return Err(other("Spline must intersect cutting edges at two or more points."));
};
if ts.len() < 2 {
return Err(other("Spline must intersect cutting edges at two or more points."));
}
let fail = || other("Cannot trim that spline.");
let (lo_d, hi_d) = sp.domain();
let kept = if tp > first && tp < last {
// The removed piece lies inside the domain: keep the run from the next hit round the
// seam to the previous one.
let lo = ts.iter().copied().filter(|t| *t < tp).fold(first, f64::max);
let hi = ts.iter().copied().filter(|t| *t > tp).fold(last, f64::min);
let tail = sp.clamped().and_then(|c| c.sub_between(hi, hi_d)).ok_or_else(fail)?;
let head = sp.clamped().and_then(|c| c.sub_between(lo_d, lo)).ok_or_else(fail)?;
tail.joined(&head).ok_or_else(fail)?
} else {
// The removed piece crosses the seam.
sp.sub_between(first, last).ok_or_else(fail)?
};
Ok(vec![EntityKind::Spline(Spline { closed: false, ..kept })])
}
fn too_short() -> crate::EngineError {
other("Lengthening would make the object zero length or negative.")
}
/// LENGTHEN an elliptical arc along its ellipse at the end nearest `pick`.
pub(super) fn lengthen_ellipse(e: &cadcraft_doc::Ellipse, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
let g = ellipse_of(e);
if g.is_full() {
return Err(other("Cannot lengthen a closed ellipse."));
}
let at_end = pick.is_none_or(|p| p.dist(g.at_param(g.end)) <= p.dist(g.at_param(g.start)));
let len = g.length();
let target = match mode {
LenMode::Delta(d) => len + d,
LenMode::Percent(p) => len * p / 100.0,
LenMode::Total(t) => t,
LenMode::To(q) => {
let t = g.param_of(q);
let sweep = if at_end { cadcraft_geom::ccw_sweep(g.start, t) } else { cadcraft_geom::ccw_sweep(t, g.end) };
if !(sweep > 1e-9 && sweep < TAU - 1e-9) {
return Err(too_short());
}
return Ok(ellipse_kind(&if at_end { Ellipse { end: t, ..g } } else { Ellipse { start: t, ..g } }));
}
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
};
if !(target.is_finite() && target > 1e-12) {
return Err(too_short());
}
let full = Ellipse { start: 0.0, end: TAU, ..g }.length();
if target >= full - 1e-9 * full.max(1.0) {
return Err(other("The new elliptical arc would be a full ellipse or longer."));
}
let t = if at_end { g.param_at_length(g.start, target) } else { g.param_at_length(g.end, -target) };
let t = norm_angle(t.ok_or_else(too_short)?);
Ok(ellipse_kind(&if at_end { Ellipse { end: t, ..g } } else { Ellipse { start: t, ..g } }))
}
/// LENGTHEN an open spline at the end nearest `pick`: shorter cuts it along the curve, longer
/// runs it on along its end tangent.
pub(super) fn lengthen_spline(sp: &Spline, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
if sp.closed {
return Err(other("Cannot lengthen a closed spline."));
}
if !sp.is_valid() {
return Err(other("Invalid spline."));
}
let (lo, hi) = sp.domain();
let at_end = pick.is_none_or(|p| p.dist(sp.eval(hi)) <= p.dist(sp.eval(lo)));
let len = sp.length();
let target = match mode {
LenMode::Delta(d) => len + d,
LenMode::Percent(p) => len * p / 100.0,
LenMode::Total(t) => t,
LenMode::To(q) => {
let (end_t, dir) = if at_end { (hi, sp.unit_tangent(hi)) } else { (lo, -sp.unit_tangent(lo)) };
let t = curves::spline_param_of(sp, q);
let beyond = (q - sp.eval(end_t)).dot(dir);
if (t - end_t).abs() <= 1e-9 * (hi - lo) && beyond > 1e-9 {
len + beyond
} else if at_end {
sp.length_between(lo, t)
} else {
sp.length_between(t, hi)
}
}
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
};
if !(target.is_finite() && target > 1e-9) {
return Err(too_short());
}
let fail = || other("Cannot lengthen that spline.");
let out = if target > len + 1e-12 {
sp.extended(at_end, target - len).ok_or_else(fail)?
} else if target >= len - 1e-12 {
sp.clone()
} else if at_end {
sp.sub_between(lo, sp.param_at_length(lo, target)).ok_or_else(fail)?
} else {
sp.sub_between(sp.param_at_length(hi, -target), hi).ok_or_else(fail)?
};
Ok(EntityKind::Spline(out))
}
/// Parameter offset that maps `b`'s parameters onto `a`'s when both are the same ellipse
/// (`a_param = b_param + offset`).
fn same_ellipse(a: &Ellipse, b: &Ellipse) -> Option<f64> {
let tol = 1e-6 * a.major.len().max(1.0);
if !a.center.near(b.center, tol) || (a.ratio - b.ratio).abs() > 1e-9 || (a.major.len() - b.major.len()).abs() > tol {
return None;
}
if (a.ratio - 1.0).abs() <= 1e-9 {
return Some(b.major.angle() - a.major.angle());
}
if a.major.near(b.major, tol) {
Some(0.0)
} else if a.major.near(-b.major, tol) {
Some(std::f64::consts::PI)
} else {
None
}
}
/// Elliptical arcs on `src`'s ellipse (as start and sweep in `src`'s parameters) merged into one
/// arc: everything but the widest gap between them, or the whole ellipse when no gap is left.
fn merge_arcs(src: &Ellipse, arcs: &[(f64, f64)]) -> Ellipse {
let tol = 1e-9;
let mut iv: Vec<(f64, f64)> = arcs.iter().map(|(s, w)| (norm_angle(s - src.start), *w)).collect();
iv.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut gaps: Vec<(f64, f64)> = Vec::new();
let mut end = f64::NEG_INFINITY;
for (s, w) in iv {
if end.is_finite() && s > end + tol {
gaps.push((end, s));
}
end = if end.is_finite() { end.max(s + w) } else { s + w };
}
if end < TAU - tol {
gaps.push((end, TAU));
} else if end > TAU {
// An arc running past the source's start covers the beginning of later gaps.
let over = end - TAU;
gaps = gaps.into_iter().map(|(a, b)| (a.max(over), b)).filter(|(a, b)| b - a > tol).collect();
}
match gaps.into_iter().max_by(|a, b| (a.1 - a.0).total_cmp(&(b.1 - b.0))) {
Some((a, b)) => Ellipse { start: norm_angle(src.start + b), end: norm_angle(src.start + a), ..*src },
None => Ellipse { start: 0.0, end: TAU, ..*src },
}
}
/// A joinable entity as exact spline pieces (lines, arcs and polyline segments become splines).
fn spline_pieces(k: &EntityKind) -> Vec<Spline> {
let arc = |c: Vec2, r: f64, start: f64, sweep: f64| Spline::from_conic_arc(c, Vec2::X * r, Vec2::Y * r, start, sweep);
match k {
EntityKind::Line(l) if !l.a.xy().near(l.b.xy(), 1e-12) => vec![Spline::from_line(l.a.xy(), l.b.xy(), 1)],
EntityKind::Arc(a) => {
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
arc(g.center, g.radius, g.start, g.sweep()).into_iter().collect()
}
EntityKind::LwPolyline(p) if !p.closed => Polyline { vertices: p.vertices.clone(), closed: false }
.segments()
.into_iter()
.filter(|sg| sg.len() > 1e-12)
.filter_map(|sg| match sg {
Segment::Line(l) => Some(Spline::from_line(l.a, l.b, 1)),
Segment::Arc { arc: a, ccw: true } => arc(a.center, a.radius, a.start, a.sweep()),
Segment::Arc { arc: a, ccw: false } => arc(a.center, a.radius, a.end, -a.sweep()),
})
.collect(),
EntityKind::Ellipse(e) => {
let g = ellipse_of(e);
if g.is_full() { Vec::new() } else { Spline::from_ellipse(&g).into_iter().collect() }
}
EntityKind::Spline(sp) if !sp.closed => sp.clamped().into_iter().collect(),
_ => Vec::new(),
}
}
fn ends(sp: &Spline) -> (Vec2, Vec2) {
let (lo, hi) = sp.domain();
(sp.eval(lo), sp.eval(hi))
}
/// JOIN for selections holding elliptical arcs or splines; `None` when there are neither (the
/// polyline join handles those). Elliptical arcs that all lie on one ellipse merge into a
/// larger arc or the full ellipse (gaps between them close). Otherwise the objects joined end
/// to end with the first one become one spline. The first joined object keeps its handle.
pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
let d = s.doc()?;
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
if !ents.iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
return Ok(None);
}
if ents.iter().any(|e| curves::is_locked(s, e.handle)) {
return Err(other("An object is on a locked layer."));
}
let ells: Vec<(Handle, Ellipse)> =
ents.iter().filter_map(|e| if let EntityKind::Ellipse(x) = &e.kind { Some((e.handle, ellipse_of(x))) } else { None }).collect();
if let Some((src_h, src)) = ells.first().copied()
&& ells.len() == ents.len()
&& ells.len() >= 2
{
let offsets: Option<Vec<f64>> = ells.iter().map(|(_, e)| same_ellipse(&src, e)).collect();
if let Some(offsets) = offsets {
let arcs: Vec<(f64, f64)> = ells.iter().zip(&offsets).map(|((_, e), o)| (e.start + o, e.sweep())).collect();
let merged = merge_arcs(&src, &arcs);
set_kind(s, src_h, ellipse_kind(&merged))?;
let doc = s.doc_mut()?;
for (h, _) in ells.iter().skip(1) {
doc.remove_entity(*h);
}
s.set_selection(Vec::new());
return Ok(Some(src_h));
}
}
// A spline through the objects that connect end to end with the first one.
let mut pool: Vec<(Handle, Spline)> = ents.iter().flat_map(|e| spline_pieces(&e.kind).into_iter().map(move |p| (e.handle, p))).collect();
if pool.is_empty() {
return Err(other("Select at least two objects to join."));
}
let (first_h, first) = pool.remove(0);
let (mut head, mut tail) = ends(&first);
let mut chain = std::collections::VecDeque::from([first]);
let mut used = vec![first_h];
let tol = 1e-6;
let touches = |p: &Spline, head: Vec2, tail: Vec2| {
let (a, b) = ends(p);
a.near(tail, tol) || b.near(tail, tol) || a.near(head, tol) || b.near(head, tol)
};
while let Some(i) = pool.iter().position(|(_, p)| touches(p, head, tail)) {
let (h, p) = pool.remove(i);
let (a, b) = ends(&p);
if a.near(tail, tol) {
tail = b;
chain.push_back(p);
} else if b.near(tail, tol) {
tail = a;
chain.push_back(p.reversed());
} else if b.near(head, tol) {
head = a;
chain.push_front(p);
} else {
head = b;
chain.push_front(p.reversed());
}
used.push(h);
}
if chain.len() < 2 {
return Err(other("Objects are not connected end to end."));
}
// A polyline joins whole or not at all.
if pool.iter().any(|(h, _)| used.contains(h)) {
return Err(other("Only part of a polyline connects; join it whole or explode it first."));
}
used.sort();
used.dedup();
let mut it = chain.into_iter();
let mut out = it.next().ok_or_else(|| other("Nothing to join."))?;
for p in it {
out = out.joined(&p).ok_or_else(|| other("Cannot join these objects into a spline."))?;
}
let (a, b) = ends(&out);
out.closed = a.near(b, tol);
let keep = first_h;
set_kind(s, keep, EntityKind::Spline(out))?;
let doc = s.doc_mut()?;
for h in used.iter().filter(|h| **h != keep) {
doc.remove_entity(*h);
}
s.set_selection(Vec::new());
Ok(Some(keep))
}
+323
View File
@@ -0,0 +1,323 @@
//! TRIM, EXTEND, JOIN and LENGTHEN on elliptical arcs and splines (#466).
use std::f64::consts::{FRAC_PI_2, PI};
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use cadcraft_engine::geom::{Ellipse, Spline, Vec2};
use serde_json::{Value, json};
fn h(r: &Value) -> String {
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
}
fn kind(s: &Session, h: &str) -> EntityKind {
s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone()
}
fn spline(s: &Session, h: &str) -> Spline {
match kind(s, h) {
EntityKind::Spline(sp) => sp,
k => panic!("not a spline: {k:?}"),
}
}
fn ellipse(s: &Session, h: &str) -> Ellipse {
match kind(s, h) {
EntityKind::Ellipse(e) => Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end },
k => panic!("not an ellipse: {k:?}"),
}
}
fn ends(sp: &Spline) -> (Vec2, Vec2) {
let (lo, hi) = sp.domain();
(sp.eval(lo), sp.eval(hi))
}
fn samples(sp: &Spline, n: usize) -> Vec<Vec2> {
let (lo, hi) = sp.domain();
(0..=n).map(|i| sp.eval(lo + (hi - lo) * i as f64 / n as f64)).collect()
}
/// Distance from `p` to the spline (dense sampling, then a ternary search round the best sample).
fn dist_to(sp: &Spline, p: Vec2) -> f64 {
let (lo, hi) = sp.domain();
let n = 4000;
let step = (hi - lo) / n as f64;
let best = (0..=n).map(|i| lo + step * i as f64).min_by(|a, b| sp.eval(*a).dist(p).total_cmp(&sp.eval(*b).dist(p))).unwrap();
let (mut a, mut b) = ((best - step).max(lo), (best + step).min(hi));
for _ in 0..100 {
let (m1, m2) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
if sp.eval(m1).dist(p) < sp.eval(m2).dist(p) {
b = m2;
} else {
a = m1;
}
}
sp.eval((a + b) / 2.0).dist(p)
}
fn count(s: &Session) -> usize {
s.doc().unwrap().model.iter().count()
}
fn length(s: &mut Session, h: &str) -> f64 {
s.execute("lengthen", &json!({ "handle": h })).unwrap()["length"].as_f64().unwrap()
}
/// An S-shaped open cubic from (0,0) to (9,0); it leaves its end heading along (1,1).
fn s_curve(s: &mut Session) -> String {
h(&s.execute("spline", &json!({ "control": [[0, 0], [3, 3], [6, -3], [9, 0]] })).unwrap())
}
// ---------------- EXTEND ----------------
#[test]
fn extend_runs_a_spline_on_along_its_end_tangent() {
let mut s = Session::new();
let sp = s_curve(&mut s);
s.execute("line", &json!({ "points": [[12, -10], [12, 20]] })).unwrap();
let before = spline(&s, &sp);
s.execute("extend", &json!({ "handle": sp, "pick": [8.9, 0] })).unwrap();
let after = spline(&s, &sp);
let (a, b) = ends(&after);
assert!(a.near(Vec2::ZERO, 1e-9), "{a:?}");
assert!(b.near(Vec2::new(12.0, 3.0), 1e-9), "{b:?}");
// The original curve is still there, unchanged.
for p in samples(&before, 20) {
assert!(dist_to(&after, p) < 1e-9, "{p:?}");
}
// The start end extends backwards along its tangent, (-1,-1) here.
s.execute("line", &json!({ "points": [[-10, -3], [10, -3]] })).unwrap();
s.execute("extend", &json!({ "handle": sp, "pick": [0.1, 0.1] })).unwrap();
assert!(ends(&spline(&s, &sp)).0.near(Vec2::new(-3.0, -3.0), 1e-9));
// Nothing ahead: an error, the spline unchanged.
let now = spline(&s, &sp);
assert!(s.execute("extend", &json!({ "handle": sp, "pick": [11.9, 2.9] })).is_err());
assert_eq!(spline(&s, &sp), now);
}
#[test]
fn extend_refuses_closed_splines() {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4], [0, 4]], "closed": true })).unwrap());
s.execute("line", &json!({ "points": [[10, -10], [10, 10]] })).unwrap();
assert!(s.execute("extend", &json!({ "handle": sp, "pick": [4, 2] })).is_err());
}
// ---------------- TRIM ----------------
#[test]
fn trimming_a_closed_spline_leaves_one_open_spline() {
for (pick, right) in [([10.0, 5.0], false), ([0.0, 5.0], true)] {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({ "fit": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true })).unwrap());
s.execute("line", &json!({ "points": [[5, -5], [5, 15]] })).unwrap();
let r = s.execute("trim", &json!({ "handle": sp, "pick": pick })).unwrap();
let hs = r["handles"].as_array().unwrap();
assert_eq!(hs.len(), 1, "{pick:?}: {r}");
let piece = spline(&s, hs[0].as_str().unwrap());
assert!(!piece.closed);
let (a, b) = ends(&piece);
assert!((a.x - 5.0).abs() < 1e-6 && (b.x - 5.0).abs() < 1e-6, "{a:?} {b:?}");
// Only the half away from the pick is left.
for p in samples(&piece, 100) {
if right {
assert!(p.x > 5.0 - 1e-6, "{pick:?}: {p:?}");
} else {
assert!(p.x < 5.0 + 1e-6, "{pick:?}: {p:?}");
}
}
}
}
// ---------------- LENGTHEN ----------------
/// A quarter of the ellipse x²/16 + y²/4 = 1, from (4,0) to (0,2).
fn quarter_ellipse(s: &mut Session) -> String {
h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap())
}
#[test]
fn lengthen_elliptical_arcs_along_the_ellipse() {
let mut s = Session::new();
let e = quarter_ellipse(&mut s);
let l0 = length(&mut s, &e);
assert!((l0 - ellipse(&s, &e).length()).abs() < 1e-9);
// DElta at the end nearest the pick (the (0,2) end); the start stays.
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "delta": 1.0 })).unwrap();
assert!((length(&mut s, &e) - (l0 + 1.0)).abs() < 1e-6);
assert!(ellipse(&s, &e).start.abs() < 1e-12);
// Total, Percent; picking the start end moves the start.
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "total": l0 })).unwrap();
assert!((ellipse(&s, &e).end - FRAC_PI_2).abs() < 1e-6);
s.execute("lengthen", &json!({ "handle": e, "pick": [4, 0], "percent": 200 })).unwrap();
assert!((length(&mut s, &e) - 2.0 * l0).abs() < 1e-6);
assert!((ellipse(&s, &e).end - FRAC_PI_2).abs() < 1e-6);
assert!((ellipse(&s, &e).start - 1.5 * PI).abs() < 1e-6, "{}", ellipse(&s, &e).start);
// DYnamic / To: the end follows the point round the ellipse.
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "to": [-4, 0] })).unwrap();
assert!((ellipse(&s, &e).end - PI).abs() < 1e-9);
// Too long (a full ellipse), too short, angle options: errors, the arc unchanged.
let before = ellipse(&s, &e);
for p in [json!({ "total": 1000.0 }), json!({ "delta": -1000.0 }), json!({ "deltaAngle": 10 }), json!({ "percent": f64::MIN_POSITIVE })] {
let mut q = p.clone();
q["handle"] = json!(e);
q["pick"] = json!([0, 2]);
assert!(s.execute("lengthen", &q).is_err(), "{q}");
assert_eq!(ellipse(&s, &e), before);
}
}
#[test]
fn lengthen_open_splines() {
let mut s = Session::new();
let sp = s_curve(&mut s);
let before = spline(&s, &sp);
let l0 = length(&mut s, &sp);
// Longer: runs on along the end tangent.
s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": 2f64.sqrt() })).unwrap();
assert!(ends(&spline(&s, &sp)).1.near(Vec2::new(10.0, 1.0), 1e-9));
assert!((length(&mut s, &sp) - l0 - 2f64.sqrt()).abs() < 1e-6);
// Shorter from the start: the far end stays put.
s.execute("lengthen", &json!({ "handle": sp, "pick": [0, 0], "total": 5.0 })).unwrap();
let now = spline(&s, &sp);
assert!((length(&mut s, &sp) - 5.0).abs() < 1e-6);
assert!(ends(&now).1.near(Vec2::new(10.0, 1.0), 1e-9));
assert!(dist_to(&before, ends(&now).0) < 1e-9, "the new start lies on the old curve");
// To a point on the curve: cut there.
let mut s = Session::new();
let sp = s_curve(&mut s);
let mid = before.eval(0.5);
s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "to": [mid.x, mid.y] })).unwrap();
assert!(ends(&spline(&s, &sp)).1.near(mid, 1e-6));
// Closed splines and nonsense lengths are refused.
let c = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4]], "closed": true })).unwrap());
assert!(s.execute("lengthen", &json!({ "handle": c, "delta": 1 })).is_err());
assert!(s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": -1e9 })).is_err());
}
#[test]
fn lengthen_at_the_command_line_on_an_elliptical_arc() {
let mut s = Session::new();
let e = quarter_ellipse(&mut s);
let l0 = ellipse(&s, &e).length();
s.script("LENGTHEN DE 1 0,2\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!((ellipse(&s, &e).length() - l0 - 1.0).abs() < 1e-6);
}
// ---------------- JOIN ----------------
fn arc_on_ellipse(s: &mut Session, start: f64, end: f64) -> String {
h(&s.execute("ellipse", &json!({ "center": [1, 1], "major": [4, 0], "ratio": 0.5, "start": start, "end": end })).unwrap())
}
#[test]
fn join_merges_elliptical_arcs_on_one_ellipse() {
// Touching arcs: one larger arc.
let mut s = Session::new();
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
let b = arc_on_ellipse(&mut s, 90.0, 180.0);
let r = s.execute("join", &json!({ "handles": [a, b] })).unwrap();
assert_eq!(h(&r), a, "the source arc is kept");
let m = ellipse(&s, &a);
assert!(m.start.abs() < 1e-9 && (m.end - PI).abs() < 1e-9, "{m:?}");
assert_eq!(count(&s), 1);
// A gap closes; the widest gap stays open.
let mut s = Session::new();
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
let b = arc_on_ellipse(&mut s, 120.0, 270.0);
s.execute("join", &json!({ "handles": [a, b] })).unwrap();
let m = ellipse(&s, &a);
assert!(m.start.abs() < 1e-9 && (m.end - 1.5 * PI).abs() < 1e-9, "{m:?}");
// Two halves: the full ellipse. The same ellipse with its major axis the other way round
// (parameters shifted by half a turn) counts.
let mut s = Session::new();
let a = arc_on_ellipse(&mut s, 0.0, 180.0);
let b = h(&s.execute("ellipse", &json!({ "center": [1, 1], "major": [-4, 0], "ratio": 0.5, "start": 0, "end": 180 })).unwrap());
s.execute("join", &json!({ "handles": [a, b] })).unwrap();
assert!(ellipse(&s, &a).is_full());
assert_eq!(count(&s), 1);
}
#[test]
fn join_makes_one_spline_from_lines_arcs_polylines_and_splines() {
let mut s = Session::new();
let sp = s_curve(&mut s);
let before = spline(&s, &sp);
let line = h(&s.execute("line", &json!({ "points": [[-5, 0], [0, 0]] })).unwrap());
// An arc leaving (9,0) and a polyline (with an arc segment) after it, picked in any order.
let arc = h(&s.execute("arc", &json!({ "center": [9, 3], "radius": 3, "start": 270, "end": 0 })).unwrap());
let pl = h(&s.execute("pline", &json!({ "vertices": [[12, 3], { "p": [12, 6], "bulge": 0.5 }, [14, 6]] })).unwrap());
let far = h(&s.execute("line", &json!({ "points": [[50, 50], [60, 60]] })).unwrap());
s.execute("join", &json!({ "handles": [line, pl, sp, arc, far] })).unwrap();
let j = spline(&s, &line);
let (a, b) = ends(&j);
assert!(a.near(Vec2::new(-5.0, 0.0), 1e-9), "{a:?}");
assert!(b.near(Vec2::new(14.0, 6.0), 1e-9), "{b:?}");
// Every joined piece lies on the result, exactly for the arc.
for p in samples(&before, 20) {
assert!(dist_to(&j, p) < 1e-9, "{p:?}");
}
for k in 0..=8 {
let t = -FRAC_PI_2 + FRAC_PI_2 * k as f64 / 8.0;
assert!(dist_to(&j, Vec2::new(9.0 + 3.0 * t.cos(), 3.0 + 3.0 * t.sin())) < 1e-9);
}
// The unconnected line stays; the others are consumed.
assert_eq!(count(&s), 2);
assert!(matches!(kind(&s, &far), EntityKind::Line(_)));
assert!(s.doc().unwrap().entity(Handle::parse_hex(&sp).unwrap()).is_none());
}
#[test]
fn join_at_the_command_line_names_the_result() {
let mut s = Session::new();
s_curve(&mut s);
s.execute("line", &json!({ "points": [[9, 0], [12, 0]] })).unwrap();
s.script("JOIN ALL\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(s.log.iter().any(|l| l.contains("2 objects joined into 1 spline")), "{:?}", s.log);
assert!(matches!(s.doc().unwrap().model.last().unwrap().kind, EntityKind::Spline(_)));
assert_eq!(count(&s), 1);
}
#[test]
fn join_refuses_unconnected_curves() {
let mut s = Session::new();
let sp = s_curve(&mut s);
let l = h(&s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap());
assert!(s.execute("join", &json!({ "handles": [sp, l] })).is_err());
assert_eq!(count(&s), 2);
// Arcs on different ellipses that do not touch.
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
let b = h(&s.execute("ellipse", &json!({ "center": [40, 40], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap());
assert!(s.execute("join", &json!({ "handles": [a, b] })).is_err());
}
#[test]
fn hostile_curve_edits_do_not_panic() {
let mut s = Session::new();
let sp = s_curve(&mut s);
let e = quarter_ellipse(&mut s);
// A degenerate spline (all control points equal) and a huge-degree one.
let d = h(&s.execute("spline", &json!({ "control": [[1, 1], [1, 1], [1, 1], [1, 1]] })).unwrap());
s.execute("line", &json!({ "points": [[0, -50], [0, 50]] })).unwrap();
for target in [&sp, &e, &d] {
for p in [
json!({ "delta": 1e308 }),
json!({ "delta": f64::MAX }),
json!({ "total": 1e-300 }),
json!({ "percent": 1e300 }),
json!({ "to": [1e308, -1e308] }),
json!({ "to": [0, 0] }),
] {
let mut q = p.clone();
q["handle"] = json!(target);
let _ = s.execute("lengthen", &q);
}
let _ = s.execute("extend", &json!({ "handle": target, "pick": [1e308, 1e308] }));
let _ = s.execute("trim", &json!({ "handle": target, "pick": [-1e308, 0] }));
let _ = s.execute("join", &json!({ "handles": [target, sp, e, d] }));
}
}
+1
View File
@@ -11,6 +11,7 @@ mod bounds;
mod curve;
mod intersect;
mod mat;
mod nurbs;
pub mod region;
mod spline;
mod spline_fit;
+715
View File
@@ -0,0 +1,715 @@
//! NURBS operations for the editing commands (TRIM, EXTEND, JOIN, LENGTHEN, FILLET): first
//! derivatives, knot insertion (Boehm), splitting, clamping, Bézier decomposition, degree
//! elevation, end-to-end joining, rational splines for lines and circular/elliptical arcs,
//! straight end extensions and arc length; plus arc length along an ellipse.
//!
//! Textbook algorithms written from the mathematics. Every loop is capped and degenerate input
//! gives `None` (or a zero vector), never a panic or NaN.
use std::f64::consts::FRAC_PI_2;
use crate::spline::{basis, find_span};
use crate::{EPS, Ellipse, Spline, TAU, Vec2};
/// Highest degree the editing operations work with.
pub const MAX_EDIT_DEGREE: usize = 25;
/// Most control points a single knot insertion grows a spline to.
const MAX_EDIT_CONTROL: usize = 50_000;
/// Most knot insertions one split makes (the degree of any spline drawn in practice).
const MAX_SPLIT_INSERTS: usize = 64;
/// Most control points a Bézier decomposition (and so a degree elevation or join) produces.
const MAX_DECOMPOSED: usize = 10_000;
/// A homogeneous control point: (w·P, w).
type H = (Vec2, f64);
fn hlerp(a: H, b: H, t: f64) -> H {
(a.0 * (1.0 - t) + b.0 * t, a.1 * (1.0 - t) + b.1 * t)
}
fn hpoint(h: H) -> Vec2 {
if h.1.abs() > 1e-300 { h.0 / h.1 } else { h.0 }
}
fn poly_len(pts: &[Vec2]) -> f64 {
pts.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).sum()
}
/// Five-point Gauss–Legendre nodes and weights on [-1, 1].
const GAUSS: [(f64, f64); 5] = [
(0.0, 0.568_888_888_888_888_9),
(-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
(0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
(-0.906_179_845_938_664, 0.236_926_885_056_189_1),
(0.906_179_845_938_664, 0.236_926_885_056_189_1),
];
fn gauss(a: f64, b: f64, f: impl Fn(f64) -> f64) -> f64 {
let (m, h) = ((a + b) / 2.0, (b - a) / 2.0);
GAUSS.iter().map(|(x, w)| w * f(m + h * x)).sum::<f64>() * h
}
/// Clamped knots over Bézier pieces meeting at `breaks`, for degree `p`.
fn bezier_knots(breaks: &[f64], p: usize) -> Vec<f64> {
let mut k = Vec::with_capacity(breaks.len() * p + 2);
let n = breaks.len();
for (i, b) in breaks.iter().enumerate() {
let m = if i == 0 || i + 1 == n { p + 1 } else { p };
k.extend(std::iter::repeat_n(*b, m));
}
k
}
/// One degree elevation of a homogeneous Bézier piece.
fn elevate_bezier(p: &[H]) -> Vec<H> {
let n = p.len();
let zero = (Vec2::ZERO, 0.0);
(0..=n)
.map(|i| {
let prev = i.checked_sub(1).and_then(|j| p.get(j)).copied().unwrap_or(zero);
let cur = p.get(i).copied().unwrap_or(zero);
hlerp(cur, prev, i as f64 / n as f64)
})
.collect()
}
impl Spline {
fn weight(&self, i: usize) -> f64 {
if self.weights.is_empty() { 1.0 } else { self.weights.get(i).copied().unwrap_or(1.0) }
}
/// True when the spline carries weights (a NURBS rather than a plain B-spline).
pub fn is_rational(&self) -> bool {
!self.weights.is_empty()
}
fn homogeneous(&self) -> Vec<H> {
self.control
.iter()
.enumerate()
.map(|(i, c)| {
let w = self.weight(i);
(*c * w, w)
})
.collect()
}
fn from_homogeneous(degree: usize, knots: Vec<f64>, h: &[H], rational: bool) -> Spline {
Spline {
degree,
knots,
control: h.iter().map(|x| hpoint(*x)).collect(),
weights: if rational { h.iter().map(|x| x.1).collect() } else { Vec::new() },
..Spline::default()
}
}
/// First derivative dC/dt (zero on an invalid spline).
pub fn derivative(&self, t: f64) -> Vec2 {
if !self.is_valid() {
return Vec2::ZERO;
}
let p = self.degree;
let n = self.control.len() - 1;
let (lo, hi) = self.domain();
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
let span = find_span(n, p, t, &self.knots);
let nb = basis(span, t, p, &self.knots);
let nd = basis(span, t, p - 1, &self.knots);
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
for j in 0..=p {
let Some(i) = (span + j).checked_sub(p) else { continue };
let Some(c) = self.control.get(i) else { continue };
let wi = self.weight(i);
let n0 = nb.get(j).copied().unwrap_or(0.0);
// N'_{i,p} = p (N_{i,p-1} / (u_{i+p} - u_i) - N_{i+1,p-1} / (u_{i+p+1} - u_{i+1})).
let left = if j >= 1 { ratio(nd.get(j - 1), k(i + p) - k(i)) } else { 0.0 };
let right = if j < p { ratio(nd.get(j), k(i + p + 1) - k(i + 1)) } else { 0.0 };
let dn = p as f64 * (left - right);
a += *c * (n0 * wi);
w += n0 * wi;
da += *c * (dn * wi);
dw += dn * wi;
}
if w.abs() < 1e-300 {
return Vec2::ZERO;
}
let d = (da - a / w * dw) / w;
if d.is_finite() { d } else { Vec2::ZERO }
}
/// Unit tangent at `t` in the direction of increasing parameter; a central difference stands
/// in where the derivative vanishes. Zero when the curve does not move at all.
pub fn unit_tangent(&self, t: f64) -> Vec2 {
let d = self.derivative(t).normalized();
if d != Vec2::ZERO {
return d;
}
let (lo, hi) = self.domain();
let h = (hi - lo) * 1e-6;
(self.eval((t + h).min(hi)) - self.eval((t - h).max(lo))).normalized()
}
/// Insert knot `t` once (Boehm's algorithm, in homogeneous coordinates). The result has no
/// fit data and is open.
pub fn insert_knot(&self, t: f64) -> Option<Spline> {
if !self.is_valid() || !t.is_finite() || self.control.len() >= MAX_EDIT_CONTROL {
return None;
}
let p = self.degree;
let n = self.control.len();
let (lo, hi) = self.domain();
let t = t.clamp(lo, hi);
// Span k: the last knot index in p..n with knots[k] <= t.
let mut k = p;
for i in p..n {
if *self.knots.get(i)? <= t {
k = i;
}
}
let h = self.homogeneous();
let mut out = Vec::with_capacity(n + 1);
for i in 0..=n {
let pt = if i + p <= k {
*h.get(i)?
} else if i > k {
*h.get(i - 1)?
} else {
let ki = *self.knots.get(i)?;
let kip = *self.knots.get(i + p)?;
let a = if (kip - ki).abs() < 1e-300 { 0.0 } else { (t - ki) / (kip - ki) };
hlerp(*h.get(i.checked_sub(1)?)?, *h.get(i)?, a)
};
out.push(pt);
}
let mut knots = self.knots.clone();
knots.insert(k + 1, t);
Some(Spline::from_homogeneous(p, knots, &out, self.is_rational()))
}
/// Cut at `t` (inserting it to multiplicity `degree`); either side may be invalid when `t`
/// is at or beyond an end.
fn split_raw(&self, t: f64) -> Option<(Spline, Spline)> {
let same = |k: &f64| (*k - t).abs() < 1e-12;
let mut s = self.clone();
s.fit.clear();
let mut guard = 0;
while s.knots.iter().filter(|k| same(k)).count() < s.degree && guard < MAX_SPLIT_INSERTS {
s = s.insert_knot(t)?;
guard += 1;
}
let p = s.degree;
if s.knots.iter().filter(|k| same(k)).count() != p {
return None;
}
let first = s.knots.iter().position(same)?;
let mut lk = s.knots.get(..first)?.to_vec();
lk.extend(std::iter::repeat_n(t, p + 1));
let lc = s.control.get(..first)?.to_vec();
let lw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(..first)?.to_vec() };
let rs = first.checked_sub(1)?;
let mut rk = vec![t; p + 1];
rk.extend(s.knots.get(first + p..)?.iter().copied());
let rc = s.control.get(rs..)?.to_vec();
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(rs..)?.to_vec() };
Some((
Spline { degree: p, knots: lk, control: lc, weights: lw, ..Spline::default() },
Spline { degree: p, knots: rk, control: rc, weights: rw, ..Spline::default() },
))
}
/// Split at parameter `t` into (before, after). `None` at the ends or on invalid input.
pub fn split_at(&self, t: f64) -> Option<(Spline, Spline)> {
let (lo, hi) = self.domain();
if !(t > lo + 1e-12 && t < hi - 1e-12) {
return None;
}
let (l, r) = self.split_raw(t)?;
(l.is_valid() && r.is_valid()).then_some((l, r))
}
/// The open piece between parameters `a < b`.
pub fn sub_between(&self, a: f64, b: f64) -> Option<Spline> {
let (lo, hi) = self.domain();
let mut s = self.clone();
s.fit.clear();
s.closed = false;
if b < hi - 1e-12 {
s = s.split_at(b)?.0;
}
if a > lo + 1e-12 {
s = s.split_at(a)?.1;
}
Some(s)
}
/// The same curve with clamped ends (end knots repeated degree + 1 times), so it starts and
/// ends on its first and last control points. Fit data is dropped.
pub fn clamped(&self) -> Option<Spline> {
if !self.is_valid() {
return None;
}
let p = self.degree;
let (lo, hi) = self.domain();
let mut s = self.clone();
s.fit.clear();
s.fit_opts = Default::default();
if !s.knots.get(..=p).is_some_and(|k| k.iter().all(|x| (*x - lo).abs() < 1e-12)) {
s = s.split_raw(lo)?.1;
}
let n = s.control.len();
if !s.knots.get(n..).is_some_and(|k| k.iter().all(|x| (*x - hi).abs() < 1e-12)) {
s = s.split_raw(hi)?.0;
}
s.closed = self.closed;
s.is_valid().then_some(s)
}
/// Reversed direction: control points, weights, fit points and knots (and the fit end
/// tangents swap ends and turn round).
pub fn reversed(&self) -> Spline {
let (lo, hi) = (self.knots.first().copied().unwrap_or(0.0), self.knots.last().copied().unwrap_or(1.0));
Spline {
degree: self.degree,
knots: self.knots.iter().rev().map(|k| lo + hi - k).collect(),
control: self.control.iter().rev().copied().collect(),
weights: self.weights.iter().rev().copied().collect(),
fit: self.fit.iter().rev().copied().collect(),
closed: self.closed,
fit_opts: crate::FitOptions {
start_tangent: self.fit_opts.end_tangent.map(|t| -t),
end_tangent: self.fit_opts.start_tangent.map(|t| -t),
..self.fit_opts
},
}
}
/// Bézier decomposition: the homogeneous control points of each polynomial piece
/// (degree + 1 each) and the parameter breaks between them (pieces + 1 values).
fn bezier_pieces(&self) -> Option<(Vec<Vec<H>>, Vec<f64>)> {
let mut s = self.clamped()?;
let p = s.degree;
if p > MAX_EDIT_DEGREE {
return None;
}
let (lo, hi) = s.domain();
let mut interior: Vec<f64> = s.knots.iter().copied().filter(|k| *k > lo + 1e-12 && *k < hi - 1e-12).collect();
interior.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
if (interior.len() + 1).saturating_mul(p) >= MAX_DECOMPOSED {
return None;
}
for &u in &interior {
let mut guard = 0;
while s.knots.iter().filter(|k| (**k - u).abs() < 1e-12).count() < p && guard <= MAX_EDIT_DEGREE {
s = s.insert_knot(u)?;
guard += 1;
}
}
let h = s.homogeneous();
let pieces = interior.len() + 1;
// A knot of multiplicity above the degree (a break in the curve) leaves extra points.
if h.len() != pieces * p + 1 {
return None;
}
let segs = (0..pieces).map(|j| h.get(j * p..=j * p + p).map(<[H]>::to_vec)).collect::<Option<Vec<_>>>()?;
let mut breaks = vec![lo];
breaks.extend(interior);
breaks.push(hi);
Some((segs, breaks))
}
/// The same curve at a higher (or equal) degree, clamped.
pub fn elevated(&self, degree: usize) -> Option<Spline> {
if degree < self.degree || degree > MAX_EDIT_DEGREE {
return None;
}
if degree == self.degree {
return self.clamped();
}
let (pieces, breaks) = self.bezier_pieces()?;
let mut out: Vec<H> = Vec::with_capacity(pieces.len() * degree + 1);
for (j, pc) in pieces.iter().enumerate() {
let mut q = pc.clone();
while q.len() < degree + 1 {
q = elevate_bezier(&q);
}
out.extend(q.into_iter().skip(usize::from(j > 0)));
}
let mut s = Spline::from_homogeneous(degree, bezier_knots(&breaks, degree), &out, self.is_rational());
s.closed = self.closed;
s.is_valid().then_some(s)
}
/// `other` joined on at this spline's end (its start should lie on this end; the shared
/// point becomes their midpoint). Degrees are matched by elevation, weights rescaled so the
/// junction carries one weight, and `other`'s parameters follow on at a similar speed. The
/// junction is a knot of multiplicity `degree`, so the curve passes through it exactly.
pub fn joined(&self, other: &Spline) -> Option<Spline> {
let deg = self.degree.max(other.degree);
let a = self.elevated(deg)?;
let b = other.elevated(deg)?;
let (ha, hb) = (a.homogeneous(), b.homogeneous());
let (la, fb) = (*ha.last()?, *hb.first()?);
let scale = if fb.1.abs() > 1e-300 { la.1 / fb.1 } else { 1.0 };
if !(scale.is_finite() && scale > 0.0) || ha.len() + hb.len() > MAX_DECOMPOSED * 2 {
return None;
}
let ((alo, ahi), (blo, bhi)) = (a.domain(), b.domain());
let speed = (ahi - alo) / poly_len(&a.control).max(EPS);
let span = poly_len(&b.control) * speed;
let k = if span.is_finite() && span > 1e-12 && bhi - blo > 1e-300 { span / (bhi - blo) } else { 1.0 };
let mut knots: Vec<f64> = a.knots.get(..a.knots.len().checked_sub(1)?)?.to_vec();
knots.extend(b.knots.get(deg + 1..)?.iter().map(|u| ahi + (u - blo) * k));
let mid = hpoint(la).mid(hpoint(fb));
let mut h = ha;
if let Some(l) = h.last_mut() {
*l = (mid * l.1, l.1);
}
h.extend(hb.iter().skip(1).map(|x| (x.0 * scale, x.1 * scale)));
let s = Spline::from_homogeneous(deg, knots, &h, a.is_rational() || b.is_rational());
(s.is_valid() && s.control.iter().all(|c| c.is_finite())).then_some(s)
}
/// A straight line from `a` to `b` as a clamped spline of `degree` (evenly spaced control
/// points, parameter = distance).
pub fn from_line(a: Vec2, b: Vec2, degree: usize) -> Spline {
let p = degree.clamp(1, MAX_EDIT_DEGREE);
let control = (0..=p).map(|i| a.lerp(b, i as f64 / p as f64)).collect();
let len = a.dist(b);
let hi = if len.is_finite() && len > 1e-12 { len } else { 1.0 };
let mut knots = vec![0.0; p + 1];
knots.extend(std::iter::repeat_n(hi, p + 1));
Spline { degree: p, knots, control, ..Spline::default() }
}
/// The elliptical arc `center + u·cos θ + v·sin θ` for θ from `start` over `sweep` (negative
/// runs clockwise) as an exact rational quadratic spline, in pieces of at most a quarter
/// turn. Circular arcs are the case `|u| = |v|`, `u ⟂ v`.
pub fn from_conic_arc(center: Vec2, u: Vec2, v: Vec2, start: f64, sweep: f64) -> Option<Spline> {
if !(start.is_finite() && sweep.is_finite() && center.is_finite() && u.is_finite() && v.is_finite())
|| sweep.abs() < 1e-12
|| sweep.abs() > TAU + 1e-9
|| u.cross(v).abs() < 1e-300
{
return None;
}
let k = ((sweep.abs() / FRAC_PI_2) - 1e-9).ceil().clamp(1.0, 4.0) as usize;
let d = sweep / k as f64;
let at = |t: f64| center + u * t.cos() + v * t.sin();
let w = (d / 2.0).cos();
let mut control = vec![at(start)];
let mut weights = vec![1.0];
for i in 0..k {
let a0 = start + d * i as f64;
let m = a0 + d / 2.0;
control.push(center + (u * m.cos() + v * m.sin()) / w);
weights.push(w);
control.push(at(a0 + d));
weights.push(1.0);
}
let step = (d.abs() * (u.len() + v.len()) / 2.0).max(1e-9);
let breaks: Vec<f64> = (0..=k).map(|i| step * i as f64).collect();
let s = Spline { degree: 2, knots: bezier_knots(&breaks, 2), control, weights, ..Spline::default() };
s.is_valid().then_some(s)
}
/// An ellipse or elliptical arc as an exact rational spline (counter-clockwise).
pub fn from_ellipse(e: &Ellipse) -> Option<Spline> {
let s = Spline::from_conic_arc(e.center, e.major, e.minor(), e.start, e.sweep())?;
Some(Spline { closed: e.is_full(), ..s })
}
/// Run the spline on in a straight line along its end tangent by `dist` > 0: past the end
/// (`at_end`) or before the start. The result is clamped, open and has no fit data.
pub fn extended(&self, at_end: bool, dist: f64) -> Option<Spline> {
if !(dist.is_finite() && dist > 0.0) || self.closed {
return None;
}
let s = self.clamped()?;
let (lo, hi) = s.domain();
let t = if at_end { hi } else { lo };
let e = s.eval(t);
let dir = s.unit_tangent(t) * if at_end { 1.0 } else { -1.0 };
if dir == Vec2::ZERO {
return None;
}
let far = e + dir * dist;
if at_end { s.joined(&Spline::from_line(e, far, s.degree)) } else { Spline::from_line(far, e, s.degree).joined(&s) }
}
/// Integration intervals for arc length between `a < b`: each knot span cut in four.
fn length_intervals(&self, a: f64, b: f64) -> Vec<(f64, f64)> {
let mut cuts = vec![a];
cuts.extend(self.knots.iter().copied().filter(|k| *k > a && *k < b));
cuts.push(b);
cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-15);
let mut out = Vec::with_capacity(cuts.len() * 4);
for w in cuts.windows(2) {
let (Some(&x0), Some(&x1)) = (w.first(), w.get(1)) else { continue };
for i in 0..4 {
out.push((x0 + (x1 - x0) * i as f64 / 4.0, x0 + (x1 - x0) * (i + 1) as f64 / 4.0));
}
}
out
}
/// Arc length between parameters `a` and `b` (Gauss–Legendre over each knot span).
pub fn length_between(&self, a: f64, b: f64) -> f64 {
if !self.is_valid() || !(a.is_finite() && b.is_finite()) {
return 0.0;
}
let (lo, hi) = self.domain();
let (a, b) = (a.min(b).clamp(lo, hi), a.max(b).clamp(lo, hi));
let l: f64 = self.length_intervals(a, b).into_iter().map(|(x, y)| gauss(x, y, |t| self.derivative(t).len())).sum();
if l.is_finite() { l } else { 0.0 }
}
/// Arc length of the whole spline.
pub fn length(&self) -> f64 {
let (lo, hi) = self.domain();
self.length_between(lo, hi)
}
/// The parameter `len` along the curve from parameter `from` (forward for positive `len`,
/// backward for negative), clamped to the domain.
pub fn param_at_length(&self, from: f64, len: f64) -> f64 {
let (lo, hi) = self.domain();
let from = if from.is_finite() { from.clamp(lo, hi) } else { lo };
if !self.is_valid() || !len.is_finite() || len == 0.0 {
return from;
}
let speed = |t: f64| self.derivative(t).len();
let (a, b, target) = if len > 0.0 { (from, hi, len) } else { (lo, from, -len) };
let mut ivs = self.length_intervals(a, b);
if len < 0.0 {
ivs.reverse();
}
let mut acc = 0.0;
for (x0, x1) in ivs {
let l = gauss(x0, x1, speed);
if acc + l >= target && l > 0.0 {
// Bisect inside this interval, measuring from its near end.
let rest = target - acc;
let (near, far) = if len > 0.0 { (x0, x1) } else { (x1, x0) };
let (mut s0, mut s1) = (near, far);
for _ in 0..60 {
let m = (s0 + s1) / 2.0;
let lm = gauss(near.min(m), near.max(m), speed);
if lm < rest {
s0 = m;
} else {
s1 = m;
}
}
return (s0 + s1) / 2.0;
}
acc += l;
}
if len > 0.0 { hi } else { lo }
}
}
impl Ellipse {
/// dP/dθ at parameter `t`.
pub fn derivative(&self, t: f64) -> Vec2 {
self.minor() * t.cos() - self.major * t.sin()
}
/// Arc length from parameter `t0` counter-clockwise over `sweep` (radians, >= 0).
pub fn arc_length_from(&self, t0: f64, sweep: f64) -> f64 {
if !(t0.is_finite() && sweep.is_finite()) || sweep <= 0.0 {
return 0.0;
}
let n = ((sweep / TAU) * 128.0).ceil().clamp(1.0, 512.0) as usize;
let h = sweep / n as f64;
let l: f64 = (0..n).map(|i| gauss(t0 + h * i as f64, t0 + h * (i + 1) as f64, |t| self.derivative(t).len())).sum();
if l.is_finite() { l } else { 0.0 }
}
/// Arc length of the ellipse or elliptical arc.
pub fn length(&self) -> f64 {
self.arc_length_from(self.start, self.sweep())
}
/// The parameter reached `len` along the ellipse from `t0`: counter-clockwise for positive
/// `len`, clockwise for negative. `None` for a whole turn or more, or degenerate input.
pub fn param_at_length(&self, t0: f64, len: f64) -> Option<f64> {
let full = self.arc_length_from(0.0, TAU);
if !(t0.is_finite() && len.is_finite() && full > EPS) || len.abs() >= full {
return None;
}
let target = len.abs();
let along = |s: f64| if len >= 0.0 { self.arc_length_from(t0, s) } else { self.arc_length_from(t0 - s, s) };
let (mut a, mut b) = (0.0, TAU);
for _ in 0..80 {
let m = (a + b) / 2.0;
if along(m) < target {
a = m;
} else {
b = m;
}
}
let s = (a + b) / 2.0;
Some(if len >= 0.0 { t0 + s } else { t0 - s })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cubic() -> Spline {
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0), Vec2::new(12.0, 2.0)], 3)
}
#[test]
fn derivative_matches_finite_differences() {
let mut s = cubic();
s.weights = vec![1.0, 2.0, 0.5, 1.0, 1.5];
let (lo, hi) = s.domain();
for i in 1..20 {
let t = lo + (hi - lo) * i as f64 / 20.0;
let h = 1e-6;
let fd = (s.eval(t + h) - s.eval(t - h)) / (2.0 * h);
assert!(s.derivative(t).near(fd, 1e-4), "{t}: {:?} vs {fd:?}", s.derivative(t));
}
// Clamped end: the derivative points along the last control leg.
assert!(s.unit_tangent(hi).near((Vec2::new(12.0, 2.0) - Vec2::new(9.0, 0.0)).normalized(), 1e-9));
}
#[test]
fn split_and_sub_keep_the_curve() {
let s = cubic();
let (l, r) = s.split_at(0.4).unwrap();
assert!(l.eval(l.domain().1).near(s.eval(0.4), 1e-12));
assert!(r.eval(r.domain().0).near(s.eval(0.4), 1e-12));
assert!(r.eval(0.7).near(s.eval(0.7), 1e-12));
let m = s.sub_between(0.2, 0.6).unwrap();
assert!(m.eval(m.domain().0).near(s.eval(0.2), 1e-12) && m.eval(m.domain().1).near(s.eval(0.6), 1e-12));
assert!(s.split_at(0.0).is_none() && s.split_at(f64::NAN).is_none());
}
#[test]
fn periodic_spline_clamps_to_the_same_curve() {
let fit = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 1.0), Vec2::new(5.0, 4.0), Vec2::new(1.0, 5.0)];
let p = Spline::from_fit(&fit, true);
assert!(p.is_periodic());
let c = p.clamped().unwrap();
assert!(!c.is_periodic());
let ((plo, phi), (clo, chi)) = (p.domain(), c.domain());
for i in 0..=20 {
let f = i as f64 / 20.0;
assert!(p.eval(plo + (phi - plo) * f).near(c.eval(clo + (chi - clo) * f), 1e-9));
}
assert!(c.control.first().unwrap().near(p.eval(plo), 1e-9));
}
#[test]
fn elevation_keeps_the_curve() {
let s = cubic();
let e = s.elevated(5).unwrap();
assert_eq!(e.degree, 5);
for i in 0..=20 {
let t = i as f64 / 20.0;
assert!(e.eval(t).near(s.eval(t), 1e-9), "{t}");
}
// A rational quarter circle elevated to a cubic stays on the circle.
let q = Spline::from_conic_arc(Vec2::ZERO, Vec2::X * 2.0, Vec2::Y * 2.0, 0.0, 3.0).unwrap();
let q3 = q.elevated(3).unwrap();
let (lo, hi) = q3.domain();
for i in 0..=20 {
assert!((q3.eval(lo + (hi - lo) * i as f64 / 20.0).len() - 2.0).abs() < 1e-9);
}
assert!(s.elevated(2).is_none() && s.elevated(1000).is_none());
}
#[test]
fn conic_arcs_are_exact() {
let e = Ellipse { center: Vec2::new(1.0, 2.0), major: Vec2::new(4.0, 3.0), ratio: 0.5, start: 0.3, end: 5.5 };
let s = Spline::from_ellipse(&e).unwrap();
let (lo, hi) = s.domain();
assert!(s.eval(lo).near(e.at_param(e.start), 1e-9) && s.eval(hi).near(e.at_param(e.end), 1e-9));
// Every point satisfies the ellipse equation.
let (a, b) = (e.major.len(), e.major.len() * e.ratio);
let ux = e.major.normalized();
for i in 0..=40 {
let p = s.eval(lo + (hi - lo) * i as f64 / 40.0) - e.center;
let (x, y) = (p.dot(ux), p.dot(ux.perp()));
assert!(((x / a).powi(2) + (y / b).powi(2) - 1.0).abs() < 1e-9);
}
// Clockwise arcs run the other way.
let cw = Spline::from_conic_arc(Vec2::ZERO, Vec2::X, Vec2::Y, 0.0, -1.0).unwrap();
assert!(cw.eval(cw.domain().1).near(Vec2::new(1f64.cos(), -(1f64.sin())), 1e-12));
assert!(Spline::from_conic_arc(Vec2::ZERO, Vec2::X, Vec2::X, 0.0, 1.0).is_none());
assert!(Spline::from_conic_arc(Vec2::ZERO, Vec2::X, Vec2::Y, 0.0, f64::NAN).is_none());
}
#[test]
fn join_line_arc_and_spline() {
let line = Spline::from_line(Vec2::new(-5.0, 0.0), Vec2::ZERO, 1);
let s = cubic();
let j = line.joined(&s).unwrap();
assert_eq!(j.degree, 3);
let (lo, hi) = j.domain();
assert!(j.eval(lo).near(Vec2::new(-5.0, 0.0), 1e-12) && j.eval(hi).near(Vec2::new(12.0, 2.0), 1e-12));
// The line part is straight (parameter = distance), the spline part unchanged.
for i in 0..=10 {
assert!(j.eval(lo + 0.5 * i as f64).near(Vec2::new(-5.0 + 0.5 * i as f64, 0.0), 1e-12));
let f = i as f64 / 10.0;
assert!(j.eval(5.0 + (hi - 5.0) * f).near(s.eval(f), 1e-9));
}
assert!((j.length() - (5.0 + s.length())).abs() < 1e-6);
// A rational arc joined to a non-rational line.
let arc = Spline::from_conic_arc(Vec2::new(12.0, 5.0), Vec2::new(0.0, -3.0), Vec2::new(3.0, 0.0), 0.0, 1.0).unwrap();
let k = j.joined(&arc).unwrap();
assert!(k.is_rational());
assert!(k.eval(k.domain().1).near(arc.eval(arc.domain().1), 1e-9));
}
#[test]
fn extension_runs_on_along_the_tangent() {
let s = cubic();
let (lo, hi) = s.domain();
let t_end = s.unit_tangent(hi);
let e = s.extended(true, 3.0).unwrap();
assert!(e.eval(e.domain().1).near(s.eval(hi) + t_end * 3.0, 1e-9));
assert!((e.length() - s.length() - 3.0).abs() < 1e-6);
let b = s.extended(false, 2.0).unwrap();
assert!(b.eval(b.domain().0).near(s.eval(lo) - s.unit_tangent(lo) * 2.0, 1e-9));
assert!(s.extended(true, -1.0).is_none() && s.extended(true, f64::NAN).is_none());
}
#[test]
fn spline_arc_length_and_inverse() {
let l = Spline::from_line(Vec2::ZERO, Vec2::new(3.0, 4.0), 3);
assert!((l.length() - 5.0).abs() < 1e-12);
let q = Spline::from_conic_arc(Vec2::ZERO, Vec2::X * 2.0, Vec2::Y * 2.0, 0.0, std::f64::consts::PI).unwrap();
assert!((q.length() - 2.0 * std::f64::consts::PI).abs() < 1e-6);
let (lo, hi) = q.domain();
let t = q.param_at_length(lo, std::f64::consts::PI);
assert!(q.eval(t).near(Vec2::new(0.0, 2.0), 1e-6));
let t = q.param_at_length(hi, -std::f64::consts::PI / 2.0);
assert!(q.eval(t).near(Vec2::new(-2f64.sqrt(), 2f64.sqrt()), 1e-6));
assert_eq!(q.param_at_length(lo, 1e9), hi);
}
#[test]
fn ellipse_arc_length() {
let c = Ellipse::full(Vec2::ZERO, Vec2::new(3.0, 0.0), 1.0);
assert!((c.length() - TAU * 3.0).abs() < 1e-9);
let e = Ellipse::full(Vec2::ZERO, Vec2::new(5.0, 0.0), 0.6);
// Ramanujan's approximation is good to ~1e-5 relative here.
let (a, b) = (5.0f64, 3.0f64);
let h = ((a - b) / (a + b)).powi(2);
let ram = std::f64::consts::PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (4.0 - 3.0 * h).sqrt()));
assert!((e.length() - ram).abs() < 1e-4);
let t = e.param_at_length(0.0, e.length() / 4.0).unwrap();
assert!((t - FRAC_PI_2).abs() < 1e-9);
let t = e.param_at_length(0.0, -e.length() / 4.0).unwrap();
assert!((t + FRAC_PI_2).abs() < 1e-9);
assert!(e.param_at_length(0.0, e.length() * 2.0).is_none());
assert!(e.param_at_length(f64::NAN, 1.0).is_none());
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ do them first.
| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 |
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 |
| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 |
| F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 |
| F6 | FILLET doesn't handle ellipses and splines; FILLET/CHAMFER of a polyline segment with another object or a second segment of the same polyline (TRIM/EXTEND/JOIN/LENGTHEN on ellipses and splines done, #466; FILLET Trim/Multiple and CHAMFER Angle/mEthod/Polyline done, #248) | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--55) | Medium | 5–8 |
| F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 |
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
| F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 |
+10 -9
View File
@@ -1,6 +1,6 @@
# Geometry parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first audit of the geometry kernel's precision against AutoCAD) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; a closed spline trims to one piece, #466) · **Target:** Autodesk AutoCAD 2027
CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right
to the last displayed digit or the tool is unusable for production drawings. This document
@@ -76,17 +76,17 @@ constraints 10%.
| Polyline with arcs | exact, with self-intersection trimming, OFFSETGAPTYPE (extend, fillet, chamfer) | segments offset and joined at neighbours' intersections; **arc bulges not recomputed** after endpoints move (comment at L413), **no loop cleanup**, collapsed segments not removed; OFFSETGAPTYPE only echoed |
| Options: Through, Erase, Layer, Multiple | yes | see [gaps.md](gaps.md) |
## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 45%)
## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 55%)
| Command | AutoCAD supports | CADCraft supports |
|---|---|---|
| TRIM | every curve type, quick and standard modes | line, arc, circle, polyline; ellipse and spline via sampled cutting (`modify.rs:485-656`, `modify2.rs:1996`) |
| EXTEND | every open curve | line, arc, polyline, ellipse; **no spline** |
| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode; **no ellipse or spline** (`modify2.rs:1804`) |
| CHAMFER | lines, polylines, xlines, rays | lines (`modify.rs:756`); polyline chamfer not confirmed |
| TRIM | every curve type, quick and standard modes | line, arc, circle, polyline; ellipse and spline via sampled cutting (`modify.rs:485-656`, `modify2.rs` `trim_other`); a closed spline leaves one open spline |
| EXTEND | every open curve | line, arc, polyline, elliptical arc, open spline (runs on along its end tangent, `modify2/curve_edits.rs`) |
| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode, Trim/No trim, Multiple; **no ellipse or spline**, no polyline segment with another object (`modify2.rs` `fillet_curves`) |
| CHAMFER | lines, polylines, xlines, rays | lines, Distance/Angle, whole polylines (Polyline option); no polyline segment with another object |
| BREAK | every curve | line, arc, circle, polyline |
| JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline |
| LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline |
| JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline into a polyline; elliptical arcs on one ellipse into a larger arc or the full ellipse (gaps close); splines with lines, arcs, polylines and elliptical arcs into one exact NURBS (degree elevation, rational arcs; `geom/src/nurbs.rs`); no helixes |
| LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline; elliptical arc (arc length along the ellipse); open spline (cut along the curve, or run on along its end tangent) |
Where line and arc are supported the results are exact.
@@ -138,7 +138,7 @@ exist for parsing, not for numerics.
spline/ellipse offset as fitted splines within a tolerance. 15–25 h.
3. A tolerance context relative to the drawing's extents, and a known-answer test suite at
origin and at 1e6–1e7 coordinates. 8–12 h.
4. FILLET/JOIN/LENGTHEN/EXTEND for ellipses and splines; CHAMFER on polylines. 10–15 h.
4. FILLET for ellipses and splines; FILLET/CHAMFER between a polyline segment and another object. 5–8 h.
5. Exact areas and MASSPROP for bulge polylines, regions (2D) and hatch loops. 6–10 h.
6. Hatch gap tolerance and curved boundaries. 8–12 h.
7. Solver: DOF reporting, G2 Smooth, ellipse/spline geometry, constraint inference. 20–30 h.
@@ -151,3 +151,4 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | First geometry-precision audit: tolerances, curves, intersections, offsets, modify commands, measurement, hatch, solver |
| 2026-10-11 | minor | EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; closed splines trim to one piece; trim/extend section 45% → 55% (#466) |