mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
d0dd5cfe16
commit
d2fda54e8a
6 files changed
+1514
-183
No files matched your search
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user