SPLINE: Knots, start/end Tangency, toLerance and Object (part of #408) (#413)

SPLINE's Knots, Object, start/end Tangency and toLerance options printed
"not available yet". Fit-point splines now take a knot parametrisation
(chord, square root, uniform), end tangents (clamped cubic with derivative
constraints) and a fit tolerance (least-squares approximation with the
fewest control points that stay within it). The spline keeps these options
so grip and SPLINEDIT edits refit the same way; DXF writes the tolerance
(44) and tangents (12/13) and recovers the parametrisation from the knots.
Object turns 2D/3D polylines with straight segments (PEDIT Spline output)
into splines through their vertices.

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-10 19:19:57 -04:00
1 parent 3ffe1ca2af
commit a395ec3d2d
15 files changed
+1173 -147

No files matched your search

+1 -5
View File
@@ -904,11 +904,7 @@ impl EntityKind {
p.const_width *= s;
}
EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(t3),
EntityKind::Spline(sp) => {
for c in sp.control.iter_mut().chain(sp.fit.iter_mut()) {
*c = m.apply(*c);
}
}
EntityKind::Spline(sp) => sp.transform(m),
EntityKind::Ray(r) | EntityKind::XLine(r) => {
t3(&mut r.base);
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
+9 -2
View File
@@ -582,6 +582,7 @@ fn insert_knot(sp: &Spline, t: f64) -> Option<Spline> {
weights: if rational { ctrl.iter().map(|(_, w)| *w).collect() } else { Vec::new() },
fit: Vec::new(),
closed: false,
fit_opts: Default::default(),
})
}
@@ -613,8 +614,8 @@ pub fn split_spline(sp: &Spline, t: f64) -> Option<(Spline, Spline)> {
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, fit: Vec::new(), closed: false };
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, fit: Vec::new(), closed: false };
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))
}
@@ -669,6 +670,12 @@ pub fn reverse_spline(sp: &Spline) -> Spline {
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
},
}
}
+55 -9
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("spline", "Spline", run_spline)
.menu(&["Draw", "Spline", "Fit Points"])
.alias(&["spl"])
.params("{fit: [[x,y],...]} | {control: [...], degree?}")
.params("{fit: [[x,y],...], closed?, knots?: chord|sqrt|uniform, startTangent?, endTangent? ([dx,dy]), tolerance?} | {control: [...], degree?} | {object: handle|true, handles?} (polyline to spline)")
.interactive(|_| Ok(Box::new(SplineM::default()))),
CommandSpec::new("donut", "Donut", run_donut)
.menu(&["Draw", "Donut"])
@@ -255,11 +255,14 @@ fn run_ray(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("object").is_some() {
return super::spline_opts::run_object(s, p);
}
let sp = if let Some(f) = points_param(p, "fit") {
if distinct_points(&f).len() < 2 {
return Err(bad("spline", "need 2+ different fit points"));
}
Spline::from_fit_points(&f)
Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?)
} else if let Some(c) = points_param(p, "control") {
if c.len() < 2 {
return Err(bad("spline", "need 2+ control points"));
@@ -1190,6 +1193,16 @@ struct SplineM {
asking_method: bool,
/// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`).
delegate: Option<Box<dyn Interactive>>,
/// Knots, end tangents and fit tolerance chosen so far.
opts: cadcraft_geom::FitOptions,
/// An option asking a further question (Knots, Tangency, toLerance, Object).
ask: Option<super::spline_opts::SplineAsk>,
}
impl SplineM {
fn make(&self, closed: bool) -> Spline {
Spline::fit_with(&self.pts, closed, self.opts)
}
}
impl Interactive for SplineM {
@@ -1197,13 +1210,17 @@ impl Interactive for SplineM {
"SPLINE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo("Current settings: Method=Fit Knots=Chord");
self.opts.knots = super::spline_opts::current_knots(s);
s.echo(format!("Current settings: Method=Fit Knots={}", self.opts.knots.name()));
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
if let Some(d) = &self.delegate {
return d.prompt(s);
}
if let Some(a) = &self.ask {
return a.prompt(&self.opts, &self.pts);
}
if self.asking_method {
return Prompt::new("Enter spline creation method", super::curves::KW).kw(&["Fit", "CV"]).default("Fit");
}
@@ -1220,6 +1237,24 @@ impl Interactive for SplineM {
if let Some(d) = &mut self.delegate {
return d.input(s, i);
}
if let Some(a) = &mut self.ask {
use super::spline_opts::Outcome;
return match a.input(s, i, &mut self.opts, &self.pts)? {
Outcome::Stay => Ok(Step::Continue),
Outcome::Back => {
self.ask = None;
Ok(Step::Continue)
}
Outcome::Finish => {
self.ask = None;
if self.pts.len() >= 2 {
s.add_entity(EntityKind::Spline(self.make(false)))?;
}
Ok(Step::Done)
}
Outcome::Done => Ok(Step::Done),
};
}
if self.asking_method {
match i {
Input::Keyword(k) if k == "CV" => {
@@ -1249,7 +1284,7 @@ impl Interactive for SplineM {
}
Input::Keyword(k) if k == "Close" => {
// A smooth closed (periodic) curve, not a repeat of the first point.
s.add_entity(EntityKind::Spline(Spline::from_fit_points_closed(&self.pts)))?;
s.add_entity(EntityKind::Spline(self.make(true)))?;
Ok(Step::Done)
}
Input::Keyword(k) if k == "Method" => {
@@ -1257,13 +1292,14 @@ impl Interactive for SplineM {
Ok(Step::Continue)
}
Input::Keyword(k) => {
// Knots, Object, start/end Tangency, toLerance: stay at this prompt.
s.echo(format!("{k}: not available yet"));
// Knots, Object, start/end Tangency, toLerance ask their own question.
self.ask =
Some(super::spline_opts::SplineAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?);
Ok(Step::Continue)
}
Input::Enter => {
if self.pts.len() >= 2 {
s.add_entity(EntityKind::Spline(Spline::from_fit_points(&self.pts)))?;
s.add_entity(EntityKind::Spline(self.make(false)))?;
}
Ok(Step::Done)
}
@@ -1278,9 +1314,19 @@ impl Interactive for SplineM {
if self.pts.is_empty() {
return Vec::new();
}
let mut opts = self.opts;
let mut pts = self.pts.clone();
pts.push(c);
vec![EntityKind::Spline(Spline::from_fit_points(&pts))]
match self.ask {
// Rubber-band the tangent being picked.
Some(super::spline_opts::SplineAsk::StartTangent) => opts.start_tangent = self.pts.first().map(|f| c - *f).filter(|d| d.len() > 1e-12),
Some(super::spline_opts::SplineAsk::EndTangent) => opts.end_tangent = self.pts.last().map(|l| c - *l).filter(|d| d.len() > 1e-12),
Some(_) => {}
None => pts.push(c),
}
if pts.len() < 2 {
return Vec::new();
}
vec![EntityKind::Spline(Spline::fit_with(&pts, false, opts))]
}
}
+1
View File
@@ -23,6 +23,7 @@ mod pline_opts;
mod props;
mod qselect;
mod settings;
mod spline_opts;
mod table;
mod trimextend;
mod utility;
+5 -5
View File
@@ -649,7 +649,7 @@ fn spline_close(sp: &Spline) -> Spline {
return sp.clone();
}
let mut out = if sp.fit.len() >= 2 {
Spline::from_fit_points_closed(&sp.fit)
sp.refit(&sp.fit, true)
} else {
let mut c = sp.control.clone();
c.extend(sp.control.first().copied());
@@ -670,7 +670,7 @@ fn spline_open(sp: &Spline) -> Spline {
}
v
};
if sp.fit.len() >= 2 { Spline::from_fit_points(&trim(&sp.fit)) } else { Spline::from_control(trim(&sp.control), sp.degree) }
if sp.fit.len() >= 2 { sp.refit(&trim(&sp.fit), false) } else { Spline::from_control(trim(&sp.control), sp.degree) }
}
pub(crate) fn spline_to_poly(sp: &Spline, precision: usize) -> LwPolyline {
@@ -696,13 +696,13 @@ fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
"close" => EntityKind::Spline(spline_close(sp)),
"open" => EntityKind::Spline(spline_open(sp)),
"reverse" => EntityKind::Spline(curves::reverse_spline(sp)),
"purge" => EntityKind::Spline(Spline { fit: Vec::new(), ..sp.clone() }),
"purge" => EntityKind::Spline(Spline { fit: Vec::new(), fit_opts: Default::default(), ..sp.clone() }),
"refit" => {
let fit = points_param(p, "fit").unwrap_or_else(|| sp.fit.clone());
if fit.len() < 2 || fit.len() > MAX_GEN {
return Err(other("The spline has no fit data; give `fit` points."));
}
EntityKind::Spline(Spline::from_fit(&fit, sp.closed))
EntityKind::Spline(sp.refit(&fit, sp.closed))
}
"polyline" => EntityKind::LwPolyline(spline_to_poly(sp, p.get("precision").and_then(Value::as_u64).unwrap_or(10) as usize)),
"move" => {
@@ -712,7 +712,7 @@ fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
if !n.fit.is_empty() {
let f = n.fit.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?;
*f = to;
n = Spline::from_fit(&n.fit, n.closed);
n = n.refit(&n.fit, n.closed);
} else {
let c = n.control.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?;
*c = to;
+237
View File
@@ -0,0 +1,237 @@
//! SPLINE's fit options: Knots, start/end Tangency, toLerance and Object (turn a polyline into a
//! spline), for the prompt machine in `draw.rs` and the JSON form.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{FitOptions, KnotParam, Spline, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, number};
use super::{bad, curves, distinct_points, f64_or, point_param, targets};
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
fn other(m: impl Into<String>) -> EngineError {
EngineError::Other(m.into())
}
/// The knot parametrisation SPLINE starts with (SPLKNOTS: 0 chord, 1 square root, 2 uniform).
pub(super) fn current_knots(s: &Session) -> KnotParam {
s.doc().ok().and_then(|d| KnotParam::parse(&d.header.i64("SPLKNOTS", 0).to_string())).unwrap_or_default()
}
/// A question an option asks before SPLINE goes back to its point prompt.
pub(super) enum SplineAsk {
Knots,
StartTangent,
EndTangent,
Tolerance,
Object(SelectPhase),
}
/// What the machine does after an answer.
pub(super) enum Outcome {
/// Ask again (an invalid answer was reported, or the selection goes on).
Stay,
/// Back to the point prompt.
Back,
/// Make the spline from the points picked so far and end (after the end tangent).
Finish,
/// The command is over (Object converted its selection, or nothing was selected).
Done,
}
impl SplineAsk {
/// The option for a keyword at the point prompt, `None` for keywords handled elsewhere.
pub(super) fn from_keyword(k: &str) -> Option<SplineAsk> {
Some(match k {
"Knots" => SplineAsk::Knots,
"start Tangency" => SplineAsk::StartTangent,
"end Tangency" => SplineAsk::EndTangent,
"toLerance" => SplineAsk::Tolerance,
"Object" => SplineAsk::Object(SelectPhase::default()),
_ => return None,
})
}
pub(super) fn prompt(&self, opts: &FitOptions, pts: &[Vec2]) -> Prompt {
match self {
SplineAsk::Knots => {
Prompt::new("Enter knot parameterization", curves::KW).kw(&["Chord", "Square root", "Uniform"]).default(opts.knots.name())
}
SplineAsk::StartTangent => Prompt::new("Specify start tangent", Accept::POINT_OR_NUMBER).base_opt(pts.first().copied()),
SplineAsk::EndTangent => Prompt::new("Specify end tangent", Accept::POINT_OR_NUMBER).base_opt(pts.last().copied()),
SplineAsk::Tolerance => Prompt::new("Specify a fit tolerance", Accept::NUMBER).default(format!("{:.4}", opts.tolerance)),
SplineAsk::Object(sel) => {
let p = sel.prompt();
if sel.removing { p } else { Prompt { message: "Select spline-fit polylines to convert".into(), ..p } }
}
}
}
pub(super) fn input(&mut self, s: &mut Session, i: Input, opts: &mut FitOptions, pts: &[Vec2]) -> Result<Outcome> {
match self {
SplineAsk::Knots => {
let k = match &i {
Input::Keyword(k) | Input::Text(k) => KnotParam::parse(k).ok_or_else(|| other("Invalid option keyword."))?,
Input::Enter => opts.knots,
_ => return Ok(Outcome::Stay),
};
opts.knots = k;
s.doc_mut()?.header.set_i64("SPLKNOTS", k.code());
Ok(Outcome::Back)
}
SplineAsk::StartTangent | SplineAsk::EndTangent => {
let start = matches!(self, SplineAsk::StartTangent);
let from = if start { pts.first() } else { pts.last() }.copied().unwrap_or_default();
let dir = match i {
Input::Point(p) => {
let d = p - from;
if d.len() < 1e-12 {
return Err(other("The tangent point must differ from the fit point."));
}
Some(d)
}
// A typed direction (angle), measured as at any angle prompt.
Input::Text(t) => Some(Vec2::from_angle(s.angle_settings().direction(&t).ok_or_else(|| other("Requires a point or an angle."))?)),
// Enter: no tangent, the end follows the fit points.
Input::Enter => None,
_ => return Ok(Outcome::Stay),
};
if start {
opts.start_tangent = dir.map(Vec2::normalized);
Ok(Outcome::Back)
} else {
opts.end_tangent = dir.map(Vec2::normalized);
Ok(Outcome::Finish)
}
}
SplineAsk::Tolerance => {
let t = match i {
Input::Text(t) => number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires a non-negative number."))?,
Input::Enter => opts.tolerance,
_ => return Ok(Outcome::Stay),
};
opts.tolerance = t;
Ok(Outcome::Back)
}
SplineAsk::Object(sel) => match sel.feed(s, &i)? {
SelOutcome::More => Ok(Outcome::Stay),
SelOutcome::Empty => Ok(Outcome::Done),
SelOutcome::Done(hs) => {
let n = convert_objects(s, &hs, *opts)?;
s.echo(format!("{n} object(s) converted to splines"));
s.set_selection(Vec::new());
Ok(Outcome::Done)
}
},
}
}
}
/// The spline for a polyline's vertices, or why it can't be converted.
///
/// Our polylines keep no spline frame: PEDIT Spline replaces the vertices with points on the
/// curve. Those points become the fit points, so the spline follows the smoothed shape; a
/// closed polyline gives a closed (periodic) spline. Arc segments (PEDIT Fit, or drawn arcs) are
/// refused: fit points would lose the arcs.
pub(super) fn polyline_spline(k: &EntityKind, opts: FitOptions) -> std::result::Result<Spline, &'static str> {
let (pts, closed): (Vec<Vec2>, bool) = match k {
EntityKind::LwPolyline(pl) => {
if pl.vertices.iter().any(|v| v.bulge.abs() > 1e-12) {
return Err("it has arc segments");
}
(pl.vertices.iter().map(|v| v.p).collect(), pl.closed)
}
EntityKind::Polyline3d(pl) => {
let z0 = pl.points.first().map(|p| p.z).unwrap_or(0.0);
if pl.points.iter().any(|p| (p.z - z0).abs() > 1e-9) {
return Err("it is not planar");
}
(pl.points.iter().map(|p| p.xy()).collect(), pl.closed)
}
_ => return Err("it is not a polyline"),
};
let mut pts = distinct_points(&pts);
if closed && pts.len() > 2 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
pts.pop();
}
if pts.len() < 2 || pts.len() > curves::MAX_GEN {
return Err("it has too few or too many vertices");
}
if closed && pts.len() < 3 {
return Err("it has too few vertices to close");
}
Ok(Spline::fit_with(&pts, closed, opts))
}
/// Replace each convertible polyline in `hs` by its spline (same handle, layer and properties);
/// reports the others. Returns how many were converted.
pub(super) fn convert_objects(s: &mut Session, hs: &[Handle], opts: FitOptions) -> Result<usize> {
let mut n = 0;
for h in hs {
let e = curves::entity(s, *h)?;
if curves::is_locked(s, *h) {
s.echo("1 object was on a locked layer.");
continue;
}
match polyline_spline(&e.kind, opts) {
Ok(sp) => {
s.doc_mut()?.modify_entity(*h, |e| e.kind = EntityKind::Spline(sp))?;
n += 1;
}
Err(why) => s.echo(format!("Object not converted: {why}.")),
}
}
Ok(n)
}
/// Fit options from the JSON form: `knots` ("chord" | "sqrt" | "uniform"), `startTangent` and
/// `endTangent` (directions [dx, dy]), `tolerance` (≥ 0).
pub(super) fn json_options(s: &Session, p: &Value) -> Result<FitOptions> {
let knots = match p.get("knots") {
None | Some(Value::Null) => current_knots(s),
Some(v) => v
.as_str()
.map(str::to_string)
.or_else(|| v.as_u64().map(|n| n.to_string()))
.and_then(|t| KnotParam::parse(&t))
.ok_or_else(|| bad("spline", "`knots` must be \"chord\", \"sqrt\" or \"uniform\""))?,
};
let tangent = |key: &str| -> Result<Option<Vec2>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => point_param(p, key)
.filter(|d| d.len() > 1e-12)
.map(|d| Some(d.normalized()))
.ok_or_else(|| bad("spline", format!("`{key}` must be a non-zero direction [dx, dy]"))),
}
};
let tolerance = f64_or(p, "tolerance", 0.0);
if tolerance < 0.0 {
return Err(bad("spline", "`tolerance` must be zero or positive"));
}
Ok(FitOptions { knots, start_tangent: tangent("startTangent")?, end_tangent: tangent("endTangent")?, tolerance })
}
/// `{object: true, handles?}`: convert polylines (or the selection) into splines.
pub(super) fn run_object(s: &mut Session, p: &Value) -> Result<Value> {
let hs = match p.get("object") {
Some(Value::Bool(true)) => targets(s, p)?,
Some(v) => vec![curves::handle_value(v).ok_or_else(|| bad("spline", "`object` must be a handle or true"))?],
None => return Err(bad("spline", "`object` is required")),
};
if hs.is_empty() {
return Err(bad("spline", "no polyline to convert: give `object` a handle or select polylines"));
}
let opts = json_options(s, p)?;
let mut converted = Vec::new();
for h in &hs {
let e = curves::entity(s, *h)?;
if curves::is_locked(s, *h) {
return Err(other("The object is on a locked layer."));
}
let sp = polyline_spline(&e.kind, opts).map_err(|why| other(format!("Object {} not converted: {why}.", h.hex())))?;
s.doc_mut()?.modify_entity(*h, |e| e.kind = EntityKind::Spline(sp))?;
converted.push(h.hex());
}
Ok(json!({ "handles": converted }))
}
+2 -2
View File
@@ -5,7 +5,7 @@
//! undoable and reachable from scripts, the control channel and MCP.
use cadcraft_doc::{DimKind, EntityKind, Handle, LwPolyline};
use cadcraft_geom::{Arc, EPS, Mat3, Polyline, Spline, Vec2, Vec3, norm_angle};
use cadcraft_geom::{Arc, EPS, Mat3, Polyline, Vec2, Vec3, norm_angle};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -181,7 +181,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
} else {
let mut fit = sp.fit.clone();
*fit.get_mut(index)? = to;
*sp = Spline::from_fit(&fit, sp.closed);
*sp = sp.refit(&fit, sp.closed);
}
}
EntityKind::Ray(r) | EntityKind::XLine(r) => {
+2 -5
View File
@@ -29,12 +29,9 @@ fn spline_close_makes_a_smooth_periodic_curve() {
}
#[test]
fn spline_options_report_and_stay_at_the_prompt() {
fn spline_rejects_text_and_switches_method() {
let mut s = Session::new();
s.cmdline("spline k o 0,0 t 5,5 l 10,0 xyz").unwrap();
for k in ["Knots: not available yet", "Object: not available yet", "start Tangency: not available yet", "toLerance: not available yet"] {
assert!(logged(&s, k), "{k}");
}
s.cmdline("spline 0,0 5,5 10,0 xyz").unwrap();
assert!(logged(&s, "Point or option keyword required."));
s.cmdline("").unwrap();
let EntityKind::Spline(sp) = last(&s) else { panic!("no spline") };
+173
View File
@@ -0,0 +1,173 @@
//! SPLINE's Knots, start/end Tangency, toLerance and Object options, at the command line and in
//! the JSON form (#408).
use cadcraft_doc::EntityKind;
use cadcraft_engine::Session;
use cadcraft_geom::{KnotParam, Spline, Vec2};
use serde_json::json;
fn last(s: &Session) -> EntityKind {
s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()).unwrap()
}
fn last_spline(s: &Session) -> Spline {
let EntityKind::Spline(sp) = last(s) else { panic!("no spline") };
sp
}
fn logged(s: &Session, text: &str) -> bool {
s.log.iter().any(|l| l.contains(text))
}
const FIT: &str = "0,0 1,0.2 5,3 6,3.1 9,0";
#[test]
fn knots_option_sets_the_parametrisation_and_is_remembered() {
let mut s = Session::new();
s.cmdline(&format!("spline k u {FIT}")).unwrap();
s.cmdline("").unwrap();
let sp = last_spline(&s);
assert_eq!(sp.fit_opts.knots, KnotParam::Uniform);
assert_eq!(sp.fit.len(), 5);
assert_eq!(sp.infer_knot_param(), KnotParam::Uniform);
// The next SPLINE starts with it, and says so.
s.cmdline(&format!("spline {FIT}")).unwrap();
s.cmdline("").unwrap();
assert!(logged(&s, "Knots=Uniform"));
assert_eq!(last_spline(&s).fit_opts.knots, KnotParam::Uniform);
// Square root, then Enter at the Knots prompt keeps the current choice.
s.cmdline("spline k s k").unwrap();
s.cmdline("").unwrap();
s.cmdline(FIT).unwrap();
s.cmdline("").unwrap();
assert_eq!(last_spline(&s).fit_opts.knots, KnotParam::SqrtChord);
// A closed spline keeps it too.
s.cmdline("spline k c 0,0 4,0 4,3 0,3 c").unwrap();
let sp = last_spline(&s);
assert!(sp.closed && sp.is_periodic() && sp.fit_opts.knots == KnotParam::Chord);
// Not a parametrisation: reported, the question stays.
s.cmdline("spline k xyz").unwrap();
assert!(logged(&s, "Invalid option keyword."));
assert!(s.running.is_some());
s.cmdline("u 0,0 5,5 10,0").unwrap();
s.cmdline("").unwrap();
assert_eq!(last_spline(&s).fit_opts.knots, KnotParam::Uniform);
}
#[test]
fn tangency_options_fix_the_end_directions() {
let mut s = Session::new();
// Start tangent straight up, end tangent straight down; the end tangent finishes the spline.
s.cmdline("spline 0,0 t 0,5 10,0 t 10,-5").unwrap();
assert!(s.running.is_none(), "the end tangent ends the command");
let sp = last_spline(&s);
assert_eq!(sp.fit, [Vec2::ZERO, Vec2::new(10.0, 0.0)]);
assert_eq!(sp.fit_opts.start_tangent, Some(Vec2::Y));
assert_eq!(sp.fit_opts.end_tangent, Some(Vec2::new(0.0, -1.0)));
let (lo, hi) = sp.domain();
let h = 1e-7;
assert!((sp.eval(lo + h) - sp.eval(lo)).normalized().near(Vec2::Y, 1e-4));
assert!((sp.eval(hi) - sp.eval(hi - h)).normalized().near(Vec2::new(0.0, -1.0), 1e-4));
assert!(sp.eval((lo + hi) / 2.0).y > 1.0, "the curve bows up between the tangents");
// A typed angle works as a direction; Enter at the end tangent leaves that end free.
s.cmdline("spline 0,0 t 90 5,5 10,0 t").unwrap();
s.cmdline("").unwrap();
let sp = last_spline(&s);
assert!(sp.fit_opts.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-9)));
assert_eq!(sp.fit_opts.end_tangent, None);
assert_eq!(sp.fit.len(), 3);
// The tangent point may not be the fit point itself.
s.cmdline("spline 0,0 t 0,0").unwrap();
assert!(logged(&s, "The tangent point must differ from the fit point."));
s.cmdline("*cancel*").ok();
}
#[test]
fn tolerance_option_approximates_the_fit_points() {
let mut s = Session::new();
let pts: Vec<String> = (0..30)
.map(|i| {
let t = i as f64 / 29.0;
format!("{:.4},{:.4}", t * 10.0, (t * 3.0).sin() * 2.0 + if i % 2 == 0 { 0.01 } else { -0.01 })
})
.collect();
s.cmdline(&format!("spline {} l 0.1 {}", pts[0], pts[1..].join(" "))).unwrap();
s.cmdline("").unwrap();
let sp = last_spline(&s);
assert_eq!(sp.fit_opts.tolerance, 0.1);
assert_eq!(sp.fit.len(), 30, "the fit points are kept");
assert!(sp.control.len() < 30, "fewer control points than an interpolation: {}", sp.control.len());
let poly = sp.tessellate(1e-4);
for f in &sp.fit {
let d = poly.iter().map(|p| p.dist(*f)).fold(f64::INFINITY, f64::min);
assert!(d <= 0.1 + 1e-3, "fit point {f:?} is {d} away");
}
// A negative tolerance is refused and asked again.
s.cmdline("spline 0,0 l -1").unwrap();
assert!(logged(&s, "Requires a non-negative number."));
s.cmdline("0 5,5 10,0").unwrap();
s.cmdline("").unwrap();
assert_eq!(last_spline(&s).fit_opts.tolerance, 0.0);
}
#[test]
fn object_option_converts_polylines() {
let mut s = Session::new();
// A spline-fit polyline: PEDIT Spline leaves straight segments along the curve.
let h = s.execute("pline", &json!({"vertices": [[0, 0], [10, 10], [20, 0], [30, 10]]})).unwrap()["handle"].as_str().unwrap().to_string();
s.execute("pedit", &json!({"handle": h, "option": "spline"})).unwrap();
let EntityKind::LwPolyline(pl) = last(&s) else { panic!("no polyline") };
let n = pl.vertices.len();
s.cmdline("spline o").unwrap();
s.set_selection(Vec::new());
s.cmdline(&format!("{},{}", pl.vertices[1].p.x, pl.vertices[1].p.y)).unwrap();
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert!(logged(&s, "1 object(s) converted to splines"));
let doc = s.doc().unwrap();
assert_eq!(doc.model.iter().count(), 1, "converted in place");
let EntityKind::Spline(sp) = last(&s) else { panic!("not converted") };
assert_eq!(sp.fit.len(), n);
assert!(sp.eval(0.0).near(Vec2::ZERO, 1e-9) && sp.eval(1.0).near(Vec2::new(30.0, 10.0), 1e-9));
// JSON form: a closed polyline becomes a closed spline; arcs are refused.
let sq = s.execute("pline", &json!({"vertices": [[0, 20], [10, 20], [10, 30], [0, 30]], "closed": true})).unwrap()["handle"].clone();
let r = s.execute("spline", &json!({"object": sq})).unwrap();
assert_eq!(r["handles"][0], sq);
let EntityKind::Spline(sp) = last(&s) else { panic!("not converted") };
assert!(sp.closed && sp.is_periodic() && sp.fit.len() == 4);
let arc = s.execute("pline", &json!({"vertices": [{"p": [0, 50], "bulge": 1.0}, [10, 50]]})).unwrap()["handle"].clone();
let e = s.execute("spline", &json!({"object": arc})).unwrap_err().to_string();
assert!(e.contains("arc segments"), "{e}");
let line = s.execute("line", &json!({"points": [[0, 60], [5, 60]]})).unwrap()["handles"][0].clone();
assert!(s.execute("spline", &json!({"object": line})).is_err());
}
#[test]
fn json_form_takes_the_fit_options() {
let mut s = Session::new();
let fit = json!([[0, 0], [1, 0.2], [5, 3], [6, 3.1], [9, 0]]);
s.execute("spline", &json!({"fit": fit, "knots": "sqrt", "startTangent": [0, 2], "endTangent": [1, 0]})).unwrap();
let sp = last_spline(&s);
assert_eq!(sp.fit_opts.knots, KnotParam::SqrtChord);
assert_eq!(sp.fit_opts.start_tangent, Some(Vec2::Y));
assert_eq!(sp.control.len(), 7, "five fit points and two tangents");
s.execute("spline", &json!({"fit": fit, "closed": true, "knots": "uniform"})).unwrap();
assert!(last_spline(&s).is_periodic());
for bad in [json!({"fit": fit, "knots": "zigzag"}), json!({"fit": fit, "tolerance": -1}), json!({"fit": fit, "startTangent": [0, 0]})] {
assert!(s.execute("spline", &bad).is_err(), "{bad}");
}
// Editing a fit point keeps the options (grips and SPLINEDIT refit the same way).
let h = s.execute("spline", &json!({"fit": fit, "knots": "uniform", "startTangent": [0, 1]})).unwrap()["handle"].clone();
s.execute("splinedit", &json!({"handle": h, "option": "move", "index": 2, "to": [5, 4]})).unwrap();
let sp = last_spline(&s);
assert_eq!(sp.fit_opts.knots, KnotParam::Uniform);
assert_eq!(sp.fit_opts.start_tangent, Some(Vec2::Y));
// Reversing swaps and turns the tangents.
s.execute("splinedit", &json!({"handle": h, "option": "reverse"})).unwrap();
let sp = last_spline(&s);
assert_eq!(sp.fit_opts.start_tangent, None);
assert_eq!(sp.fit_opts.end_tangent, Some(-Vec2::Y));
}
+2
View File
@@ -13,6 +13,7 @@ mod intersect;
mod mat;
pub mod region;
mod spline;
mod spline_fit;
mod vec;
pub use bounds::Bounds2;
@@ -20,6 +21,7 @@ pub use curve::{Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_t
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
pub use mat::{Mat3, Mat4};
pub use spline::Spline;
pub use spline_fit::{FitOptions, KnotParam};
pub use vec::{Vec2, Vec3};
/// Geometric tolerance for coincidence tests in drawing units.
+12 -117
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::spline_fit::FitOptions;
use crate::{Bounds2, Vec2};
/// A (possibly rational) B-spline curve, DXF SPLINE style.
@@ -15,6 +16,10 @@ pub struct Spline {
pub fit: Vec<Vec2>,
#[serde(default)]
pub closed: bool,
/// How the curve was built from `fit` (knot parametrisation, end tangents, fit tolerance), so
/// edits to the fit points rebuild it the same way.
#[serde(default)]
pub fit_opts: FitOptions,
}
impl Spline {
@@ -23,68 +28,13 @@ impl Spline {
let n = control.len();
let degree = degree.clamp(1, n.saturating_sub(1).max(1));
let knots = clamped_uniform_knots(n, degree);
Spline { degree, knots, control, weights: Vec::new(), fit: Vec::new(), closed: false }
Spline { degree, knots, control, weights: Vec::new(), fit: Vec::new(), closed: false, fit_opts: FitOptions::default() }
}
/// Cubic interpolation through fit points (chord-length parametrisation, natural-ish ends
/// via a clamped knot vector: solve the banded system for control points).
pub fn from_fit_points(fit: &[Vec2]) -> Spline {
let n = fit.len();
if n < 2 {
return Spline {
degree: 1,
knots: vec![0.0, 0.0, 1.0, 1.0],
control: fit.to_vec(),
weights: Vec::new(),
fit: fit.to_vec(),
closed: false,
};
}
if n == 2 {
let mut s = Spline::from_control(fit.to_vec(), 1);
s.fit = fit.to_vec();
return s;
}
let degree = 3.min(n - 1);
// Chord-length parameters.
let mut u = vec![0.0; n];
let mut total = 0.0;
for i in 1..n {
total += fit.get(i).zip(fit.get(i - 1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0).max(1e-12);
if let Some(x) = u.get_mut(i) {
*x = total;
}
}
for x in u.iter_mut() {
*x /= total.max(1e-300);
}
// Knots by averaging.
let mut knots = vec![0.0; degree + 1];
for j in 1..n.saturating_sub(degree) {
let s: f64 = (j..j + degree).filter_map(|i| u.get(i)).sum();
knots.push(s / degree as f64);
}
knots.extend(std::iter::repeat_n(1.0, degree + 1));
// Basis matrix N[i][j] = N_j(u_i).
let mut m = vec![vec![0.0; n]; n];
for (i, ui) in u.iter().enumerate() {
let span = find_span(n - 1, degree, *ui, &knots);
let b = basis(span, *ui, degree, &knots);
for (k, v) in b.iter().enumerate() {
if let Some(cell) = m.get_mut(i).and_then(|r| r.get_mut(span + k - degree)) {
*cell = *v;
}
}
}
let xs: Vec<f64> = fit.iter().map(|p| p.x).collect();
let ys: Vec<f64> = fit.iter().map(|p| p.y).collect();
let (Some(cx), Some(cy)) = (solve(m.clone(), xs), solve(m, ys)) else {
let mut s = Spline::from_control(fit.to_vec(), degree);
s.fit = fit.to_vec();
return s;
};
let control = cx.into_iter().zip(cy).map(|(x, y)| Vec2::new(x, y)).collect();
Spline { degree, knots, control, weights: Vec::new(), fit: fit.to_vec(), closed: false }
Spline::fit_with(fit, false, FitOptions::default())
}
/// Smooth closed (periodic) cubic interpolation through fit points. Chord-length parameters
@@ -92,67 +42,12 @@ impl Spline {
/// first `degree` control points repeat at the end, so the curve has continuous tangent and
/// curvature at the seam. A repeated closing fit point is dropped.
pub fn from_fit_points_closed(fit: &[Vec2]) -> Spline {
let mut pts = fit.to_vec();
if pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
pts.pop();
}
let n = pts.len();
let fallback = |pts: &[Vec2]| {
let mut f = pts.to_vec();
f.extend(pts.first().copied());
let mut s = Spline::from_fit_points(&f);
s.fit = pts.to_vec();
s.closed = true;
s
};
let p = 3;
if n < p {
return fallback(&pts);
}
// Parameters t_0 = 0 .. t_n = 1 (t_n is the return to the first point).
let mut t = vec![0.0; n + 1];
let mut total = 0.0;
for i in 1..=n {
total += pts.get(i - 1).zip(pts.get(i % n)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0).max(1e-12);
if let Some(x) = t.get_mut(i) {
*x = total;
}
}
for x in t.iter_mut() {
*x /= total;
}
// Periodic knots: knots[j] = t_(j-p), extended by whole periods on both sides.
let tk = |i: isize| {
let (q, r) = (i.div_euclid(n as isize), i.rem_euclid(n as isize) as usize);
t.get(r).copied().unwrap_or(0.0) + q as f64
};
let m = n + p;
let knots: Vec<f64> = (0..m + p + 1).map(|j| tk(j as isize - p as isize)).collect();
// Interpolation at t_i; control index k and k + n are the same unknown.
let mut a = vec![vec![0.0; n]; n];
for (i, row) in a.iter_mut().enumerate() {
let u = t.get(i).copied().unwrap_or(0.0);
let span = find_span(m - 1, p, u, &knots);
for (k, v) in basis(span, u, p, &knots).iter().enumerate() {
if let Some(cell) = row.get_mut((span + k - p) % n) {
*cell += *v;
}
}
}
let xs: Vec<f64> = pts.iter().map(|q| q.x).collect();
let ys: Vec<f64> = pts.iter().map(|q| q.y).collect();
let (Some(cx), Some(cy)) = (solve(a.clone(), xs), solve(a, ys)) else { return fallback(&pts) };
let mut control: Vec<Vec2> = cx.into_iter().zip(cy).map(|(x, y)| Vec2::new(x, y)).collect();
if control.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) {
return fallback(&pts);
}
control.extend_from_within(..p);
Spline { degree: p, knots, control, weights: Vec::new(), fit: pts, closed: true }
Spline::fit_with(fit, true, FitOptions::default())
}
/// Interpolation through fit points, open or closed (see `from_fit_points_closed`).
pub fn from_fit(fit: &[Vec2], closed: bool) -> Spline {
if closed { Spline::from_fit_points_closed(fit) } else { Spline::from_fit_points(fit) }
Spline::fit_with(fit, closed, FitOptions::default())
}
/// Closed with an unclamped (periodic) knot vector, as DXF flags it (group 70 bit 2).
@@ -221,7 +116,7 @@ pub(crate) fn clamped_uniform_knots(n: usize, degree: usize) -> Vec<f64> {
k
}
fn find_span(n: usize, p: usize, u: f64, knots: &[f64]) -> usize {
pub(crate) fn find_span(n: usize, p: usize, u: f64, knots: &[f64]) -> usize {
let kn = knots.get(n + 1).copied().unwrap_or(1.0);
if u >= kn {
return n;
@@ -246,7 +141,7 @@ fn find_span(n: usize, p: usize, u: f64, knots: &[f64]) -> usize {
mid
}
fn basis(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
pub(crate) fn basis(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
let mut n = vec![0.0; p + 1];
let mut left = vec![0.0; p + 1];
let mut right = vec![0.0; p + 1];
@@ -271,7 +166,7 @@ fn basis(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
/// Gaussian elimination with partial pivoting.
#[allow(clippy::needless_range_loop)]
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
let n = b.len();
for c in 0..n {
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
+584
View File
@@ -0,0 +1,584 @@
//! Fit-point splines: knot parametrisation (chord, square root, uniform), end tangents and fit
//! tolerance (least-squares approximation with fewer control points), as AutoCAD's SPLINE Fit
//! method offers them. Construction follows the textbook global interpolation and approximation
//! schemes (Piegl & Tiller, *The NURBS Book*, ch. 9).
use serde::{Deserialize, Serialize};
use crate::spline::{basis, find_span, solve};
use crate::{Mat3, Spline, Vec2};
/// How fit points are spaced in parameter space (the SPLINE `Knots` option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum KnotParam {
/// Proportional to the distance between fit points.
#[default]
Chord,
/// Proportional to the square root of the distance ("centripetal").
SqrtChord,
/// Equal steps whatever the distance.
Uniform,
}
impl KnotParam {
pub const ALL: [KnotParam; 3] = [KnotParam::Chord, KnotParam::SqrtChord, KnotParam::Uniform];
/// The name as the prompt and the "Current settings" line show it.
pub fn name(self) -> &'static str {
match self {
KnotParam::Chord => "Chord",
KnotParam::SqrtChord => "Square root",
KnotParam::Uniform => "Uniform",
}
}
/// Parse a keyword or JSON value ("chord", "sqrt", "square root", "uniform", or 0/1/2 as in
/// the SPLKNOTS setting).
pub fn parse(s: &str) -> Option<KnotParam> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"c" | "chord" | "0" => KnotParam::Chord,
"s" | "sqrt" | "square root" | "squareroot" | "centripetal" | "1" => KnotParam::SqrtChord,
"u" | "uniform" | "2" => KnotParam::Uniform,
_ => return None,
})
}
/// The SPLKNOTS code (0 chord, 1 square root, 2 uniform).
pub fn code(self) -> i64 {
match self {
KnotParam::Chord => 0,
KnotParam::SqrtChord => 1,
KnotParam::Uniform => 2,
}
}
fn step(self, d: f64) -> f64 {
match self {
KnotParam::Chord => d.max(1e-12),
KnotParam::SqrtChord => d.sqrt().max(1e-12),
KnotParam::Uniform => 1.0,
}
}
}
/// How a spline is built from its fit points. The default interpolates with chord-length
/// parameters and free ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct FitOptions {
#[serde(default)]
pub knots: KnotParam,
/// Unit tangent direction at the first fit point; `None` lets the end follow the points.
#[serde(default)]
pub start_tangent: Option<Vec2>,
/// Unit tangent direction at the last fit point.
#[serde(default)]
pub end_tangent: Option<Vec2>,
/// Largest allowed distance between the curve and a fit point; 0 passes through them.
#[serde(default)]
pub tolerance: f64,
}
impl FitOptions {
/// Normalised tangents (zero or non-finite directions dropped) and a finite, non-negative
/// tolerance: what a spline stores.
pub fn sanitized(self) -> FitOptions {
let dir = |t: Option<Vec2>| t.filter(|v| v.x.is_finite() && v.y.is_finite() && v.len() > 1e-12).map(Vec2::normalized);
FitOptions {
knots: self.knots,
start_tangent: dir(self.start_tangent),
end_tangent: dir(self.end_tangent),
tolerance: if self.tolerance.is_finite() { self.tolerance.max(0.0) } else { 0.0 },
}
}
}
/// Fit points above this count are always interpolated: the approximation search solves dense
/// systems the size of the point count several times.
const MAX_APPROX_POINTS: usize = 300;
impl Spline {
/// Build a spline through (or, with a tolerance, near) `fit`, open or closed. Closed curves
/// are periodic and ignore end tangents; their tolerance is kept but they still interpolate.
pub fn fit_with(fit: &[Vec2], closed: bool, opts: FitOptions) -> Spline {
let opts = opts.sanitized();
let mut s = if closed { closed_fit(fit, opts.knots) } else { open_fit(fit, opts) };
s.fit_opts = if closed { FitOptions { start_tangent: None, end_tangent: None, ..opts } } else { opts };
s
}
/// Rebuild from new fit points with this spline's knot parametrisation, tangents and tolerance.
pub fn refit(&self, fit: &[Vec2], closed: bool) -> Spline {
Spline::fit_with(fit, closed, self.fit_opts)
}
/// Which knot parametrisation produced this spline's knots from its fit points (DXF files
/// keep the knots but not the parametrisation). Compares knot vectors only (no solving), so
/// it is cheap for any number of fit points. Chord when nothing matches.
pub fn infer_knot_param(&self) -> KnotParam {
if self.fit.len() < 3 {
return self.fit_opts.knots;
}
let (Some(k0), Some(k1)) = (self.knots.get(self.degree), self.knots.get(self.control.len())) else { return KnotParam::Chord };
if !(k1 - k0).is_finite() || k1 - k0 <= 1e-300 {
return KnotParam::Chord;
}
let norm: Vec<f64> = self.knots.iter().map(|k| (k - k0) / (k1 - k0)).collect();
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
let same = |k: &[f64]| k.len() == norm.len() && k.iter().zip(&norm).all(|(a, b)| close(*a, *b));
let p = self.degree;
let o = &self.fit_opts;
KnotParam::ALL
.into_iter()
.find(|kind| {
if self.closed {
// Our periodic knots run from the parameter of fit point 0 (at 0) to 1.
let mut pts = self.fit.clone();
if pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
pts.pop();
}
let t = params(&pts, *kind, true);
let pk = periodic_knots(&t, pts.len(), p);
if same(&pk) {
return true;
}
}
let u = params(&self.fit, *kind, self.closed);
if !self.closed {
if same(&interp_knots(&u, o.start_tangent.is_some(), o.end_tangent.is_some()).1) {
return true;
}
let m = self.control.len();
if p < m && m < u.len() && same(&lsq_knots(&u, m, p)) {
return true;
}
}
// Other writers use the parameters themselves as interior knots.
u.len() > 2 && u.iter().skip(1).take(u.len() - 2).all(|q| norm.iter().any(|k| close(*k, *q)))
})
.unwrap_or(KnotParam::Chord)
}
/// Apply an affine map to the control and fit points and turn the end tangents with it.
pub fn transform(&mut self, m: &Mat3) {
for c in self.control.iter_mut().chain(self.fit.iter_mut()) {
*c = m.apply(*c);
}
let turn = |t: Option<Vec2>| t.map(|v| m.apply_vec(v).normalized()).filter(|v| v.len() > 0.5);
self.fit_opts.start_tangent = turn(self.fit_opts.start_tangent);
self.fit_opts.end_tangent = turn(self.fit_opts.end_tangent);
}
}
/// Normalised parameters of the fit points: `n` values for an open curve, `n + 1` for a closed
/// one (the last is the return to the first point).
pub(crate) fn params(pts: &[Vec2], kind: KnotParam, closed: bool) -> Vec<f64> {
let n = pts.len();
let count = if closed { n + 1 } else { n };
let mut u = vec![0.0; count];
let mut total = 0.0;
for i in 1..count {
total += pts.get(i - 1).zip(pts.get(i % n.max(1))).map(|(a, b)| kind.step(a.dist(*b))).unwrap_or(1.0);
if let Some(x) = u.get_mut(i) {
*x = total;
}
}
for x in u.iter_mut() {
*x /= total.max(1e-300);
}
u
}
fn chord_length(pts: &[Vec2]) -> f64 {
pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum()
}
fn open_fit(fit: &[Vec2], opts: FitOptions) -> Spline {
let n = fit.len();
let tangents = opts.start_tangent.is_some() || opts.end_tangent.is_some();
if n < 2 || (n == 2 && !tangents) {
let mut s = if n < 2 {
Spline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], control: fit.to_vec(), ..Spline::default() }
} else {
Spline::from_control(fit.to_vec(), 1)
};
s.fit = fit.to_vec();
return s;
}
let u = params(fit, opts.knots, false);
// Tangents are directions; the derivative's length is the curve length in parameter space.
let len = chord_length(fit).max(1e-12);
let d0 = opts.start_tangent.map(|t| t * len);
let d1 = opts.end_tangent.map(|t| t * len);
if opts.tolerance > 0.0
&& n <= MAX_APPROX_POINTS
&& let Some(s) = approximate(fit, &u, d0, d1, opts.tolerance)
{
return s;
}
interpolate(fit, &u, d0, d1).unwrap_or_else(|| {
let mut s = Spline::from_control(fit.to_vec(), 3.min(n - 1));
s.fit = fit.to_vec();
s
})
}
/// Global interpolation: one control point per fit point plus one per end-derivative
/// constraint, knots by averaging the parameters (an end with a derivative counts its
/// parameter twice).
fn interpolate(fit: &[Vec2], u: &[f64], d0: Option<Vec2>, d1: Option<Vec2>) -> Option<Spline> {
let (p, knots) = interp_knots(u, d0.is_some(), d1.is_some());
let m = knots.len().checked_sub(p + 1)?;
let mut a = vec![vec![0.0; m]; m];
let (mut xs, mut ys) = (vec![0.0; m], vec![0.0; m]);
let mut row = 0;
let mut put = |coeffs: &[(usize, f64)], rhs: Vec2, a: &mut Vec<Vec<f64>>| {
if let Some(r) = a.get_mut(row) {
for (j, v) in coeffs {
if let Some(c) = r.get_mut(*j) {
*c += *v;
}
}
}
if let (Some(x), Some(y)) = (xs.get_mut(row), ys.get_mut(row)) {
(*x, *y) = (rhs.x, rhs.y);
}
row += 1;
};
for (ui, q) in u.iter().zip(fit) {
let span = find_span(m - 1, p, *ui, &knots);
let coeffs: Vec<(usize, f64)> = basis(span, *ui, p, &knots).into_iter().enumerate().map(|(k, v)| (span + k - p, v)).collect();
put(&coeffs, *q, &mut a);
}
// Clamped ends: C'(0) = p / u_(p+1) (P1 - P0), C'(1) = p / (1 - u_(m-1)) (P_(m-1) - P_(m-2)).
if let Some(d) = d0 {
let c = p as f64 / knots.get(p + 1).copied().unwrap_or(1.0).max(1e-12);
put(&[(0, -c), (1, c)], d, &mut a);
}
if let Some(d) = d1 {
let c = p as f64 / (1.0 - knots.get(m - 1).copied().unwrap_or(0.0)).max(1e-12);
put(&[(m - 2, -c), (m - 1, c)], d, &mut a);
}
let (cx, cy) = (solve(a.clone(), xs)?, solve(a, ys)?);
let control: Vec<Vec2> = cx.into_iter().zip(cy).map(|(x, y)| Vec2::new(x, y)).collect();
if control.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) {
return None;
}
Some(Spline { degree: p, knots, control, fit: fit.to_vec(), ..Spline::default() })
}
/// Degree and clamped knots for interpolating at parameters `u`, with optional end derivatives:
/// knots average the parameters, an end with a derivative counting its parameter twice.
fn interp_knots(u: &[f64], d0: bool, d1: bool) -> (usize, Vec<f64>) {
let m = u.len() + usize::from(d0) + usize::from(d1);
let p = 3.min(m.saturating_sub(1)).max(1);
let mut seq = Vec::with_capacity(m);
seq.extend(u.first().copied().filter(|_| d0));
seq.extend_from_slice(u);
seq.extend(u.last().copied().filter(|_| d1));
let mut knots = vec![0.0; p + 1];
for j in 1..m.saturating_sub(p) {
let s: f64 = (j..j + p).filter_map(|i| seq.get(i)).sum();
knots.push(s / p as f64);
}
knots.extend(std::iter::repeat_n(1.0, p + 1));
(p, knots)
}
/// Clamped knots for a least-squares fit with `m` control points of degree `p` so every span
/// holds parameters (The NURBS Book eq. 9.69). Needs `p < m < u.len()`.
fn lsq_knots(u: &[f64], m: usize, p: usize) -> Vec<f64> {
let n = u.len();
let d = n as f64 / m.saturating_sub(p).max(1) as f64;
let mut knots = vec![0.0; p + 1];
for j in 1..m.saturating_sub(p) {
let jd = j as f64 * d;
let i = (jd.floor() as usize).clamp(1, n.saturating_sub(1).max(1));
let a = jd - i as f64;
knots.push((1.0 - a) * u.get(i - 1).copied().unwrap_or(0.0) + a * u.get(i).copied().unwrap_or(1.0));
}
knots.extend(std::iter::repeat_n(1.0, p + 1));
knots
}
/// Periodic knots for `n` points with closed parameters `t` (`n + 1` values): knots[j] =
/// t_(j-p), extended by whole periods on both sides.
fn periodic_knots(t: &[f64], n: usize, p: usize) -> Vec<f64> {
let tk = |i: isize| {
let (q, r) = (i.div_euclid(n.max(1) as isize), i.rem_euclid(n.max(1) as isize) as usize);
t.get(r).copied().unwrap_or(0.0) + q as f64
};
(0..n + 2 * p + 1).map(|j| tk(j as isize - p as isize)).collect()
}
/// Least-squares approximation within `tol`: the fewest control points (searched by bisection)
/// whose curve passes within `tol` of every fit point, the end points (and end tangents) kept
/// exact. `None` when only interpolation meets the tolerance.
fn approximate(fit: &[Vec2], u: &[f64], d0: Option<Vec2>, d1: Option<Vec2>, tol: f64) -> Option<Spline> {
let n = fit.len();
let p = 3;
// Fewer control points than fit points, or it is not an approximation.
let (mut lo, mut hi) = (p + 1, n.checked_sub(1)?);
let mut best = None;
while lo <= hi {
let mid = (lo + hi) / 2;
match least_squares(fit, u, mid, p, d0, d1) {
Some(s) if max_deviation(&s, fit, u) <= tol => {
best = Some(s);
hi = mid - 1;
}
_ => lo = mid + 1,
}
}
best
}
fn max_deviation(s: &Spline, fit: &[Vec2], u: &[f64]) -> f64 {
fit.iter().zip(u).map(|(q, t)| s.eval(*t).dist(*q)).fold(0.0, f64::max)
}
fn least_squares(fit: &[Vec2], u: &[f64], m: usize, p: usize, d0: Option<Vec2>, d1: Option<Vec2>) -> Option<Spline> {
let n = fit.len();
if m <= p || m >= n {
return None;
}
let knots = lsq_knots(u, m, p);
let (first, last) = (*fit.first()?, *fit.last()?);
let mut fixed: Vec<Option<Vec2>> = vec![None; m];
let mut fix = |j: usize, q: Vec2| {
if let Some(f) = fixed.get_mut(j) {
*f = Some(q);
}
};
fix(0, first);
fix(m - 1, last);
// An end tangent fixes the neighbouring control point (clamped-end derivative).
if let Some(d) = d0 {
fix(1, first + d * (knots.get(p + 1).copied().unwrap_or(1.0) / p as f64));
}
if let Some(d) = d1 {
fix(m - 2, last - d * ((1.0 - knots.get(m - 1).copied().unwrap_or(0.0)) / p as f64));
}
let free: Vec<usize> = (0..m).filter(|j| fixed.get(*j).is_some_and(Option::is_none)).collect();
let k = free.len();
let mut a = vec![vec![0.0; k]; k];
let (mut bx, mut by) = (vec![0.0; k], vec![0.0; k]);
for (t, q) in u.iter().zip(fit).skip(1).take(n.saturating_sub(2)) {
let span = find_span(m - 1, p, *t, &knots);
let mut row = vec![0.0; m];
for (i, v) in basis(span, *t, p, &knots).into_iter().enumerate() {
if let Some(c) = row.get_mut(span + i - p) {
*c = v;
}
}
let mut r = *q;
for (j, c) in row.iter().enumerate() {
if let Some(Some(f)) = fixed.get(j) {
r -= *f * *c;
}
}
for (ri, &fi) in free.iter().enumerate() {
let ni = row.get(fi).copied().unwrap_or(0.0);
if ni == 0.0 {
continue;
}
if let (Some(x), Some(y)) = (bx.get_mut(ri), by.get_mut(ri)) {
*x += ni * r.x;
*y += ni * r.y;
}
for (ci, &fj) in free.iter().enumerate() {
if let Some(cell) = a.get_mut(ri).and_then(|rr| rr.get_mut(ci)) {
*cell += ni * row.get(fj).copied().unwrap_or(0.0);
}
}
}
}
let (cx, cy) = if k == 0 { (Vec::new(), Vec::new()) } else { (solve(a.clone(), bx)?, solve(a, by)?) };
let mut solved = free.iter().zip(cx.into_iter().zip(cy));
let control: Vec<Vec2> = fixed.into_iter().map(|f| f.or_else(|| solved.next().map(|(_, (x, y))| Vec2::new(x, y)))).collect::<Option<_>>()?;
if control.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) {
return None;
}
Some(Spline { degree: p, knots, control, fit: fit.to_vec(), ..Spline::default() })
}
fn closed_fit(fit: &[Vec2], kind: KnotParam) -> Spline {
let mut pts = fit.to_vec();
if pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
pts.pop();
}
let n = pts.len();
let fallback = |pts: &[Vec2]| {
let mut f = pts.to_vec();
f.extend(pts.first().copied());
let mut s = open_fit(&f, FitOptions { knots: kind, ..FitOptions::default() });
s.fit = pts.to_vec();
s.closed = true;
s
};
let p = 3;
if n < p {
return fallback(&pts);
}
// Parameters t_0 = 0 .. t_n = 1 (t_n is the return to the first point).
let t = params(&pts, kind, true);
let m = n + p;
let knots = periodic_knots(&t, n, p);
// Interpolation at t_i; control index k and k + n are the same unknown.
let mut a = vec![vec![0.0; n]; n];
for (i, row) in a.iter_mut().enumerate() {
let u = t.get(i).copied().unwrap_or(0.0);
let span = find_span(m - 1, p, u, &knots);
for (k, v) in basis(span, u, p, &knots).iter().enumerate() {
if let Some(cell) = row.get_mut((span + k - p) % n) {
*cell += *v;
}
}
}
let xs: Vec<f64> = pts.iter().map(|q| q.x).collect();
let ys: Vec<f64> = pts.iter().map(|q| q.y).collect();
let (Some(cx), Some(cy)) = (solve(a.clone(), xs), solve(a, ys)) else { return fallback(&pts) };
let mut control: Vec<Vec2> = cx.into_iter().zip(cy).map(|(x, y)| Vec2::new(x, y)).collect();
if control.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) {
return fallback(&pts);
}
control.extend_from_within(..p);
Spline { degree: p, knots, control, fit: pts, closed: true, ..Spline::default() }
}
#[cfg(test)]
mod tests {
use super::*;
fn pts() -> Vec<Vec2> {
vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.2), Vec2::new(5.0, 3.0), Vec2::new(6.0, 3.1), Vec2::new(9.0, 0.0)]
}
fn passes_through(s: &Spline, fit: &[Vec2], tol: f64) -> bool {
let u = params(fit, s.fit_opts.knots, s.closed);
fit.iter().zip(&u).all(|(f, t)| s.eval(*t).dist(*f) < tol)
}
#[test]
fn default_options_match_the_plain_fit() {
let fit = pts();
assert_eq!(Spline::fit_with(&fit, false, FitOptions::default()), Spline::from_fit_points(&fit));
assert_eq!(Spline::fit_with(&fit, true, FitOptions::default()), Spline::from_fit_points_closed(&fit));
}
#[test]
fn knot_parametrisations_differ_and_still_interpolate() {
let fit = pts();
let knots: Vec<Vec<f64>> = KnotParam::ALL
.iter()
.map(|k| {
let s = Spline::fit_with(&fit, false, FitOptions { knots: *k, ..FitOptions::default() });
assert!(s.is_valid() && passes_through(&s, &fit, 1e-3), "{k:?}");
assert_eq!(s.fit_opts.knots, *k);
assert_eq!(s.infer_knot_param(), *k, "the knots identify the parametrisation");
s.knots
})
.collect();
assert!(knots[0] != knots[1] && knots[1] != knots[2] && knots[0] != knots[2]);
// Uniform: equally spaced parameters, so the interior knots are 1/2 (five points, cubic).
assert!(knots[2].iter().any(|k| (k - 0.5).abs() < 1e-12));
for k in KnotParam::ALL {
let s = Spline::fit_with(&fit, true, FitOptions { knots: k, ..FitOptions::default() });
assert!(s.is_valid() && s.is_periodic() && passes_through(&s, &fit, 1e-3), "closed {k:?}");
assert_eq!(s.infer_knot_param(), k);
}
}
#[test]
fn end_tangents_set_the_end_directions() {
let fit = pts();
let opts = FitOptions { start_tangent: Some(Vec2::new(0.0, 2.0)), end_tangent: Some(Vec2::new(1.0, -1.0)), ..FitOptions::default() };
let s = Spline::fit_with(&fit, false, opts);
assert!(s.is_valid() && passes_through(&s, &fit, 1e-3));
assert_eq!(s.control.len(), fit.len() + 2);
assert_eq!(s.fit_opts.start_tangent, Some(Vec2::new(0.0, 1.0)), "stored as a unit vector");
let (lo, hi) = s.domain();
let h = 1e-7;
let start = (s.eval(lo + h) - s.eval(lo)).normalized();
let end = (s.eval(hi) - s.eval(hi - h)).normalized();
assert!(start.near(Vec2::new(0.0, 1.0), 1e-4), "{start:?}");
assert!(end.near(Vec2::new(1.0, -1.0).normalized(), 1e-4), "{end:?}");
// One tangent only, and two fit points with tangents (a cubic Bezier).
let one = Spline::fit_with(&fit, false, FitOptions { start_tangent: Some(Vec2::Y), ..FitOptions::default() });
assert!(one.is_valid() && one.control.len() == fit.len() + 1 && passes_through(&one, &fit, 1e-3));
let two = [Vec2::ZERO, Vec2::new(4.0, 0.0)];
let b = Spline::fit_with(
&two,
false,
FitOptions { start_tangent: Some(Vec2::Y), end_tangent: Some(Vec2::new(0.0, -1.0)), ..FitOptions::default() },
);
assert!(b.is_valid() && b.degree == 3 && b.control.len() == 4);
assert!(b.eval(0.5).y > 0.5, "the tangents bow the curve up");
// Closed curves ignore tangents.
assert_eq!(Spline::fit_with(&fit, true, opts).fit_opts.start_tangent, None);
}
#[test]
fn tolerance_approximates_with_fewer_control_points() {
// Thirty noisy points along a gentle arc.
let fit: Vec<Vec2> = (0..30)
.map(|i| {
let t = i as f64 / 29.0;
Vec2::new(t * 10.0, (t * 3.0).sin() * 2.0 + if i % 2 == 0 { 0.01 } else { -0.01 })
})
.collect();
let exact = Spline::fit_with(&fit, false, FitOptions::default());
let tol = 0.1;
let s = Spline::fit_with(&fit, false, FitOptions { tolerance: tol, ..FitOptions::default() });
assert!(s.is_valid());
assert!(s.control.len() < exact.control.len(), "{} control points", s.control.len());
let u = params(&fit, KnotParam::Chord, false);
assert!(max_deviation(&s, &fit, &u) <= tol + 1e-9);
assert!(s.eval(0.0).near(fit[0], 1e-9) && s.eval(1.0).near(fit[29], 1e-9), "ends stay exact");
assert_eq!(s.fit, fit, "the fit points are kept");
assert_eq!(s.fit_opts.tolerance, tol);
// With tangents too.
let t = Spline::fit_with(
&fit,
false,
FitOptions { tolerance: tol, start_tangent: Some(Vec2::X), end_tangent: Some(Vec2::X), ..FitOptions::default() },
);
assert!(t.is_valid() && max_deviation(&t, &fit, &u) <= tol + 1e-9);
let h = 1e-7;
assert!((t.eval(h) - t.eval(0.0)).normalized().near(Vec2::X, 1e-4));
// A tolerance too small to approximate interpolates instead.
let tiny = Spline::fit_with(&fit, false, FitOptions { tolerance: 1e-9, ..FitOptions::default() });
assert_eq!(tiny.control.len(), exact.control.len());
}
#[test]
fn hostile_options_never_panic() {
let fit = pts();
let bad = FitOptions {
knots: KnotParam::Uniform,
start_tangent: Some(Vec2::new(f64::NAN, 1.0)),
end_tangent: Some(Vec2::ZERO),
tolerance: f64::INFINITY,
};
let s = Spline::fit_with(&fit, false, bad);
assert!(s.is_valid() && s.fit_opts.start_tangent.is_none() && s.fit_opts.end_tangent.is_none() && s.fit_opts.tolerance == 0.0);
for n in 0..4 {
let few: Vec<Vec2> = fit.iter().take(n).copied().collect();
let _ = Spline::fit_with(
&few,
false,
FitOptions { start_tangent: Some(Vec2::Y), end_tangent: Some(Vec2::X), tolerance: 1.0, ..FitOptions::default() },
);
let _ = Spline::fit_with(&few, true, FitOptions { knots: KnotParam::SqrtChord, ..FitOptions::default() });
}
let same = [Vec2::ZERO; 5];
let _ = Spline::fit_with(&same, false, FitOptions { tolerance: 0.5, start_tangent: Some(Vec2::X), ..FitOptions::default() });
}
#[test]
fn transform_turns_the_tangents() {
let fit = pts();
let mut s = Spline::fit_with(&fit, false, FitOptions { start_tangent: Some(Vec2::X), ..FitOptions::default() });
s.transform(&Mat3::rotate(std::f64::consts::FRAC_PI_2));
assert!(s.fit_opts.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-12)));
}
}
+14 -2
View File
@@ -265,11 +265,23 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
let control = t.pts(10);
let weights = t.all_f(41);
let fit = t.pts(11);
let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed: t.i(70).unwrap_or(0) & 1 != 0 };
let tangent = |c: i32| t.f(c).map(|_| t.p(c).xy());
let fit_opts = cadcraft_geom::FitOptions {
knots: Default::default(),
start_tangent: tangent(12),
end_tangent: tangent(13),
tolerance: t.fd(44, 0.0),
}
.sanitized();
let closed = t.i(70).unwrap_or(0) & 1 != 0;
let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed, fit_opts };
if !sp.is_valid() && fit.len() >= 2 {
sp = Spline::from_fit(&fit, sp.closed);
sp = Spline::fit_with(&fit, sp.closed, fit_opts);
} else if !sp.is_valid() && sp.control.len() >= 2 {
sp = Spline::from_control(sp.control.clone(), degree);
} else if fit.len() >= 3 {
// DXF keeps the knots, not how they were spaced: recover it for later refits.
sp.fit_opts.knots = sp.infer_knot_param();
}
EntityKind::Spline(sp)
}
+12
View File
@@ -496,6 +496,18 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.i(72, s.knots.len() as i64);
w.i(73, s.control.len() as i64);
w.i(74, s.fit.len() as i64);
w.f(42, 1e-10);
w.f(43, 1e-10);
// Fit data: tolerance and end tangents (the knot parametrisation is implied by the knots).
if !s.fit.is_empty() {
w.f(44, s.fit_opts.tolerance);
if let Some(t) = s.fit_opts.start_tangent {
w.p(12, t.to3(0.0));
}
if let Some(t) = s.fit_opts.end_tangent {
w.p(13, t.to3(0.0));
}
}
for k in &s.knots {
w.f(40, *k);
}
+64
View File
@@ -0,0 +1,64 @@
//! SPLINE fit data in DXF: fit tolerance (group 44) and end tangents (12/13) are written and read
//! back; the knot parametrisation, which DXF does not store, is recovered from the knots (#408).
use cadcraft_doc::{Common, Drawing, EntityKind, Space};
use cadcraft_geom::{FitOptions, KnotParam, Spline, Vec2};
fn roundtrip(sp: &Spline) -> Spline {
let mut d = Drawing::new_metric();
d.add(&Space::Model, Common::default(), EntityKind::Spline(sp.clone())).unwrap();
let bytes = cadcraft_io::write(&d, "a.dxf").unwrap();
let back = cadcraft_io::read(&bytes, "a.dxf").unwrap();
let Some(EntityKind::Spline(r)) = back.model.iter().map(|e| e.kind.clone()).next() else { panic!("no spline") };
r
}
#[test]
fn fit_options_roundtrip_through_dxf() {
let fit = [Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.2), Vec2::new(5.0, 3.0), Vec2::new(6.0, 3.1), Vec2::new(9.0, 0.0)];
for knots in KnotParam::ALL {
let opts =
FitOptions { knots, start_tangent: Some(Vec2::new(0.0, 1.0)), end_tangent: Some(Vec2::new(1.0, -1.0).normalized()), tolerance: 0.0 };
let sp = Spline::fit_with(&fit, false, opts);
let r = roundtrip(&sp);
assert_eq!(r.fit_opts.knots, knots);
assert!(r.fit_opts.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-9)));
assert!(r.fit_opts.end_tangent.is_some_and(|t| t.near(Vec2::new(1.0, -1.0).normalized(), 1e-9)));
assert_eq!(r.control.len(), sp.control.len());
for i in 0..=20 {
let t = i as f64 / 20.0;
assert!(r.eval(t).near(sp.eval(t), 1e-9));
}
// Closed (periodic) splines keep their parametrisation too.
let c = roundtrip(&Spline::fit_with(&fit, true, FitOptions { knots, ..FitOptions::default() }));
assert!(c.is_periodic() && c.fit_opts.knots == knots);
}
let fit: Vec<Vec2> = (0..20).map(|i| Vec2::new(i as f64, (i as f64 * 0.4).sin())).collect();
let sp = Spline::fit_with(&fit, false, FitOptions { tolerance: 0.05, ..FitOptions::default() });
let r = roundtrip(&sp);
assert_eq!(r.fit_opts.tolerance, 0.05);
assert_eq!(r.control.len(), sp.control.len(), "the approximation is kept, not re-interpolated");
}
#[test]
fn fit_data_is_written_with_the_documented_groups() {
let sp = Spline::fit_with(
&[Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(8.0, 2.0)],
false,
FitOptions { start_tangent: Some(Vec2::Y), tolerance: 0.25, ..FitOptions::default() },
);
let mut d = Drawing::new_metric();
d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
let text = String::from_utf8(cadcraft_io::write(&d, "a.dxf").unwrap()).unwrap();
let spline = &text[text.find("AcDbSpline").unwrap()..];
let spline = &spline[..spline.find("\r\n 0\r\n").unwrap()];
assert!(spline.contains(" 44\r\n0.25\r\n"), "fit tolerance");
assert!(spline.contains(" 12\r\n0.0\r\n 22\r\n1.0\r\n"), "start tangent");
assert!(!spline.contains(" 13\r\n"), "no end tangent");
// A fit spline whose stored knots don't match its control points is rebuilt from the fit
// data, tangents included.
let dxf = "0\r\nSECTION\r\n2\r\nENTITIES\r\n0\r\nSPLINE\r\n8\r\n0\r\n100\r\nAcDbSpline\r\n70\r\n8\r\n71\r\n3\r\n74\r\n3\r\n12\r\n0.0\r\n22\r\n1.0\r\n32\r\n0.0\r\n11\r\n0.0\r\n21\r\n0.0\r\n31\r\n0.0\r\n11\r\n4.0\r\n21\r\n0.0\r\n31\r\n0.0\r\n11\r\n8.0\r\n21\r\n2.0\r\n31\r\n0.0\r\n0\r\nENDSEC\r\n0\r\nEOF\r\n";
let back = cadcraft_io::read(dxf.as_bytes(), "b.dxf").unwrap();
let Some(EntityKind::Spline(r)) = back.model.iter().map(|e| e.kind.clone()).next() else { panic!("no spline") };
assert!(r.is_valid() && r.fit_opts.start_tangent == Some(Vec2::Y) && r.control.len() == 4);
}