From d2fda54e8a27c22952de6b645e8c49d746d840ed Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 19:12:46 +0200 Subject: [PATCH] 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 --- crates/engine/src/cmd/modify2.rs | 246 ++----- crates/engine/src/cmd/modify2/pedit_vertex.rs | 166 +++++ crates/engine/src/cmd/modify2/splinedit.rs | 697 ++++++++++++++++++ crates/engine/src/cmd/modify2_tests.rs | 2 +- crates/engine/tests/pedit_splinedit.rs | 300 ++++++++ crates/geom/src/spline_fit.rs | 286 ++++++- 6 files changed, 1514 insertions(+), 183 deletions(-) create mode 100644 crates/engine/src/cmd/modify2/pedit_vertex.rs create mode 100644 crates/engine/src/cmd/modify2/splinedit.rs create mode 100644 crates/engine/tests/pedit_splinedit.rs diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 384b7465..deb0dca2 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -26,13 +26,17 @@ pub fn specs() -> Vec { .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 { } } -/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex. -pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool) -> Vec { +/// 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]) -> Vec { let pts: Vec = 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 { }; // 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 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 { - 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::>() })); + } + // 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 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)>, } 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 { @@ -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 { - 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 { - 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::>(); - 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 { - 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, - fit_menu: bool, - history: Vec, -} - -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 { - 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 diff --git a/crates/engine/src/cmd/modify2/pedit_vertex.rs b/crates/engine/src/cmd/modify2/pedit_vertex.rs new file mode 100644 index 00000000..be0d5d38 --- /dev/null +++ b/crates/engine/src/cmd/modify2/pedit_vertex.rs @@ -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> { + 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| { + if let Some(v) = vs.last_mut() { + *v = PolyVertex::new(v.p); + } + LwPolyline { vertices: vs, closed: false, ..pl.clone() } + }; + let pieces: Vec = 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>> { + 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 = 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(()) + } +} diff --git a/crates/engine/src/cmd/modify2/splinedit.rs b/crates/engine/src/cmd/modify2/splinedit.rs new file mode 100644 index 00000000..d562a3f9 --- /dev/null +++ b/crates/engine/src/cmd/modify2/splinedit.rs @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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::>(); + 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 { + 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 { + 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 { + need_fit(sp)?; + let at = *sp.fit.get(i).ok_or_else(|| bad("splinedit", "fit point index out of range"))?; + let fit: Vec = 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 { + 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, end: Option) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 = sp.control.iter().take(u).copied().collect(); + ctrl.remove(j); + let rational = !sp.weights.is_empty(); + let mut ws: Vec = (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, + sub: Sub, + back: Sub, + history: Vec, + /// Marked fit point (Fit data) or distinct control point (Edit vertex). + mark: usize, + before: bool, + start_tangent: Option, +} + +fn nearest(pts: &[Vec2], p: Vec2) -> Option { + 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 { + 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 { + 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 ", Accept::POINT), + Sub::FitAddNew => { + Prompt::new("Specify new fit point ", Accept::POINT).kw(&["After", "Before"]).base_opt(sp.fit.get(self.mark).copied()) + } + Sub::FitKink => Prompt::new("Specify new point on spline ", 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 ", 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 { + 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 { + 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 + } +} diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs index af2649d0..0b72ecee 100644 --- a/crates/engine/src/cmd/modify2_tests.rs +++ b/crates/engine/src/cmd/modify2_tests.rs @@ -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(_))); } diff --git a/crates/engine/tests/pedit_splinedit.rs b/crates/engine/tests/pedit_splinedit.rs new file mode 100644 index 00000000..242515a2 --- /dev/null +++ b/crates/engine/tests/pedit_splinedit.rs @@ -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 { + 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()); +} diff --git a/crates/geom/src/spline_fit.rs b/crates/geom/src/spline_fit.rs index 6cc54ef4..7ab0f461 100644 --- a/crates/geom/src/spline_fit.rs +++ b/crates/geom/src/spline_fit.rs @@ -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, 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, Vec) { + let mut pts: Vec = Vec::with_capacity(fit.len()); + let mut kink: Vec = 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 { + 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 = 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 = 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 = Vec::new(); + let mut control: Vec = 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 { + 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 { + 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 { + 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 = 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 = (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 = (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]