feat(modify): PEDIT Edit vertex Break/Straighten/Tangent/Regen, SPLINEDIT fit data and vertex options, closed-spline fit tolerance (part of #554) (#558)

Squash of PR #558 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 13:12:46 -04:00
1 parent d0dd5cfe16
commit d2fda54e8a
6 files changed
+1514 -183

No files matched your search

+67 -179
View File
@@ -26,13 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["pe"])
.params(
"{handle | handles, option: close|open|join|width|fit|spline|decurve|reverse|ltypegen|vertex, width?, handles2? (join), fuzz?, \
action?: move|insert|straighten|width, index?, to?, startWidth?, endWidth?, convert?: bool}",
action?: move|insert|straighten|break|width|regen, index?, index2? (straighten, break), to?, startWidth?, endWidth?, \
tangents?: [{index, angle (deg) | direction}] (fit), convert?: bool}",
)
.interactive(|_| Ok(Box::new(PeditM::default()))),
CommandSpec::new("splinedit", "Spline", run_splinedit)
.menu(&["Modify", "Object", "Spline"])
.alias(&["spe"])
.params("{handle, option: close|open|reverse|refit|purge|polyline|move, fit?, index?, to?, precision?}")
.params(
"{handle, option: close|open|reverse|refit|purge|polyline|move|fitmove|fitadd|fitdelete|kink|tangents|tolerance|vertexmove|vertexdelete|weight, \
fit?, index?, to?, before?: bool (fitadd), at? (kink), start?, end? (tangent directions; null = default), tolerance?, weight?, precision?}",
)
.interactive(|_| Ok(Box::new(SplineditM::default()))),
CommandSpec::new("lengthen", "Lengthen", run_lengthen)
.menu(&["Modify", "Lengthen"])
@@ -110,8 +114,9 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option<LwPolyline> {
}
}
/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex.
pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex,
/// following the tangent directions given per vertex (Edit vertex Tangent).
pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool, tans: &[Option<Vec2>]) -> Vec<PolyVertex> {
let pts: Vec<Vec2> = vs.iter().map(|v| v.p).collect();
let n = pts.len();
if n < 3 {
@@ -133,6 +138,9 @@ pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
};
// Open ends: mirror the neighbouring tangent across the chord for a natural end.
let tan_at = |i: usize| -> Vec2 {
if let Some(Some(t)) = tans.get(i) {
return *t;
}
if closed || (i != 0 && i + 1 != n) {
return tangent(i);
}
@@ -290,7 +298,7 @@ fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value
v.end_width = 0.0;
}
}
"fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed),
"fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed, &pedit_vertex::tangent_param(p, pl.vertices.len())?),
"spline" | "s" => {
let segs = s.doc()?.header.i64("SPLINESEGS", 8).clamp(1, 64) as usize;
pl.vertices = spline_vertices(&pl.vertices, pl.closed, segs);
@@ -333,10 +341,16 @@ fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value
pl.vertices.insert(i + 1, PolyVertex::new(to));
}
"straighten" => {
if let Some(v) = pl.vertices.get_mut(i) {
v.bulge = 0.0;
}
let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize);
pedit_vertex::straighten(&mut pl, i, j)?;
}
"break" => {
let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize);
let hs = pedit_vertex::break_poly(s, h, &pl, i, j)?;
return Ok(json!({ "handle": h.hex(), "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }));
}
// The display is always current; tangents only exist within an interactive PEDIT.
"regen" => {}
"width" => {
let sw = f64_or(p, "startWidth", 0.0).max(0.0);
let ew = f64_or(p, "endWidth", sw).max(0.0);
@@ -345,7 +359,7 @@ fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value
v.end_width = ew;
}
}
_ => return Err(bad("pedit", "action must be move, insert, straighten or width")),
_ => return Err(bad("pedit", "action must be move, insert, straighten, break, width or regen")),
}
}
_ => return Err(bad("pedit", "unknown option")),
@@ -380,8 +394,13 @@ enum PPhase {
VInsert,
VWidthStart,
VWidthEnd,
/// The second marker of Break / Straighten.
VMark,
VTangent,
}
mod pedit_vertex;
#[derive(Default)]
struct PeditM {
phase: PPhase,
@@ -391,6 +410,13 @@ struct PeditM {
join: SelectPhase,
vertex: usize,
vwidth: f64,
/// Break / Straighten: the second marked vertex, and which of the two runs.
mark2: usize,
breaking: bool,
/// Edit vertex Tangent directions by vertex position, used by Fit.
tangents: Vec<(Vec2, Vec2)>,
/// Pieces split off by Break, with the history length after it (erased again by Undo).
split_off: Vec<(usize, Vec<Handle>)>,
}
impl PeditM {
@@ -466,6 +492,9 @@ impl Interactive for PeditM {
PPhase::VInsert => Prompt::new("Specify location for new vertex", Accept::POINT),
PPhase::VWidthStart => Prompt::new("Specify starting width for next segment", Accept::NUMBER),
PPhase::VWidthEnd => Prompt::new("Specify ending width for next segment", Accept::NUMBER).default(format!("{:.4}", self.vwidth)),
PPhase::VMark => Prompt::new("Enter an option", curves::KW).kw(&["Next", "Previous", "Go", "eXit"]).default("N"),
PPhase::VTangent => Prompt::new("Specify direction of vertex tangent", Accept::POINT_OR_NUMBER)
.base_opt(self.poly(s).and_then(|p| p.vertices.get(self.vertex).map(|v| v.p))),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
@@ -530,7 +559,7 @@ impl Interactive for PeditM {
self.vertex = 0;
self.phase = PPhase::Vertex;
}
"fit" | "f" => self.apply(s, "fit", json!({}))?,
"fit" | "f" => self.apply(s, "fit", json!({ "tangents": self.tangents_json(s) }))?,
"spline" | "s" => self.apply(s, "spline", json!({}))?,
"decurve" | "d" => {
// Within the session, Decurve restores the frame before Fit/Spline.
@@ -550,6 +579,7 @@ impl Interactive for PeditM {
"ltype gen" | "l" => self.apply(s, "ltypegen", json!({}))?,
"reverse" | "r" => self.apply(s, "reverse", json!({}))?,
"undo" | "u" => {
self.undo_split(s)?;
if let (Some(h), Some(k)) = (self.h, self.history.pop()) {
set_kind(s, h, k)?;
}
@@ -590,13 +620,18 @@ impl Interactive for PeditM {
"previous" | "p" => self.vertex = (self.vertex + n - 1) % n,
"move" | "m" => self.phase = PPhase::VMove,
"insert" | "i" => self.phase = PPhase::VInsert,
"straighten" | "s" => {
self.apply(s, "vertex", json!({ "action": "straighten", "index": self.vertex }))?;
"straighten" | "s" | "break" | "b" => {
self.breaking = k.starts_with('b');
self.mark2 = self.vertex;
self.phase = PPhase::VMark;
}
"tangent" | "t" => self.phase = PPhase::VTangent,
// The display is always current.
"regen" | "r" => {}
"width" | "w" => self.phase = PPhase::VWidthStart,
"exit" | "x" => self.phase = PPhase::Main,
"" => {}
other_k => s.echo(format!("{other_k}: not available yet")),
_ => return Err(other("Invalid option keyword.")),
}
Ok(Step::Continue)
}
@@ -620,6 +655,15 @@ impl Interactive for PeditM {
}
Ok(Step::Continue)
}
PPhase::VMark => {
let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() });
self.mark_input(s, &k)?;
Ok(Step::Continue)
}
PPhase::VTangent => {
self.tangent_input(s, &i)?;
Ok(Step::Continue)
}
PPhase::VWidthEnd => {
let ew = match &i {
Input::Text(t) => number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?,
@@ -632,15 +676,21 @@ impl Interactive for PeditM {
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if !matches!(self.phase, PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd) {
if !matches!(
self.phase,
PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd | PPhase::VMark | PPhase::VTangent
) {
return Vec::new();
}
let Some(v) = self.poly(s).and_then(|p| p.vertices.get(self.vertex).copied()) else { return Vec::new() };
let k = s.pixel_size() * 8.0;
let mut out = vec![line(v.p - Vec2::new(k, k), v.p + Vec2::new(k, k)), line(v.p - Vec2::new(k, -k), v.p + Vec2::new(k, -k))];
if matches!(self.phase, PPhase::VMove | PPhase::VInsert) {
if matches!(self.phase, PPhase::VMove | PPhase::VInsert | PPhase::VTangent) {
out.push(line(v.p, c));
}
if let (PPhase::VMark, Some(m)) = (self.phase, self.poly(s).and_then(|p| p.vertices.get(self.mark2).copied())) {
out.extend([line(m.p - Vec2::new(k, k), m.p + Vec2::new(k, k)), line(m.p - Vec2::new(k, -k), m.p + Vec2::new(k, -k))]);
}
out
}
}
@@ -649,170 +699,8 @@ impl Interactive for PeditM {
// SPLINEDIT
// =====================================================================================
fn spline_close(sp: &Spline) -> Spline {
if sp.closed {
return sp.clone();
}
let mut out = if sp.fit.len() >= 2 {
sp.refit(&sp.fit, true)
} else {
let mut c = sp.control.clone();
c.extend(sp.control.first().copied());
Spline::from_control(c, sp.degree)
};
out.closed = true;
out
}
fn spline_open(sp: &Spline) -> Spline {
if !sp.closed {
return sp.clone();
}
let trim = |v: &[Vec2]| -> Vec<Vec2> {
let mut v = v.to_vec();
if v.len() > 2 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) {
v.pop();
}
v
};
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 {
let (lo, hi) = sp.domain();
let n = (precision.clamp(1, 99) * sp.control.len().max(2)).min(MAX_GEN);
let vs = (0..=n).map(|i| PolyVertex::new(sp.eval(lo + (hi - lo) * i as f64 / n as f64))).collect::<Vec<_>>();
let mut vs = vs;
if sp.closed && vs.len() > 2 {
vs.pop();
}
LwPolyline { vertices: vs, closed: sp.closed, const_width: 0.0, elevation: 0.0, plinegen: false }
}
fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("splinedit", "`handle` is required"))?;
let e = curves::entity(s, h)?;
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let EntityKind::Spline(sp) = &e.kind else { return Err(other("Object selected is not a spline.")) };
let opt = str_param(p, "option").ok_or_else(|| bad("splinedit", "`option` is required"))?.to_ascii_lowercase();
let k = match opt.as_str() {
"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(), 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(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" => {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("splinedit", "`index` is required"))? as usize;
let to = point_req("splinedit", p, "to")?;
let mut n = sp.clone();
if !n.fit.is_empty() {
let f = n.fit.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?;
*f = to;
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;
}
EntityKind::Spline(n)
}
_ => return Err(bad("splinedit", "unknown option")),
};
set_kind(s, h, k)?;
Ok(json!({ "handle": h.hex() }))
}
#[derive(Default)]
struct SplineditM {
h: Option<Handle>,
fit_menu: bool,
history: Vec<EntityKind>,
}
impl Interactive for SplineditM {
fn name(&self) -> &'static str {
"SPLINEDIT"
}
fn prompt(&self, s: &Session) -> Prompt {
let Some(h) = self.h else { return Prompt::new("Select spline", Accept::POINT) };
if self.fit_menu {
return Prompt::new("Enter a fit data option", curves::KW)
.kw(&["Add", "Close", "Delete", "Kink", "Move", "Purge", "Tangents", "toLerance", "eXit"])
.default("eXit");
}
let closed = s.doc().ok().and_then(|d| d.entity(h)).is_some_and(|e| matches!(&e.kind, EntityKind::Spline(sp) if sp.closed));
Prompt::new("Enter an option", curves::KW)
.kw(&[if closed { "Open" } else { "Close" }, "Join", "Fit data", "Edit vertex", "convert to Polyline", "Reverse", "Undo", "eXit"])
.default("eXit")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(h) = self.h else {
return match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
if !matches!(curves::entity(s, h)?.kind, EntityKind::Spline(_)) {
return Err(other("Object selected is not a spline."));
}
self.h = Some(h);
s.set_selection(vec![h]);
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
};
let k = match &i {
Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(),
Input::Enter => "exit".into(),
_ => return Ok(Step::Continue),
};
let mut apply = |s: &mut Session, opt: &str| -> Result<()> {
let before = curves::entity(s, h)?.kind;
run_splinedit(s, &json!({ "handle": h.hex(), "option": opt }))?;
self.history.push(before);
Ok(())
};
if self.fit_menu {
match k.as_str() {
"purge" | "p" => apply(s, "purge")?,
"close" | "c" => apply(s, "close")?,
"exit" | "x" => {}
_ => s.echo(format!("{k}: not available yet")),
}
self.fit_menu = false;
return Ok(Step::Continue);
}
match k.as_str() {
"close" | "c" => apply(s, "close")?,
"open" | "o" => apply(s, "open")?,
"reverse" | "r" => apply(s, "reverse")?,
"convert to polyline" | "p" => {
apply(s, "polyline")?;
return Ok(Step::Done);
}
"fit data" | "f" => self.fit_menu = true,
"undo" | "u" => {
if let Some(k) = self.history.pop() {
set_kind(s, h, k)?;
}
}
"exit" | "x" => {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
_ => s.echo(format!("{k}: not available yet")),
}
Ok(Step::Continue)
}
}
mod splinedit;
use splinedit::{SplineditM, run_splinedit};
// =====================================================================================
// LENGTHEN
@@ -0,0 +1,166 @@
//! PEDIT Edit vertex: Break and Straighten between two marked vertices, Tangent directions
//! that Fit honours, and Regen.
use super::*;
fn ordered(pl: &LwPolyline, i: usize, j: usize) -> Result<(usize, usize)> {
let n = pl.vertices.len();
if i >= n || j >= n {
return Err(bad("pedit", "vertex index out of range"));
}
Ok((i.min(j), i.max(j)))
}
/// Straighten: drop the vertices between `i` and `j` and make the segment between them
/// straight (the same vertex twice straightens the segment that follows it).
pub(super) fn straighten(pl: &mut LwPolyline, i: usize, j: usize) -> Result<()> {
let (lo, hi) = ordered(pl, i, j)?;
if hi > lo + 1 {
pl.vertices.drain(lo + 1..hi);
}
if let Some(v) = pl.vertices.get_mut(lo) {
v.bulge = 0.0;
}
Ok(())
}
/// Break: remove the segments between vertices `i` and `j`. An open polyline splits in two (a
/// piece with fewer than two vertices is dropped); a closed one opens, keeping the part from the
/// later vertex round to the earlier one. Returns the handles left (the first is `h`).
pub(super) fn break_poly(s: &mut Session, h: Handle, pl: &LwPolyline, i: usize, j: usize) -> Result<Vec<Handle>> {
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let (lo, hi) = ordered(pl, i, j)?;
let n = pl.vertices.len();
let open = |mut vs: Vec<PolyVertex>| {
if let Some(v) = vs.last_mut() {
*v = PolyVertex::new(v.p);
}
LwPolyline { vertices: vs, closed: false, ..pl.clone() }
};
let pieces: Vec<LwPolyline> = if pl.closed {
let vs = pl.vertices.get(hi..).unwrap_or_default().iter().chain(pl.vertices.get(..=lo).unwrap_or_default()).copied().collect();
vec![open(vs)]
} else {
if lo == hi && (lo == 0 || lo + 1 == n) {
return Err(other("Cannot break a polyline at its end vertex."));
}
[pl.vertices.get(..=lo), pl.vertices.get(hi..)].into_iter().flatten().filter(|v| v.len() >= 2).map(|v| open(v.to_vec())).collect()
};
let mut it = pieces.into_iter();
let first = it.next().ok_or_else(|| other("Breaking there would remove the whole polyline."))?;
let e = curves::entity(s, h)?;
let space = s.doc()?.space_of(h).ok_or_else(|| other("No such object."))?;
set_kind(s, h, EntityKind::LwPolyline(first))?;
let mut out = vec![h];
for p in it {
out.push(push_entity(s, &space, e.common.clone(), EntityKind::LwPolyline(p))?);
}
Ok(out)
}
/// Tangent directions for Fit from `tangents: [{index, angle (degrees) | direction: [dx, dy]}]`,
/// one entry per vertex (`None` = the direction Fit works out itself).
pub(super) fn tangent_param(p: &Value, n: usize) -> Result<Vec<Option<Vec2>>> {
let mut out = vec![None; n];
let Some(list) = p.get("tangents").filter(|v| !v.is_null()) else { return Ok(out) };
let list = list.as_array().ok_or_else(|| bad("pedit", "`tangents` must be an array of {index, angle | direction}"))?;
for t in list {
let i = t.get("index").and_then(Value::as_u64).ok_or_else(|| bad("pedit", "each tangent needs an `index`"))? as usize;
let d = match (t.get("direction").and_then(point_value), t.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite())) {
(Some(d), _) => d,
(None, Some(a)) => Vec2::from_angle(a.to_radians()),
_ => return Err(bad("pedit", "each tangent needs an `angle` (degrees) or a `direction`")),
};
if d.len() <= 1e-12 {
return Err(bad("pedit", "a tangent direction can't be zero"));
}
*out.get_mut(i).ok_or_else(|| bad("pedit", "tangent vertex index out of range"))? = Some(d.normalized());
}
Ok(out)
}
impl PeditM {
/// Next / Previous / Go / eXit with the second marker of Break and Straighten.
pub(super) fn mark_input(&mut self, s: &mut Session, k: &str) -> Result<()> {
let n = self.poly(s).map(|p| p.vertices.len()).unwrap_or(0).max(1);
match k {
"next" | "n" => self.mark2 = (self.mark2 + 1) % n,
"previous" | "p" => self.mark2 = (self.mark2 + n - 1) % n,
"go" | "g" if self.breaking => {
let h = self.h.ok_or_else(|| other("No polyline."))?;
let pl = self.poly(s).ok_or_else(|| other("No polyline."))?;
self.snapshot(s);
match break_poly(s, h, &pl, self.vertex, self.mark2) {
// Undoing the break erases the piece split off again.
Ok(hs) => self.split_off.push((self.history.len(), hs.into_iter().skip(1).collect())),
Err(e) => {
self.history.pop();
return Err(e);
}
}
let len = self.poly(s).map(|p| p.vertices.len()).unwrap_or(1);
self.vertex = self.vertex.min(self.mark2).min(len.saturating_sub(1));
self.phase = PPhase::Vertex;
}
"go" | "g" => {
self.apply(s, "vertex", json!({ "action": "straighten", "index": self.vertex, "index2": self.mark2 }))?;
let len = self.poly(s).map(|p| p.vertices.len()).unwrap_or(1);
self.vertex = self.vertex.min(self.mark2).min(len.saturating_sub(1));
self.phase = PPhase::Vertex;
}
"exit" | "x" => self.phase = PPhase::Vertex,
"" => {}
_ => return Err(other("Invalid option keyword.")),
}
Ok(())
}
/// A tangent direction for the marked vertex: a point (direction from the vertex) or an angle.
pub(super) fn tangent_input(&mut self, s: &mut Session, i: &Input) -> Result<()> {
let v = self.poly(s).and_then(|p| p.vertices.get(self.vertex).map(|v| v.p)).ok_or_else(|| other("No vertex."))?;
let d = match i {
Input::Point(p) => *p - v,
Input::Text(t) => Vec2::from_angle(s.angle_settings().direction(t).ok_or_else(|| other("Requires a point or an angle."))?),
Input::Enter => {
self.phase = PPhase::Vertex;
return Ok(());
}
_ => return Ok(()),
};
if d.len() <= 1e-12 {
return Err(other("Specify a point away from the vertex."));
}
self.tangents.retain(|(p, _)| !p.near(v, 1e-9));
self.tangents.push((v, d.normalized()));
self.phase = PPhase::Vertex;
Ok(())
}
/// The tangents set in this session for the polyline's current vertices (JSON `tangents`).
pub(super) fn tangents_json(&self, s: &Session) -> Value {
let Some(pl) = self.poly(s) else { return json!([]) };
let list: Vec<Value> = pl
.vertices
.iter()
.enumerate()
.filter_map(|(i, v)| self.tangents.iter().find(|(p, _)| p.near(v.p, 1e-9)).map(|(_, d)| json!({ "index": i, "direction": [d.x, d.y] })))
.collect();
Value::Array(list)
}
/// Undo of a Break also erases the pieces it split off.
pub(super) fn undo_split(&mut self, s: &mut Session) -> Result<()> {
if let Some((at, hs)) = self.split_off.last()
&& *at == self.history.len()
{
let d = s.doc_mut()?;
for h in hs {
d.remove_entity(*h);
}
self.split_off.pop();
}
Ok(())
}
}
+697
View File
@@ -0,0 +1,697 @@
//! SPLINEDIT: Close/Open, Fit data (Add, Close/Open, Delete, Kink, Move, Purge, Tangents,
//! toLerance), Edit vertex (Delete, Move, Weight), convert to Polyline, Reverse and Undo.
//!
//! Fit-point edits rebuild the curve from the edited fit points with the spline's knot
//! parametrisation, end tangents and fit tolerance. Control-point edits change the curve
//! directly and drop the fit data, as the curve no longer follows it.
use super::*;
/// The spline after a JSON SPLINEDIT option (`option` plus that option's parameters).
pub(super) fn run_splinedit(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("splinedit", "`handle` is required"))?;
let e = curves::entity(s, h)?;
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let EntityKind::Spline(sp) = &e.kind else { return Err(other("Object selected is not a spline.")) };
let opt = str_param(p, "option").ok_or_else(|| bad("splinedit", "`option` is required"))?.to_ascii_lowercase();
let k = edit(sp, &opt, p)?;
set_kind(s, h, k)?;
Ok(json!({ "handle": h.hex() }))
}
fn index(p: &Value) -> Result<usize> {
p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad("splinedit", "`index` is required"))
}
/// An optional direction: absent or null is `None` (the system default).
fn dir_param(p: &Value, key: &str) -> Result<Option<Vec2>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let d = point_value(v).filter(|d| d.len() > 1e-12).ok_or_else(|| bad("splinedit", format!("`{key}` must be a direction [dx, dy]")))?;
Ok(Some(d.normalized()))
}
}
}
fn edit(sp: &Spline, opt: &str, p: &Value) -> Result<EntityKind> {
let spline = |s: Spline| Ok(EntityKind::Spline(s));
match opt {
"close" => spline(spline_close(sp)),
"open" => spline(spline_open(sp)),
"reverse" => spline(curves::reverse_spline(sp)),
"purge" => spline(without_fit(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."));
}
spline(sp.refit(&fit, sp.closed))
}
"polyline" => {
let precision = p.get("precision").and_then(Value::as_u64).unwrap_or(10).min(99) as usize;
Ok(EntityKind::LwPolyline(spline_to_poly(sp, precision)))
}
"move" => {
let (i, to) = (index(p)?, point_req("splinedit", p, "to")?);
if sp.fit.is_empty() { spline(move_control(sp, i, to)?) } else { spline(move_fit(sp, i, to)?) }
}
"fitmove" => spline(move_fit(sp, index(p)?, point_req("splinedit", p, "to")?)?),
"fitadd" => spline(add_fit(sp, index(p)?, point_req("splinedit", p, "to")?, bool_or(p, "before", false))?),
"fitdelete" => spline(delete_fit(sp, index(p)?)?),
"kink" => spline(kink(sp, point_req("splinedit", p, "at")?)?),
"tangents" => spline(tangents(sp, dir_param(p, "start")?, dir_param(p, "end")?)?),
"tolerance" => {
let tol = f64_req("splinedit", p, "tolerance")?;
spline(tolerance(sp, tol)?)
}
"vertexmove" => spline(move_control(sp, index(p)?, point_req("splinedit", p, "to")?)?),
"vertexdelete" => spline(delete_control(sp, index(p)?)?),
"weight" => spline(set_weight(sp, index(p)?, f64_req("splinedit", p, "weight")?)?),
_ => Err(bad("splinedit", "unknown option")),
}
}
// ---------------- close / open / convert ----------------
fn spline_close(sp: &Spline) -> Spline {
if sp.closed {
return sp.clone();
}
let mut out = if sp.fit.len() >= 2 {
sp.refit(&sp.fit, true)
} else {
let mut c = sp.control.clone();
c.extend(sp.control.first().copied());
Spline::from_control(c, sp.degree)
};
out.closed = true;
out
}
fn spline_open(sp: &Spline) -> Spline {
if !sp.closed {
return sp.clone();
}
let trim = |v: &[Vec2]| -> Vec<Vec2> {
let mut v = v.to_vec();
if v.len() > 2 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) {
v.pop();
}
v
};
if sp.fit.len() >= 2 { sp.refit(&trim(&sp.fit), false) } else { Spline::from_control(trim(&sp.control), sp.degree) }
}
fn spline_to_poly(sp: &Spline, precision: usize) -> LwPolyline {
let (lo, hi) = sp.domain();
let n = (precision.clamp(1, 99) * sp.control.len().max(2)).min(MAX_GEN);
let mut vs = (0..=n).map(|i| PolyVertex::new(sp.eval(lo + (hi - lo) * i as f64 / n as f64))).collect::<Vec<_>>();
if sp.closed && vs.len() > 2 {
vs.pop();
}
LwPolyline { vertices: vs, closed: sp.closed, const_width: 0.0, elevation: 0.0, plinegen: false }
}
// ---------------- fit data ----------------
fn need_fit(sp: &Spline) -> Result<()> {
if sp.fit.is_empty() { Err(other("The spline has no fit data.")) } else { Ok(()) }
}
fn without_fit(sp: Spline) -> Spline {
Spline { fit: Vec::new(), fit_opts: Default::default(), ..sp }
}
fn move_fit(sp: &Spline, i: usize, to: Vec2) -> Result<Spline> {
need_fit(sp)?;
let mut fit = sp.fit.clone();
let f = fit.get_mut(i).ok_or_else(|| bad("splinedit", "fit point index out of range"))?;
// A kink (a repeated point) moves as one.
let at = *f;
for q in fit.iter_mut().skip(i).take_while(|q| q.near(at, 1e-12)) {
*q = to;
}
Ok(sp.refit(&fit, sp.closed))
}
/// The index after fit point `i` and its repeats (a kink).
fn after(fit: &[Vec2], i: usize) -> usize {
let Some(at) = fit.get(i) else { return fit.len() };
i + 1 + fit.iter().skip(i + 1).take_while(|q| q.near(*at, 1e-12)).count()
}
/// A new fit point after (or before) fit point `i`.
fn add_fit(sp: &Spline, i: usize, to: Vec2, before: bool) -> Result<Spline> {
need_fit(sp)?;
if i >= sp.fit.len() {
return Err(bad("splinedit", "fit point index out of range"));
}
if sp.fit.len() >= MAX_GEN {
return Err(other("Too many fit points."));
}
let mut fit = sp.fit.clone();
// After a kink means after both copies of its point.
let at = if before { i } else { after(&fit, i) };
fit.insert(at.min(fit.len()), to);
Ok(sp.refit(&fit, sp.closed))
}
/// Remove fit point `i` (both copies of a kink).
fn delete_fit(sp: &Spline, i: usize) -> Result<Spline> {
need_fit(sp)?;
let at = *sp.fit.get(i).ok_or_else(|| bad("splinedit", "fit point index out of range"))?;
let fit: Vec<Vec2> = sp.fit.iter().copied().filter(|q| !q.near(at, 1e-12)).collect();
let min = if sp.closed { 3 } else { 2 };
if fit.len() < min {
return Err(other(format!("A spline needs at least {min} fit points.")));
}
Ok(sp.refit(&fit, sp.closed))
}
/// A fit point with a corner (no tangent or curvature continuity) at the point of the curve
/// nearest `at`.
fn kink(sp: &Spline, at: Vec2) -> Result<Spline> {
need_fit(sp)?;
if sp.fit.len() + 2 > MAX_GEN {
return Err(other("Too many fit points."));
}
let t = curves::spline_param_of(sp, at);
let q = sp.eval(t);
let mut fit = sp.fit.clone();
if let Some(i) = fit.iter().position(|f| f.near(q, 1e-9)) {
let f = fit.get(i).copied().unwrap_or(q);
if fit.get(i + 1).is_none_or(|n| !n.near(f, 1e-12)) {
fit.insert(i, f);
}
} else {
// Next to the nearest fit point, on the side the parameters put it.
let i = nearest(&sp.fit, q).unwrap_or(0);
let ti = sp.fit.get(i).map_or(t, |f| curves::spline_param_of(sp, *f));
let at = if t > ti { after(&sp.fit, i) } else { i };
let at = at.max(usize::from(!sp.closed)).min(fit.len().saturating_sub(usize::from(!sp.closed)));
fit.splice(at..at, [q, q]);
}
Ok(sp.refit(&fit, sp.closed))
}
fn tangents(sp: &Spline, start: Option<Vec2>, end: Option<Vec2>) -> Result<Spline> {
need_fit(sp)?;
if sp.closed {
return Err(other("A closed spline has no end tangents."));
}
let mut s = sp.clone();
s.fit_opts.start_tangent = start;
s.fit_opts.end_tangent = end;
Ok(s.refit(&sp.fit, false))
}
fn tolerance(sp: &Spline, tol: f64) -> Result<Spline> {
need_fit(sp)?;
if !(tol.is_finite() && tol >= 0.0) {
return Err(other("Requires a non-negative fit tolerance."));
}
let mut s = sp.clone();
s.fit_opts.tolerance = tol;
Ok(s.refit(&sp.fit, sp.closed))
}
// ---------------- control points ----------------
/// The number of distinct control points: a periodic spline repeats its first `degree` at the
/// end, and a clamped closed one repeats its first at the end.
fn distinct(sp: &Spline) -> usize {
let n = sp.control.len();
if sp.is_periodic() {
n.saturating_sub(sp.degree)
} else if sp.closed && n > 2 && sp.control.first().zip(sp.control.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
n - 1
} else {
n
}
}
/// Indices holding distinct control point `j` (with its repeats).
fn copies(sp: &Spline, j: usize) -> Vec<usize> {
let (n, u) = (sp.control.len(), distinct(sp));
if j >= u {
return Vec::new();
}
let mut out = vec![j];
if u < n {
out.extend((j + u..n).step_by(u.max(1)));
}
out
}
fn move_control(sp: &Spline, j: usize, to: Vec2) -> Result<Spline> {
let ix = copies(sp, j);
if ix.is_empty() {
return Err(bad("splinedit", "control point index out of range"));
}
let mut s = without_fit(sp.clone());
for i in ix {
if let Some(c) = s.control.get_mut(i) {
*c = to;
}
}
Ok(s)
}
fn set_weight(sp: &Spline, j: usize, w: f64) -> Result<Spline> {
if !(w.is_finite() && w > 0.0) {
return Err(other("Requires a positive weight."));
}
let ix = copies(sp, j);
if ix.is_empty() {
return Err(bad("splinedit", "control point index out of range"));
}
let mut s = without_fit(sp.clone());
if s.weights.len() != s.control.len() {
s.weights = vec![1.0; s.control.len()];
}
for i in ix {
if let Some(x) = s.weights.get_mut(i) {
*x = w;
}
}
if s.weights.iter().all(|x| *x == 1.0) {
s.weights.clear();
}
Ok(s)
}
/// Remove distinct control point `j` and one knot near it, so the rest of the curve keeps its
/// shape as far as it can.
fn delete_control(sp: &Spline, j: usize) -> Result<Spline> {
let p = sp.degree;
let (n, u) = (sp.control.len(), distinct(sp));
if j >= u {
return Err(bad("splinedit", "control point index out of range"));
}
if !sp.is_valid() {
return Err(other("The spline is invalid."));
}
let mut s = without_fit(sp.clone());
let w = |s: &Spline, i: usize| s.weights.get(i).copied().unwrap_or(1.0);
if sp.is_periodic() {
if u <= p + 1 {
return Err(other(format!("A closed spline of degree {p} needs more than {} control points.", p + 1)));
}
// Breakpoints of one period; drop one near the point and extend periodically again.
let mut b: Vec<f64> = sp.knots.get(p..=p + u).ok_or_else(|| other("The spline is invalid."))?.to_vec();
let period = b.last().copied().unwrap_or(1.0) - b.first().copied().unwrap_or(0.0);
let r = ((j + p.div_ceil(2)) % u).clamp(1, u - 1);
b.remove(r);
let m = u - 1;
let knot = |i: isize| {
let (q, r) = (i.div_euclid(m as isize), i.rem_euclid(m as isize) as usize);
b.get(r).copied().unwrap_or(0.0) + q as f64 * period
};
s.knots = (0..m + 2 * p + 1).map(|i| knot(i as isize - p as isize)).collect();
let mut ctrl: Vec<Vec2> = sp.control.iter().take(u).copied().collect();
ctrl.remove(j);
let rational = !sp.weights.is_empty();
let mut ws: Vec<f64> = (0..u).map(|i| w(sp, i)).collect();
ws.remove(j);
for i in 0..p {
let (c, x) = (ctrl.get(i).copied().unwrap_or_default(), ws.get(i).copied().unwrap_or(1.0));
ctrl.push(c);
ws.push(x);
}
s.control = ctrl;
s.weights = if rational { ws } else { Vec::new() };
} else {
if n <= p + 1 {
return Err(other(format!("A spline of degree {p} needs at least {} control points.", p + 1)));
}
if u < n && (j == 0 || j + 1 >= n) {
return Err(other("The start point of a closed spline can't be deleted."));
}
s.control.remove(j);
if !s.weights.is_empty() {
s.weights.remove(j.min(s.weights.len() - 1));
}
s.knots.remove((j + p.div_ceil(2)).clamp(p + 1, n - 1));
}
if !s.is_valid() {
return Err(other("Cannot delete that control point."));
}
Ok(s)
}
// ---------------- interactive ----------------
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Sub {
#[default]
Main,
Precision,
Fit,
FitAddPick,
FitAddNew,
FitDelete,
FitKink,
FitMove,
FitStartTangent,
FitEndTangent,
FitTolerance,
Vertex,
VertexDelete,
VertexMove,
VertexWeight,
/// "Select point" from FitMove / VertexMove / VertexWeight (in `back`).
SelectPoint,
}
#[derive(Default)]
pub(super) struct SplineditM {
h: Option<Handle>,
sub: Sub,
back: Sub,
history: Vec<EntityKind>,
/// Marked fit point (Fit data) or distinct control point (Edit vertex).
mark: usize,
before: bool,
start_tangent: Option<Vec2>,
}
fn nearest(pts: &[Vec2], p: Vec2) -> Option<usize> {
pts.iter().enumerate().min_by(|a, b| a.1.dist(p).total_cmp(&b.1.dist(p))).map(|(i, _)| i)
}
impl SplineditM {
fn spline(&self, s: &Session) -> Option<Spline> {
match &s.doc().ok()?.entity(self.h?)?.kind {
EntityKind::Spline(sp) => Some(sp.clone()),
_ => None,
}
}
/// Points the marker walks: fit points in the fit data menus, else distinct control points.
fn marks(&self, s: &Session) -> Vec<Vec2> {
let Some(sp) = self.spline(s) else { return Vec::new() };
let fit = matches!(self.sub, Sub::FitMove | Sub::FitAddPick | Sub::FitDelete) || (self.sub == Sub::SelectPoint && self.back == Sub::FitMove);
if fit {
sp.fit.clone()
} else {
let u = distinct(&sp);
sp.control.into_iter().take(u).collect()
}
}
fn apply(&mut self, s: &mut Session, p: Value) -> Result<()> {
let h = self.h.ok_or_else(|| other("No spline."))?;
let before = curves::entity(s, h)?.kind;
let mut p = p;
if let Some(o) = p.as_object_mut() {
o.insert("handle".into(), json!(h.hex()));
}
run_splinedit(s, &p)?;
self.history.push(before);
Ok(())
}
/// Next / Previous / Select point / eXit at the marker prompts; `true` when handled.
fn step_mark(&mut self, s: &Session, k: &str) -> bool {
let n = self.marks(s).len().max(1);
match k {
"next" | "n" => self.mark = (self.mark + 1) % n,
"previous" | "p" => self.mark = (self.mark + n - 1) % n,
"select point" | "s" => {
self.back = self.sub;
self.sub = Sub::SelectPoint;
}
"exit" | "x" => self.sub = if self.sub == Sub::FitMove { Sub::Fit } else { Sub::Vertex },
_ => return false,
}
true
}
}
impl Interactive for SplineditM {
fn name(&self) -> &'static str {
"SPLINEDIT"
}
fn prompt(&self, s: &Session) -> Prompt {
let Some(sp) = self.spline(s) else { return Prompt::new("Select spline", Accept::POINT) };
let close = if sp.closed { "Open" } else { "Close" };
let marker = ["Next", "Previous", "Select point", "eXit"];
match self.sub {
Sub::Main => {
let mut kw = vec![close, "Join"];
if !sp.fit.is_empty() {
kw.push("Fit data");
}
kw.extend(["Edit vertex", "convert to Polyline", "Reverse", "Undo", "eXit"]);
Prompt::new("Enter an option", curves::KW).kw(&kw).default("eXit")
}
Sub::Precision => Prompt::new("Specify a precision", Accept::NUMBER).default("10"),
Sub::Fit => Prompt::new("Enter a fit data option", curves::KW)
.kw(&["Add", close, "Delete", "Kink", "Move", "Purge", "Tangents", "toLerance", "eXit"])
.default("eXit"),
Sub::FitAddPick | Sub::FitDelete => Prompt::new("Specify existing fit point <exit>", Accept::POINT),
Sub::FitAddNew => {
Prompt::new("Specify new fit point <exit>", Accept::POINT).kw(&["After", "Before"]).base_opt(sp.fit.get(self.mark).copied())
}
Sub::FitKink => Prompt::new("Specify new point on spline <exit>", Accept::POINT),
Sub::FitMove => Prompt::new("Specify new location", Accept::POINT).kw(&marker).default("N").base_opt(sp.fit.get(self.mark).copied()),
Sub::FitStartTangent => Prompt::new("Specify start tangent", Accept::POINT).kw(&["System default"]).base_opt(sp.fit.first().copied()),
Sub::FitEndTangent => Prompt::new("Specify end tangent", Accept::POINT).kw(&["System default"]).base_opt(sp.fit.last().copied()),
Sub::FitTolerance => Prompt::new("Enter fit tolerance", Accept::NUMBER).default(format!("{:.4}", sp.fit_opts.tolerance)),
Sub::Vertex => Prompt::new("Enter a vertex editing option", curves::KW)
.kw(&["Add", "Delete", "Elevate order", "Move", "Weight", "eXit"])
.default("eXit"),
Sub::VertexDelete => Prompt::new("Specify control point <exit>", Accept::POINT),
Sub::VertexMove => {
Prompt::new("Specify new location", Accept::POINT).kw(&marker).default("N").base_opt(sp.control.get(self.mark).copied())
}
Sub::VertexWeight => {
let w = sp.weights.get(self.mark).copied().unwrap_or(1.0);
Prompt::new(format!("Enter new weight (current = {w:.4})"), Accept::NUMBER).kw(&marker).default("N")
}
Sub::SelectPoint => Prompt::new(if self.back == Sub::FitMove { "Specify fit point" } else { "Specify control point" }, Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(h) = self.h else {
return match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
if !matches!(curves::entity(s, h)?.kind, EntityKind::Spline(_)) {
return Err(other("Object selected is not a spline."));
}
self.h = Some(h);
s.set_selection(vec![h]);
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
};
let k = match &i {
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
_ => None,
};
let kw = k.as_deref().unwrap_or("");
let point = match &i {
Input::Point(p) => Some(*p),
_ => None,
};
let enter = i == Input::Enter;
let sp = self.spline(s).ok_or_else(|| other("The spline was erased."))?;
match self.sub {
Sub::Main => match (enter, kw) {
(true, _) | (_, "exit" | "x") => {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
(_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?,
(_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?,
(_, "reverse" | "r") => self.apply(s, json!({ "option": "reverse" }))?,
(_, "convert to polyline" | "p") => self.sub = Sub::Precision,
(_, "fit data" | "f") if sp.fit.is_empty() => s.echo("The spline has no fit data."),
(_, "fit data" | "f") => self.sub = Sub::Fit,
(_, "edit vertex" | "e") => {
self.mark = 0;
self.sub = Sub::Vertex;
}
(_, "undo" | "u") => match self.history.pop() {
Some(k) => set_kind(s, h, k)?,
None => s.echo("All edits have been undone."),
},
(_, "") => {}
(_, k) => s.echo(format!("{k}: not available yet")),
},
Sub::Precision => {
let precision = match &i {
Input::Text(t) => number(t).filter(|n| (0.0..=99.0).contains(n)).ok_or_else(|| other("Requires an integer between 0 and 99."))?,
_ => 10.0,
};
self.apply(s, json!({ "option": "polyline", "precision": precision.round() as u64 }))?;
s.set_selection(Vec::new());
return Ok(Step::Done);
}
Sub::Fit => match (enter, kw) {
(true, _) | (_, "exit" | "x") => self.sub = Sub::Main,
(_, "add" | "a") => self.sub = Sub::FitAddPick,
(_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?,
(_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?,
(_, "delete" | "d") => self.sub = Sub::FitDelete,
(_, "kink" | "k") => self.sub = Sub::FitKink,
(_, "move" | "m") => {
self.mark = 0;
self.sub = Sub::FitMove;
}
(_, "purge" | "p") => {
self.apply(s, json!({ "option": "purge" }))?;
self.sub = Sub::Main;
}
(_, "tangents" | "t") if sp.closed => s.echo("A closed spline has no end tangents."),
(_, "tangents" | "t") => self.sub = Sub::FitStartTangent,
(_, "tolerance" | "l") => self.sub = Sub::FitTolerance,
(_, "") => {}
_ => return Err(other("Invalid option keyword.")),
},
Sub::FitAddPick => match point {
Some(p) => {
self.mark = nearest(&sp.fit, p).ok_or_else(|| other("The spline has no fit data."))?;
self.before = false;
self.sub = Sub::FitAddNew;
}
None if enter => self.sub = Sub::Fit,
None => {}
},
Sub::FitAddNew => match (point, kw) {
(Some(p), _) => {
self.apply(s, json!({ "option": "fitadd", "index": self.mark, "to": [p.x, p.y], "before": self.before }))?;
// Keep adding after the new point.
if let Some(sp) = self.spline(s) {
self.mark = nearest(&sp.fit, p).unwrap_or(self.mark);
}
self.before = false;
}
(None, "after" | "a") => self.before = false,
(None, "before" | "b") => self.before = true,
_ if enter => self.sub = Sub::FitAddPick,
_ => {}
},
Sub::FitDelete => match point {
Some(p) => {
let i = nearest(&sp.fit, p).ok_or_else(|| other("The spline has no fit data."))?;
self.apply(s, json!({ "option": "fitdelete", "index": i }))?;
}
None if enter => self.sub = Sub::Fit,
None => {}
},
Sub::FitKink => match point {
Some(p) => self.apply(s, json!({ "option": "kink", "at": [p.x, p.y] }))?,
None if enter => self.sub = Sub::Fit,
None => {}
},
Sub::FitMove | Sub::VertexMove => match point {
Some(p) => {
let opt = if self.sub == Sub::FitMove { "fitmove" } else { "vertexmove" };
self.apply(s, json!({ "option": opt, "index": self.mark, "to": [p.x, p.y] }))?;
}
None if enter => {
self.step_mark(s, "next");
}
None => {
if !self.step_mark(s, kw) && !kw.is_empty() {
return Err(other("Invalid option keyword."));
}
}
},
Sub::FitStartTangent | Sub::FitEndTangent => {
let end = self.sub == Sub::FitEndTangent;
let from = if end { sp.fit.last() } else { sp.fit.first() }.copied().unwrap_or_default();
let dir = match (point, kw) {
(Some(p), _) => {
let d = p - from;
if d.len() <= 1e-12 {
return Err(other("The tangent needs a point away from the end point."));
}
Some(d)
}
(None, "system default" | "s") => None,
_ if enter => None,
_ => return Ok(Step::Continue),
};
if end {
let start = self.start_tangent.map(|d| [d.x, d.y]);
self.apply(s, json!({ "option": "tangents", "start": start, "end": dir.map(|d| [d.x, d.y]) }))?;
self.sub = Sub::Fit;
} else {
self.start_tangent = dir;
self.sub = Sub::FitEndTangent;
}
}
Sub::FitTolerance => {
if let Input::Text(t) = &i {
let tol = number(t).filter(|v| *v >= 0.0).ok_or_else(|| other("Requires a non-negative fit tolerance."))?;
self.apply(s, json!({ "option": "tolerance", "tolerance": tol }))?;
}
self.sub = Sub::Fit;
}
Sub::Vertex => match (enter, kw) {
(true, _) | (_, "exit" | "x") => self.sub = Sub::Main,
(_, "delete" | "d") => self.sub = Sub::VertexDelete,
(_, "move" | "m") => {
self.mark = 0;
self.sub = Sub::VertexMove;
}
(_, "weight" | "w") => {
self.mark = 0;
self.sub = Sub::VertexWeight;
}
(_, "") => {}
(_, k) => s.echo(format!("{k}: not available yet")),
},
Sub::VertexDelete => match point {
Some(p) => {
let u = distinct(&sp);
let j = nearest(sp.control.get(..u).unwrap_or_default(), p).ok_or_else(|| other("The spline has no control points."))?;
self.apply(s, json!({ "option": "vertexdelete", "index": j }))?;
}
None if enter => self.sub = Sub::Vertex,
None => {}
},
Sub::VertexWeight => match &i {
Input::Text(t) if number(t).is_some() => {
let w = number(t).filter(|w| *w > 0.0).ok_or_else(|| other("Requires a positive weight."))?;
self.apply(s, json!({ "option": "weight", "index": self.mark, "weight": w }))?;
}
Input::Enter => {
self.step_mark(s, "next");
}
_ => {
if !self.step_mark(s, kw) && !kw.is_empty() {
return Err(other("Invalid option keyword."));
}
}
},
Sub::SelectPoint => {
if let Some(p) = point {
self.sub = self.back;
self.mark = nearest(&self.marks(s), p).unwrap_or(0);
} else if enter {
self.sub = self.back;
}
}
}
Ok(Step::Continue)
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
let marked = matches!(self.sub, Sub::FitMove | Sub::VertexMove | Sub::VertexWeight | Sub::FitAddNew);
let Some(v) = marked.then(|| self.marks(s).get(self.mark).copied()).flatten() else { return Vec::new() };
let k = s.pixel_size() * 8.0;
let mut out = vec![line(v - Vec2::new(k, k), v + Vec2::new(k, k)), line(v - Vec2::new(k, -k), v + Vec2::new(k, -k))];
if matches!(self.sub, Sub::FitMove | Sub::VertexMove | Sub::FitAddNew) {
out.push(line(v, c));
}
out
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ fn splinedit_json_and_command_line() {
assert!(matches!(last(&s), EntityKind::Spline(x) if x.closed));
s.cmdline("o r u").unwrap();
assert!(matches!(last(&s), EntityKind::Spline(x) if !x.closed && near(x.control[0], 0.0, 0.0)));
s.cmdline("f p p").unwrap();
s.cmdline("f p 4").unwrap();
assert!(s.running.is_none());
assert!(matches!(last(&s), EntityKind::LwPolyline(_)));
}
+300
View File
@@ -0,0 +1,300 @@
//! PEDIT Edit vertex Break / Straighten / Tangent / Regen and the SPLINEDIT Fit data and Edit
//! vertex options, at the command line and in the JSON form (#554).
use cadcraft_doc::{EntityKind, LwPolyline};
use cadcraft_engine::Session;
use cadcraft_geom::{Spline, Vec2};
use serde_json::{Value, json};
fn h(v: &Value) -> String {
v["handle"].as_str().unwrap().to_string()
}
fn kind(s: &Session, hex: &str) -> EntityKind {
let h = cadcraft_doc::Handle::parse_hex(hex).unwrap();
s.doc().unwrap().entity(h).unwrap().kind.clone()
}
fn polys(s: &Session) -> Vec<LwPolyline> {
s.doc()
.unwrap()
.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::LwPolyline(p) => Some(p.clone()),
_ => None,
})
.collect()
}
fn spline(s: &Session, hex: &str) -> Spline {
let EntityKind::Spline(sp) = kind(s, hex) else { panic!("not a spline") };
sp
}
fn pts(p: &LwPolyline) -> Vec<(f64, f64)> {
p.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()
}
fn pline(s: &mut Session, points: Value, closed: bool) -> String {
h(&s.execute("pline", &json!({ "points": points, "closed": closed })).unwrap())
}
/// Start direction of the segment that leaves vertex `v`.
fn leaving(p: &LwPolyline, at: Vec2) -> Vec2 {
let i = p.vertices.iter().position(|v| v.p.near(at, 1e-9)).unwrap();
let (a, b) = (p.vertices[i], p.vertices[(i + 1) % p.vertices.len()]);
let chord = (b.p - a.p).normalized();
let turn = -2.0 * a.bulge.atan();
Vec2::new(chord.x * turn.cos() - chord.y * turn.sin(), chord.x * turn.sin() + chord.y * turn.cos())
}
fn has_corner(sp: &Spline) -> bool {
let (lo, hi) = sp.domain();
sp.knots.iter().any(|k| *k > lo && *k < hi && sp.knots.iter().filter(|x| (*x - k).abs() < 1e-12).count() >= sp.degree)
}
// ---------------- PEDIT ----------------
#[test]
fn edit_vertex_break_splits_an_open_polyline_and_undo_joins_it() {
let mut s = Session::new();
pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 10]]), false);
s.cmdline("pedit 5,0 e n b n g").unwrap();
let mut ps = polys(&s);
ps.sort_by(|a, b| a.vertices.len().cmp(&b.vertices.len()).then(a.vertices[0].p.x.total_cmp(&b.vertices[0].p.x)));
assert_eq!(ps.len(), 2, "{}", s.prompt_text());
assert_eq!(pts(&ps[0]), vec![(0.0, 0.0), (10.0, 0.0)]);
assert_eq!(pts(&ps[1]), vec![(10.0, 10.0), (0.0, 10.0)]);
assert!(s.prompt_text().contains("vertex editing option"), "back at the vertex menu: {}", s.prompt_text());
s.cmdline("x u").unwrap();
let ps = polys(&s);
assert_eq!(ps.len(), 1, "undo erases the split-off piece");
assert_eq!(ps[0].vertices.len(), 4);
s.cmdline("").unwrap();
assert!(s.running.is_none());
}
#[test]
fn break_json_opens_a_closed_polyline_and_refuses_the_ends() {
let mut s = Session::new();
let r = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], [0, 10]]), true);
let out = s.execute("pedit", &json!({ "handle": r, "option": "vertex", "action": "break", "index": 1, "index2": 2 })).unwrap();
assert_eq!(out["handles"].as_array().unwrap().len(), 1);
let EntityKind::LwPolyline(p) = kind(&s, &r) else { panic!() };
assert!(!p.closed);
assert_eq!(pts(&p), vec![(10.0, 10.0), (0.0, 10.0), (0.0, 0.0), (10.0, 0.0)], "the segment 1-2 is gone");
let o = pline(&mut s, json!([[0, 0], [10, 0], [20, 0]]), false);
// The same vertex in the middle splits there.
let out = s.execute("pedit", &json!({ "handle": o, "option": "vertex", "action": "break", "index": 1 })).unwrap();
assert_eq!(out["handles"].as_array().unwrap().len(), 2);
let o2 = pline(&mut s, json!([[0, 0], [10, 0], [20, 0]]), false);
for (i, j) in [(0, 0), (2, 2), (0, 2)] {
assert!(s.execute("pedit", &json!({ "handle": o2, "option": "vertex", "action": "break", "index": i, "index2": j })).is_err(), "{i} {j}");
}
assert!(s.execute("pedit", &json!({ "handle": o2, "option": "vertex", "action": "break", "index": 0, "index2": 99 })).is_err());
let EntityKind::LwPolyline(p) = kind(&s, &o2) else { panic!() };
assert_eq!(p.vertices.len(), 3, "a refused break changes nothing");
}
#[test]
fn edit_vertex_straighten_removes_the_vertices_between_the_markers() {
let mut s = Session::new();
let p = pline(&mut s, json!([[0, 0], [5, 3], [10, 0], [15, 3], [20, 0]]), false);
s.cmdline("pedit 2.5,1.5 e s n n g x").unwrap();
s.cmdline("").unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &p) else { panic!() };
assert_eq!(pts(&pl), vec![(0.0, 0.0), (10.0, 0.0), (15.0, 3.0), (20.0, 0.0)]);
// JSON, and the same vertex twice straightens the following arc.
s.execute("pedit", &json!({ "handle": p, "option": "fit" })).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &p) else { panic!() };
assert!(pl.vertices[0].bulge != 0.0);
s.execute("pedit", &json!({ "handle": p, "option": "vertex", "action": "straighten", "index": 0 })).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &p) else { panic!() };
assert_eq!(pl.vertices[0].bulge, 0.0);
let n = pl.vertices.len();
s.execute("pedit", &json!({ "handle": p, "option": "vertex", "action": "straighten", "index": 3, "index2": 0 })).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &p) else { panic!() };
assert_eq!(pl.vertices.len(), n - 2, "vertices 1 and 2 are gone");
assert_eq!(pl.vertices[0].bulge, 0.0);
}
#[test]
fn edit_vertex_tangent_steers_fit() {
let mut s = Session::new();
let p = pline(&mut s, json!([[0, 0], [5, 5], [10, 0]]), false);
s.cmdline("pedit 2,2 e n t 90 x f").unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &p) else { panic!() };
assert!(leaving(&pl, Vec2::new(5.0, 5.0)).near(Vec2::Y, 1e-6), "{:?}", leaving(&pl, Vec2::new(5.0, 5.0)));
// A point gives the direction from the vertex; Regen changes nothing.
s.cmdline("d e n t 10,5 r x f").unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &p) else { panic!() };
assert!(leaving(&pl, Vec2::new(5.0, 5.0)).near(Vec2::X, 1e-6));
s.cmdline("").unwrap();
assert!(s.running.is_none());
// JSON: tangents with the fit option.
let q = pline(&mut s, json!([[0, 0], [5, 5], [10, 0]]), false);
s.execute("pedit", &json!({ "handle": q, "option": "fit", "tangents": [{ "index": 1, "angle": 90 }] })).unwrap();
let EntityKind::LwPolyline(pl) = kind(&s, &q) else { panic!() };
assert!(leaving(&pl, Vec2::new(5.0, 5.0)).near(Vec2::Y, 1e-6));
s.execute("pedit", &json!({ "handle": q, "option": "vertex", "action": "regen", "index": 0 })).unwrap();
for bad in [json!([{ "index": 9, "angle": 0 }]), json!([{ "index": 0 }]), json!([{ "index": 0, "direction": [0, 0] }]), json!("x")] {
assert!(s.execute("pedit", &json!({ "handle": q, "option": "fit", "tangents": bad })).is_err(), "{bad}");
}
}
// ---------------- SPLINEDIT ----------------
fn fit_spline(s: &mut Session) -> String {
h(&s.execute("spline", &json!({ "fit": [[0, 0], [2, 2], [4, 0], [6, 2]] })).unwrap())
}
#[test]
fn fit_data_options_at_the_command_line() {
let mut s = Session::new();
let sp = fit_spline(&mut s);
s.cmdline("splinedit 0,0 f a 2,2 3,1").unwrap();
assert_eq!(spline(&s, &sp).fit[2], Vec2::new(3.0, 1.0), "added after the picked fit point");
s.cmdline("").unwrap();
s.cmdline("").unwrap();
s.cmdline("d 3,1").unwrap();
assert_eq!(spline(&s, &sp).fit.len(), 4);
s.cmdline("").unwrap();
s.cmdline("m n 2,3 x").unwrap();
assert_eq!(spline(&s, &sp).fit[1], Vec2::new(2.0, 3.0));
s.cmdline("l 0.5").unwrap();
assert_eq!(spline(&s, &sp).fit_opts.tolerance, 0.5);
s.cmdline("t 0,5 s").unwrap();
let o = spline(&s, &sp).fit_opts;
assert!(o.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-9)) && o.end_tangent.is_none());
s.cmdline("l 0").unwrap();
s.cmdline("k 5,1").unwrap();
let k = spline(&s, &sp);
assert_eq!(k.fit.len(), 6, "a kink is a repeated fit point");
assert!(has_corner(&k));
s.cmdline("").unwrap();
s.cmdline("c").unwrap();
assert!(spline(&s, &sp).closed);
s.cmdline("o x u").unwrap();
assert!(spline(&s, &sp).closed, "Undo takes back the last edit");
s.cmdline("x").unwrap();
assert!(s.running.is_none());
}
#[test]
fn fit_data_json_options() {
let mut s = Session::new();
let sp = fit_spline(&mut s);
let run = |s: &mut Session, p: Value| {
let mut p = p;
p["handle"] = json!(sp);
s.execute("splinedit", &p)
};
run(&mut s, json!({ "option": "fitadd", "index": 0, "to": [1, 1] })).unwrap();
assert_eq!(spline(&s, &sp).fit[1], Vec2::new(1.0, 1.0));
run(&mut s, json!({ "option": "fitadd", "index": 0, "to": [-1, 0], "before": true })).unwrap();
assert_eq!(spline(&s, &sp).fit[0], Vec2::new(-1.0, 0.0));
run(&mut s, json!({ "option": "fitdelete", "index": 0 })).unwrap();
run(&mut s, json!({ "option": "fitmove", "index": 1, "to": [1, 2] })).unwrap();
assert_eq!(spline(&s, &sp).fit.len(), 5);
run(&mut s, json!({ "option": "tangents", "start": [1, 0], "end": [0, -3] })).unwrap();
assert_eq!(spline(&s, &sp).fit_opts.end_tangent, Some(-Vec2::Y));
run(&mut s, json!({ "option": "tangents", "start": null })).unwrap();
assert!(spline(&s, &sp).fit_opts.start_tangent.is_none());
run(&mut s, json!({ "option": "kink", "at": [2, 2] })).unwrap();
assert!(has_corner(&spline(&s, &sp)), "a kink at an existing fit point");
run(&mut s, json!({ "option": "fitdelete", "index": 3 })).unwrap();
assert!(!has_corner(&spline(&s, &sp)), "deleting the kink point removes both copies");
run(&mut s, json!({ "option": "tolerance", "tolerance": 0.2 })).unwrap();
for bad in [
json!({ "option": "fitadd", "index": 99, "to": [0, 0] }),
json!({ "option": "fitdelete", "index": 99 }),
json!({ "option": "tolerance", "tolerance": -1 }),
json!({ "option": "tangents", "start": [0, 0] }),
json!({ "option": "kink" }),
json!({ "option": "weight", "index": 0, "weight": 0 }),
json!({ "option": "weight", "index": 0, "weight": -2 }),
json!({ "option": "vertexmove", "index": 999, "to": [0, 0] }),
] {
assert!(run(&mut s, bad.clone()).is_err(), "{bad}");
}
// Two fit points left: no fewer.
let two = h(&s.execute("spline", &json!({ "fit": [[0, 0], [5, 5]] })).unwrap());
assert!(s.execute("splinedit", &json!({ "handle": two, "option": "fitdelete", "index": 0 })).is_err());
// Without fit data, and closed splines have no end tangents.
let cv = h(&s.execute("spline", &json!({ "control": [[0, 0], [2, 3], [4, 0], [6, 3]] })).unwrap());
assert!(s.execute("splinedit", &json!({ "handle": cv, "option": "fitadd", "index": 0, "to": [1, 1] })).is_err());
run(&mut s, json!({ "option": "close" })).unwrap();
assert!(run(&mut s, json!({ "option": "tangents", "start": [1, 0] })).is_err());
}
#[test]
fn closed_splines_honour_the_fit_tolerance() {
let mut s = Session::new();
let fit: Vec<[f64; 2]> = (0..24)
.map(|i| {
let a = i as f64 / 24.0 * std::f64::consts::TAU;
let r = 5.0 + if i % 2 == 0 { 0.02 } else { -0.02 };
[a.cos() * r, a.sin() * r]
})
.collect();
let sp = h(&s.execute("spline", &json!({ "fit": fit, "closed": true })).unwrap());
let exact = spline(&s, &sp);
assert!(exact.closed && exact.is_periodic());
s.execute("splinedit", &json!({ "handle": sp, "option": "tolerance", "tolerance": 0.1 })).unwrap();
let t = spline(&s, &sp);
assert!(t.is_periodic() && t.control.len() < exact.control.len(), "{} vs {}", t.control.len(), exact.control.len());
assert_eq!(t.fit_opts.tolerance, 0.1);
assert_eq!(t.fit.len(), 24);
}
#[test]
fn edit_vertex_move_weight_and_delete() {
let mut s = Session::new();
let sp = h(&s.execute("spline", &json!({ "control": [[0, 0], [2, 3], [4, 0], [6, 3], [8, 0]] })).unwrap());
s.cmdline("splinedit 0,0 e m n 2,4 x").unwrap();
assert_eq!(spline(&s, &sp).control[1], Vec2::new(2.0, 4.0));
s.cmdline("w n n 2").unwrap();
assert_eq!(spline(&s, &sp).weights[2], 2.0);
assert!(s.prompt_text().contains("current = 2.0000"), "{}", s.prompt_text());
s.cmdline("x d 8,0").unwrap();
assert_eq!(spline(&s, &sp).control.len(), 4);
assert!(spline(&s, &sp).is_valid());
// Degree 3 needs four control points.
s.cmdline("0,0").unwrap();
assert!(s.log.iter().any(|l| l.contains("at least 4 control points")), "{:?}", s.log.last());
assert_eq!(spline(&s, &sp).control.len(), 4);
s.cmdline("").unwrap();
s.cmdline("x p 2").unwrap();
assert!(s.running.is_none());
let EntityKind::LwPolyline(p) = kind(&s, &sp) else { panic!("converted") };
assert_eq!(p.vertices.len(), 2 * 4 + 1, "precision 2 over four control points");
}
#[test]
fn control_edits_drop_fit_data_and_keep_closed_splines_closed() {
let mut s = Session::new();
let sp = fit_spline(&mut s);
s.execute("splinedit", &json!({ "handle": sp, "option": "vertexmove", "index": 1, "to": [1, 5] })).unwrap();
let x = spline(&s, &sp);
assert!(x.fit.is_empty() && x.control[1] == Vec2::new(1.0, 5.0));
let c = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4], [0, 4], [-2, 2]], "closed": true })).unwrap());
let before = spline(&s, &c);
assert!(before.is_periodic());
let u = before.control.len() - before.degree;
s.execute("splinedit", &json!({ "handle": c, "option": "vertexmove", "index": 0, "to": [-1, -1] })).unwrap();
let x = spline(&s, &c);
assert_eq!(x.control[0], x.control[u], "the periodic repeat moves too");
s.execute("splinedit", &json!({ "handle": c, "option": "weight", "index": 1, "weight": 3 })).unwrap();
let x = spline(&s, &c);
assert_eq!((x.weights[1], x.weights[1 + u]), (3.0, 3.0));
s.execute("splinedit", &json!({ "handle": c, "option": "vertexdelete", "index": 2 })).unwrap();
let x = spline(&s, &c);
assert!(x.is_valid() && x.is_periodic() && x.control.len() == before.control.len() - 1 && x.weights.len() == x.control.len());
let (lo, hi) = x.domain();
assert!(x.eval(lo).near(x.eval(hi), 1e-9), "still closed");
assert!(s.execute("splinedit", &json!({ "handle": c, "option": "vertexdelete", "index": 99 })).is_err());
}
+283 -3
View File
@@ -98,10 +98,25 @@ 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.
/// are periodic and ignore end tangents; a tolerance approximates them with fewer periodic
/// control points. A fit point given twice in a row is a kink (SPLINEDIT Fit data Kink): the
/// curve passes through it with a corner (a knot of full multiplicity) and the pieces on
/// either side are fitted on their own.
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) };
let kinked = if fit.len() <= MAX_KINK_POINTS { kinked_fit(fit, closed, opts) } else { None };
let mut s = match kinked {
Some(s) => s,
None => {
// Repeats at the ends of an open curve are no corners: fit without them.
let (pts, _) = kinks(fit, false);
let mut s = if closed { closed_fit(&pts, opts.knots, opts.tolerance) } else { open_fit(&pts, opts) };
if pts.len() != fit.len() && !closed {
s.fit = fit.to_vec();
}
s
}
};
s.fit_opts = if closed { FitOptions { start_tangent: None, end_tangent: None, ..opts } } else { opts };
s
}
@@ -401,7 +416,189 @@ fn least_squares(fit: &[Vec2], u: &[f64], m: usize, p: usize, d0: Option<Vec2>,
Some(Spline { degree: p, knots, control, fit: fit.to_vec(), ..Spline::default() })
}
fn closed_fit(fit: &[Vec2], kind: KnotParam) -> Spline {
/// Fit point counts above this ignore kinks (each piece is fitted and joined separately).
const MAX_KINK_POINTS: usize = 20_000;
/// Kink positions: `fit` with consecutive repeats removed and, per kept point, whether it was
/// repeated (a kink). A closed list's repeated closing point is not a kink.
fn kinks(fit: &[Vec2], closed: bool) -> (Vec<Vec2>, Vec<bool>) {
let mut pts: Vec<Vec2> = Vec::with_capacity(fit.len());
let mut kink: Vec<bool> = Vec::with_capacity(fit.len());
for q in fit {
if pts.last().is_some_and(|l| l.near(*q, 1e-12)) {
if let Some(k) = kink.last_mut() {
*k = true;
}
} else {
pts.push(*q);
kink.push(false);
}
}
if closed && pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
pts.pop();
let last = kink.pop().unwrap_or(false);
if let Some(k) = kink.first_mut() {
*k |= last;
}
}
(pts, kink)
}
/// A curve with corners at repeated fit points: each run between kinks is fitted open (the
/// outer ends keep the end tangents) and the cubic pieces are joined end to end. A closed curve
/// starts and ends at its first kink. `None` without kinks.
fn kinked_fit(fit: &[Vec2], closed: bool, opts: FitOptions) -> Option<Spline> {
let (mut pts, mut kink) = kinks(fit, closed);
let n = pts.len();
if n < 2 {
return None;
}
// Ends of an open curve are corners anyway, so only interior kinks count.
let interior = |i: usize| closed || (i > 0 && i + 1 < n);
let first = (0..n).find(|i| kink.get(*i).copied().unwrap_or(false) && interior(*i))?;
if closed {
pts.rotate_left(first);
kink.rotate_left(first);
pts.push(*pts.first()?);
kink.push(true);
}
let last = pts.len() - 1;
let mut pieces: Vec<Spline> = Vec::new();
let mut start = 0;
for i in 1..=last {
if i < last && !kink.get(i).copied().unwrap_or(false) {
continue;
}
let run = pts.get(start..=i)?;
let o = FitOptions {
start_tangent: if start == 0 && !closed { opts.start_tangent } else { None },
end_tangent: if i == last && !closed { opts.end_tangent } else { None },
..opts
};
pieces.push(cubic(open_fit(run, o))?);
start = i;
}
let lens: Vec<f64> = pieces.iter().map(|p| chord_length(&p.fit).max(1e-12)).collect();
let total: f64 = lens.iter().sum();
let p = 3;
let mut knots: Vec<f64> = Vec::new();
let mut control: Vec<Vec2> = Vec::new();
let mut at = 0.0;
let count = pieces.len();
for (j, (piece, len)) in pieces.iter().zip(&lens).enumerate() {
let (lo, hi) = piece.domain();
let scale = len / total.max(1e-300) / (hi - lo).max(1e-300);
let mapped = piece.knots.iter().map(|k| at + (k - lo) * scale);
let keep = piece.knots.len() - usize::from(j + 1 < count);
knots.extend(mapped.take(keep).skip(if j == 0 { 0 } else { p + 1 }));
control.extend(piece.control.iter().skip(usize::from(j > 0)).copied());
at += len / total.max(1e-300);
}
let s = Spline { degree: p, knots, control, fit: fit.to_vec(), closed, ..Spline::default() };
s.is_valid().then_some(s)
}
/// A clamped, non-rational fitted piece raised to degree 3: interpolation through two or three
/// points gives a single Bézier segment of lower degree, which is raised exactly.
fn cubic(mut s: Spline) -> Option<Spline> {
if s.degree == 3 {
return Some(s);
}
if s.degree > 3 || s.control.len() != s.degree + 1 {
return None;
}
let (lo, hi) = s.domain();
while s.degree < 3 {
let p = s.degree;
let c = &s.control;
let mut q = Vec::with_capacity(p + 2);
q.push(*c.first()?);
for i in 1..=p {
let a = i as f64 / (p + 1) as f64;
q.push(*c.get(i - 1)? * a + *c.get(i)? * (1.0 - a));
}
q.push(*c.last()?);
s.degree = p + 1;
s.control = q;
}
s.knots = [lo; 4].into_iter().chain([hi; 4]).collect();
Some(s)
}
/// Least-squares approximation of a closed curve within `tol`: the fewest periodic control
/// points (searched by bisection) whose curve passes within `tol` of every fit point. `None`
/// when only interpolation meets the tolerance.
fn approximate_closed(pts: &[Vec2], t: &[f64], p: usize, tol: f64) -> Option<Spline> {
let n = pts.len();
let (mut lo, mut hi) = (p + 1, n.checked_sub(1)?);
let mut best = None;
while lo <= hi {
let mid = (lo + hi) / 2;
match periodic_least_squares(pts, t, mid, p) {
Some(s) if pts.iter().zip(t).all(|(q, u)| s.eval(*u).dist(*q) <= tol) => {
best = Some(s);
hi = mid - 1;
}
_ => lo = mid + 1,
}
}
best
}
/// A periodic spline with `m` distinct control points (`p < m < pts.len()`) closest in the
/// least-squares sense to `pts` at closed parameters `t`. The period's breakpoints spread over
/// the parameters so every span holds some (the closed counterpart of `lsq_knots`).
fn periodic_least_squares(pts: &[Vec2], t: &[f64], m: usize, p: usize) -> Option<Spline> {
let n = pts.len();
if m <= p || m >= n || t.len() != n + 1 {
return None;
}
let mut b = Vec::with_capacity(m + 1);
for j in 0..=m {
let jd = j as f64 * n as f64 / m as f64;
let i = (jd.floor() as usize).min(n - 1);
let a = jd - i as f64;
b.push((1.0 - a) * t.get(i).copied()? + a * t.get(i + 1).copied()?);
}
if b.windows(2).any(|w| w.first().zip(w.get(1)).is_none_or(|(x, y)| y - x <= 1e-12)) {
return None;
}
let knots = periodic_knots(&b, m, p);
let mut a = vec![vec![0.0; m]; m];
let (mut bx, mut by) = (vec![0.0; m], vec![0.0; m]);
for (q, u) in pts.iter().zip(t) {
let span = find_span(m + p - 1, p, *u, &knots);
let mut row = vec![0.0; m];
for (k, v) in basis(span, *u, p, &knots).into_iter().enumerate() {
if let Some(c) = (span + k).checked_sub(p).and_then(|i| row.get_mut(i % m)) {
*c += v;
}
}
for (i, ri) in row.iter().enumerate() {
if *ri == 0.0 {
continue;
}
if let (Some(x), Some(y)) = (bx.get_mut(i), by.get_mut(i)) {
*x += ri * q.x;
*y += ri * q.y;
}
if let Some(ar) = a.get_mut(i) {
for (cell, rj) in ar.iter_mut().zip(&row) {
*cell += ri * rj;
}
}
}
}
let (cx, cy) = (solve(a.clone(), bx)?, solve(a, by)?);
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 None;
}
control.extend_from_within(..p);
Some(Spline { degree: p, knots, control, fit: pts.to_vec(), closed: true, ..Spline::default() })
}
fn closed_fit(fit: &[Vec2], kind: KnotParam, tol: f64) -> 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();
@@ -421,6 +618,12 @@ fn closed_fit(fit: &[Vec2], kind: KnotParam) -> Spline {
}
// Parameters t_0 = 0 .. t_n = 1 (t_n is the return to the first point).
let t = params(&pts, kind, true);
if tol > 0.0
&& n <= MAX_APPROX_POINTS
&& let Some(s) = approximate_closed(&pts, &t, p, tol)
{
return s;
}
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.
@@ -550,6 +753,74 @@ mod tests {
assert_eq!(tiny.control.len(), exact.control.len());
}
#[test]
fn tolerance_approximates_closed_curves() {
// Forty slightly noisy points round a circle.
let fit: Vec<Vec2> = (0..40)
.map(|i| {
let a = i as f64 / 40.0 * std::f64::consts::TAU;
let r = 5.0 + if i % 2 == 0 { 0.01 } else { -0.01 };
Vec2::new(a.cos() * r, a.sin() * r)
})
.collect();
let exact = Spline::fit_with(&fit, true, FitOptions::default());
let tol = 0.05;
let s = Spline::fit_with(&fit, true, FitOptions { tolerance: tol, ..FitOptions::default() });
assert!(s.is_valid() && s.is_periodic() && s.closed);
assert!(s.control.len() < exact.control.len(), "{} control points", s.control.len());
let u = params(&fit, KnotParam::Chord, true);
assert!(fit.iter().zip(&u).all(|(q, t)| s.eval(*t).dist(*q) <= tol + 1e-9));
let (lo, hi) = s.domain();
assert!(s.eval(lo).near(s.eval(hi), 1e-9), "still closed");
assert_eq!(s.fit, fit);
assert_eq!(s.fit_opts.tolerance, tol);
// Too tight a tolerance interpolates.
let tiny = Spline::fit_with(&fit, true, FitOptions { tolerance: 1e-9, ..FitOptions::default() });
assert_eq!(tiny.control.len(), exact.control.len());
// Every parametrisation approximates.
for k in KnotParam::ALL {
let s = Spline::fit_with(&fit, true, FitOptions { tolerance: tol, knots: k, ..FitOptions::default() });
assert!(s.is_valid() && s.control.len() < exact.control.len(), "{k:?}");
}
}
fn on_curve(s: &Spline, q: Vec2) -> bool {
let (lo, hi) = s.domain();
(0..=4000).any(|i| s.eval(lo + (hi - lo) * i as f64 / 4000.0).dist(q) < 1e-2)
}
#[test]
fn repeated_fit_points_make_kinks() {
let fit = [Vec2::new(0.0, 0.0), Vec2::new(2.0, 2.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(6.0, 2.0), Vec2::new(8.0, 0.0)];
let s = Spline::fit_with(&fit, false, FitOptions::default());
assert!(s.is_valid() && s.degree == 3 && !s.closed);
assert_eq!(s.fit, fit, "the kink stays in the fit data");
assert!(fit.iter().all(|q| on_curve(&s, *q)));
// A knot of multiplicity 3 at the corner, where the tangent turns.
let k = s.knots.iter().copied().find(|k| s.knots.iter().filter(|x| (*x - k).abs() < 1e-12).count() == 3).unwrap();
assert!(s.eval(k).near(Vec2::new(4.0, 0.0), 1e-9));
let h = 1e-6;
let before = (s.eval(k) - s.eval(k - h)).normalized();
let after = (s.eval(k + h) - s.eval(k)).normalized();
assert!(before.dot(after) < 0.5, "a corner: {before:?} {after:?}");
// Two-point pieces become straight cubic pieces.
let l = [Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)];
let s = Spline::fit_with(&l, false, FitOptions::default());
assert!(s.is_valid() && s.degree == 3 && s.control.len() == 7);
assert!(on_curve(&s, Vec2::new(0.5, 0.0)) && on_curve(&s, Vec2::new(1.0, 0.5)));
// Closed: the curve starts and ends at the kink, clamped.
let c = [Vec2::ZERO, Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(4.0, 4.0), Vec2::new(0.0, 4.0)];
let s = Spline::fit_with(&c, true, FitOptions::default());
assert!(s.is_valid() && s.closed && !s.is_periodic());
let (lo, hi) = s.domain();
assert!(s.eval(lo).near(Vec2::ZERO, 1e-9) && s.eval(hi).near(Vec2::ZERO, 1e-9));
assert!(c.iter().all(|q| on_curve(&s, *q)));
// Kinks at the ends of an open curve change nothing.
let e = [Vec2::ZERO, Vec2::ZERO, Vec2::new(2.0, 2.0), Vec2::new(4.0, 0.0)];
let plain = Spline::fit_with(&e[1..], false, FitOptions::default());
assert_eq!(Spline::fit_with(&e, false, FitOptions::default()).control.len(), plain.control.len());
}
#[test]
fn hostile_options_never_panic() {
let fit = pts();
@@ -572,6 +843,15 @@ mod tests {
}
let same = [Vec2::ZERO; 5];
let _ = Spline::fit_with(&same, false, FitOptions { tolerance: 0.5, start_tangent: Some(Vec2::X), ..FitOptions::default() });
let _ = Spline::fit_with(&same, true, FitOptions { tolerance: 0.5, ..FitOptions::default() });
let pairs = [Vec2::ZERO, Vec2::ZERO, Vec2::X, Vec2::X, Vec2::Y, Vec2::Y];
for closed in [false, true] {
for n in 0..=pairs.len() {
let _ = Spline::fit_with(&pairs[..n], closed, FitOptions { tolerance: 0.1, ..FitOptions::default() });
}
}
let tight: Vec<Vec2> = (0..8).map(|i| Vec2::new(i as f64 * 1e-13, 0.0)).collect();
let _ = Spline::fit_with(&tight, true, FitOptions { tolerance: 1.0, ..FitOptions::default() });
}
#[test]