diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 984720db..65646a2f 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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); diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index fccb71ac..2b1f3ae6 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -582,6 +582,7 @@ fn insert_knot(sp: &Spline, t: f64) -> Option { 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 + }, } } diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index dfb7be58..b097a426 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { 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 { } fn run_spline(s: &mut Session, p: &Value) -> Result { + 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>, + /// 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, +} + +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 { - 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))] } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index eb454aa4..3cd305d7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -23,6 +23,7 @@ mod pline_opts; mod props; mod qselect; mod settings; +mod spline_opts; mod table; mod trimextend; mod utility; diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index f9e27d83..ff8fe9aa 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -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 { "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 { 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; diff --git a/crates/engine/src/cmd/spline_opts.rs b/crates/engine/src/cmd/spline_opts.rs new file mode 100644 index 00000000..a5512db7 --- /dev/null +++ b/crates/engine/src/cmd/spline_opts.rs @@ -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) -> 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 { + 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 { + 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 { + let (pts, closed): (Vec, 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 { + 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 { + 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> { + 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 { + 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 })) +} diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index f0e2d2d4..43e6330b 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -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 { diff --git a/crates/engine/tests/draw_prompts.rs b/crates/engine/tests/draw_prompts.rs index 9ed0fe5a..af373a5d 100644 --- a/crates/engine/tests/draw_prompts.rs +++ b/crates/engine/tests/draw_prompts.rs @@ -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") }; diff --git a/crates/engine/tests/spline_options.rs b/crates/engine/tests/spline_options.rs new file mode 100644 index 00000000..a9cc52ad --- /dev/null +++ b/crates/engine/tests/spline_options.rs @@ -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 = (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)); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 7ad474f5..f4aa87bb 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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. diff --git a/crates/geom/src/spline.rs b/crates/geom/src/spline.rs index e58a46b4..aaca36c1 100644 --- a/crates/geom/src/spline.rs +++ b/crates/geom/src/spline.rs @@ -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, #[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 = fit.iter().map(|p| p.x).collect(); - let ys: Vec = 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 = (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 = pts.iter().map(|q| q.x).collect(); - let ys: Vec = 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 = 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 { 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 { +pub(crate) fn basis(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec { 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 { /// Gaussian elimination with partial pivoting. #[allow(clippy::needless_range_loop)] -fn solve(mut a: Vec>, mut b: Vec) -> Option> { +pub(crate) fn solve(mut a: Vec>, mut b: Vec) -> Option> { 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()))?; diff --git a/crates/geom/src/spline_fit.rs b/crates/geom/src/spline_fit.rs new file mode 100644 index 00000000..6cc54ef4 --- /dev/null +++ b/crates/geom/src/spline_fit.rs @@ -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 { + 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, + /// Unit tangent direction at the last fit point. + #[serde(default)] + pub end_tangent: Option, + /// 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| 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 = 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| 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 { + 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, d1: Option) -> Option { + 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>| { + 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 = 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) { + 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 { + 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 { + 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, d1: Option, tol: f64) -> Option { + 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, d1: Option) -> Option { + 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> = 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 = (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 = fixed.into_iter().map(|f| f.or_else(|| solved.next().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() }) +} + +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 = pts.iter().map(|q| q.x).collect(); + let ys: Vec = 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 = 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 { + 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> = 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 = (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 = 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))); + } +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index e8172dc3..500ac21c 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -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) } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 5c12f1d4..c3c8c94d 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -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); } diff --git a/crates/io/tests/spline_fit_data.rs b/crates/io/tests/spline_fit_data.rs new file mode 100644 index 00000000..d709701d --- /dev/null +++ b/crates/io/tests/spline_fit_data.rs @@ -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 = (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); +}