mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
3ffe1ca2af
commit
a395ec3d2d
15 files changed
+1173
-147
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ mod pline_opts;
|
||||
mod props;
|
||||
mod qselect;
|
||||
mod settings;
|
||||
mod spline_opts;
|
||||
mod table;
|
||||
mod trimextend;
|
||||
mod utility;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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()))?;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user