mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #469 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
10f7a9b2a3
commit
ea0f866c7c
9 files changed
+1392
-123
No files matched your search
@@ -535,103 +535,14 @@ pub fn kind_distance(k: &EntityKind, p: Vec2) -> f64 {
|
||||
|
||||
// ---------------- splines ----------------
|
||||
|
||||
/// Insert knot `t` once (Boehm), in homogeneous coordinates.
|
||||
fn insert_knot(sp: &Spline, t: f64) -> Option<Spline> {
|
||||
if !sp.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let p = sp.degree;
|
||||
let n = sp.control.len();
|
||||
let w = |i: usize| if sp.weights.is_empty() { 1.0 } else { sp.weights.get(i).copied().unwrap_or(1.0) };
|
||||
// Span k with knots[k] <= t < knots[k+1].
|
||||
let (lo, hi) = sp.domain();
|
||||
let t = t.clamp(lo, hi);
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if sp.knots.get(i).copied()? <= t {
|
||||
k = i;
|
||||
}
|
||||
}
|
||||
let mut ctrl: Vec<(Vec2, f64)> = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let pt = if i + p <= k {
|
||||
let c = sp.control.get(i).copied()?;
|
||||
(c * w(i), w(i))
|
||||
} else if i > k {
|
||||
let c = sp.control.get(i - 1).copied()?;
|
||||
(c * w(i - 1), w(i - 1))
|
||||
} else {
|
||||
let ki = sp.knots.get(i).copied()?;
|
||||
let kip = sp.knots.get(i + p).copied()?;
|
||||
let a = if (kip - ki).abs() < 1e-300 { 0.0 } else { (t - ki) / (kip - ki) };
|
||||
let c0 = sp.control.get(i - 1).copied()?;
|
||||
let c1 = sp.control.get(i).copied()?;
|
||||
let (h0, w0) = (c0 * w(i - 1), w(i - 1));
|
||||
let (h1, w1) = (c1 * w(i), w(i));
|
||||
(h0 * (1.0 - a) + h1 * a, w0 * (1.0 - a) + w1 * a)
|
||||
};
|
||||
ctrl.push(pt);
|
||||
}
|
||||
let mut knots = sp.knots.clone();
|
||||
knots.insert(k + 1, t);
|
||||
let rational = !sp.weights.is_empty();
|
||||
Some(Spline {
|
||||
degree: p,
|
||||
knots,
|
||||
control: ctrl.iter().map(|(h, w)| if w.abs() > 1e-300 { *h / *w } else { *h }).collect(),
|
||||
weights: if rational { ctrl.iter().map(|(_, w)| *w).collect() } else { Vec::new() },
|
||||
fit: Vec::new(),
|
||||
closed: false,
|
||||
fit_opts: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a spline at parameter `t` into (left, right). `None` at the ends.
|
||||
pub fn split_spline(sp: &Spline, t: f64) -> Option<(Spline, Spline)> {
|
||||
let (lo, hi) = sp.domain();
|
||||
if !(t > lo + 1e-12 && t < hi - 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let mut s = sp.clone();
|
||||
s.fit.clear();
|
||||
let mult = |s: &Spline| s.knots.iter().filter(|k| (**k - t).abs() < 1e-12).count();
|
||||
let mut guard = 0;
|
||||
while mult(&s) < s.degree && guard < 64 {
|
||||
s = insert_knot(&s, t)?;
|
||||
guard += 1;
|
||||
}
|
||||
let p = s.degree;
|
||||
// Knots equal to t now have multiplicity p; first index of t:
|
||||
let first = s.knots.iter().position(|k| (*k - t).abs() < 1e-12)?;
|
||||
// Left: control points 0..first (count = first), knots 0..first+p ... + t
|
||||
let left_n = first;
|
||||
let mut lk: Vec<f64> = s.knots.get(..first)?.to_vec();
|
||||
lk.extend(std::iter::repeat_n(t, p + 1));
|
||||
let lc = s.control.get(..left_n)?.to_vec();
|
||||
let lw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(..left_n)?.to_vec() };
|
||||
let right_start = first.checked_sub(1)?;
|
||||
let mut rk = vec![t; p + 1];
|
||||
rk.extend(s.knots.get(first + p..)?.iter().copied());
|
||||
let rc = s.control.get(right_start..)?.to_vec();
|
||||
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(right_start..)?.to_vec() };
|
||||
let l = Spline { degree: p, knots: lk, control: lc, weights: lw, ..Spline::default() };
|
||||
let r = Spline { degree: p, knots: rk, control: rc, weights: rw, ..Spline::default() };
|
||||
(l.is_valid() && r.is_valid()).then_some((l, r))
|
||||
sp.split_at(t)
|
||||
}
|
||||
|
||||
/// The part of a spline between parameters `a < b`.
|
||||
pub fn sub_spline(sp: &Spline, a: f64, b: f64) -> Option<Spline> {
|
||||
let (lo, hi) = sp.domain();
|
||||
let mut s = sp.clone();
|
||||
s.fit.clear();
|
||||
s.closed = false;
|
||||
if b < hi - 1e-12 {
|
||||
s = split_spline(&s, b)?.0;
|
||||
}
|
||||
if a > lo + 1e-12 {
|
||||
s = split_spline(&s, a)?.1;
|
||||
}
|
||||
Some(s)
|
||||
sp.sub_between(a, b)
|
||||
}
|
||||
|
||||
/// Parameter of the point on the spline nearest `p`.
|
||||
@@ -662,21 +573,7 @@ pub fn spline_param_of(sp: &Spline, p: Vec2) -> f64 {
|
||||
|
||||
/// Reverse a spline's direction (control, weights, fit points and knots).
|
||||
pub fn reverse_spline(sp: &Spline) -> Spline {
|
||||
let (lo, hi) = (sp.knots.first().copied().unwrap_or(0.0), sp.knots.last().copied().unwrap_or(1.0));
|
||||
Spline {
|
||||
degree: sp.degree,
|
||||
knots: sp.knots.iter().rev().map(|k| lo + hi - k).collect(),
|
||||
control: sp.control.iter().rev().copied().collect(),
|
||||
weights: sp.weights.iter().rev().copied().collect(),
|
||||
fit: sp.fit.iter().rev().copied().collect(),
|
||||
closed: sp.closed,
|
||||
// The end tangents swap ends and turn round.
|
||||
fit_opts: cadcraft_geom::FitOptions {
|
||||
start_tangent: sp.fit_opts.end_tangent.map(|t| -t),
|
||||
end_tangent: sp.fit_opts.start_tangent.map(|t| -t),
|
||||
..sp.fit_opts
|
||||
},
|
||||
}
|
||||
sp.reversed()
|
||||
}
|
||||
|
||||
/// Reverse an LWPOLYLINE's vertex order, keeping its shape (bulges and widths move with segments).
|
||||
|
||||
@@ -1392,6 +1392,9 @@ fn run_breakat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines).
|
||||
pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
if let Some(h) = super::modify2::join_curves(s, hs)? {
|
||||
return Ok(Some(h));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
|
||||
@@ -1536,10 +1539,17 @@ impl SelectThen {
|
||||
}
|
||||
Op::Join => {
|
||||
match join(s, &self.objs) {
|
||||
Ok(_) => {
|
||||
// Objects outside the joined chain are kept, so count only the ones JOIN consumed.
|
||||
let n = s.doc().map(|d| self.objs.iter().filter(|h| d.entity(**h).is_none()).count()).unwrap_or(0);
|
||||
s.echo(format!("{n} objects joined into 1 polyline"))
|
||||
Ok(h) => {
|
||||
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
|
||||
// (and the source object an ellipse or spline join keeps).
|
||||
let d = s.doc()?;
|
||||
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
|
||||
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
|
||||
Some(EntityKind::Spline(_)) => "spline",
|
||||
Some(EntityKind::Ellipse(_)) => "ellipse",
|
||||
_ => "polyline",
|
||||
};
|
||||
s.echo(format!("{n} objects joined into 1 {what}"))
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ use super::machines::{SelOutcome, SelectPhase, number};
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod curve_edits;
|
||||
pub(crate) use curve_edits::join_curves;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("pedit", "Polyline", run_pedit)
|
||||
@@ -830,11 +833,13 @@ fn measure_len(k: &EntityKind) -> Option<(f64, Option<f64>)> {
|
||||
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
Some((g.len(), Some(g.sweep())))
|
||||
}
|
||||
EntityKind::Ellipse(e) => Some((ellipse_of(e).length(), None)),
|
||||
EntityKind::Spline(sp) if sp.is_valid() => Some((sp.length(), None)),
|
||||
_ => Chain::of(k).map(|c| (c.len(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lengthen a line, arc or open polyline at the end nearest `pick`.
|
||||
/// Lengthen a line, arc, open polyline, elliptical arc or open spline at the end nearest `pick`.
|
||||
pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
|
||||
let too_short = || other("Lengthening would make the object zero length or negative.");
|
||||
match k {
|
||||
@@ -919,7 +924,9 @@ pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -
|
||||
}
|
||||
Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() }))
|
||||
}
|
||||
_ => Err(other("Select a line, arc or open polyline.")),
|
||||
EntityKind::Ellipse(e) => curve_edits::lengthen_ellipse(e, pick, mode),
|
||||
EntityKind::Spline(sp) => curve_edits::lengthen_spline(sp, pick, mode),
|
||||
_ => Err(other("Select a line, arc, open polyline, elliptical arc or open spline.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2059,6 +2066,9 @@ pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
let tp = curves::spline_param_of(sp, pick);
|
||||
if sp.closed {
|
||||
return curve_edits::trim_closed_spline(sp, &ts, tp);
|
||||
}
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min);
|
||||
if lo == lo_d && hi == hi_d {
|
||||
@@ -2081,7 +2091,7 @@ pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend objects that modify.rs does not handle itself (elliptical arcs).
|
||||
/// Extend objects that modify.rs does not handle itself (elliptical arcs and open splines).
|
||||
pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<EntityKind> {
|
||||
match k {
|
||||
EntityKind::Ellipse(e) => {
|
||||
@@ -2105,6 +2115,7 @@ pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Resul
|
||||
let t = t.ok_or_else(|| other("Object does not intersect an edge."))?;
|
||||
Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } }))
|
||||
}
|
||||
EntityKind::Spline(sp) => curve_edits::extend_spline(sp, cut, pick),
|
||||
_ => Err(other("Cannot extend that object.")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! EXTEND, LENGTHEN, JOIN and closed-spline TRIM for elliptical arcs and splines (#466).
|
||||
//!
|
||||
//! The curve mathematics (knot insertion, joining, tangent extensions, arc length) lives in
|
||||
//! `cadcraft_geom::Spline` / `cadcraft_geom::Ellipse`; this module applies it to entities.
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle};
|
||||
use cadcraft_geom::{Ellipse, Line, Polyline, Segment, Spline, TAU, Vec2, intersect_ext, norm_angle};
|
||||
|
||||
use super::{LenMode, curves, ellipse_kind, ellipse_of, other, set_kind};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// EXTEND a spline's end nearest `pick` along its end tangent to the nearest boundary edge.
|
||||
pub(super) fn extend_spline(sp: &Spline, cut: &[Segment], pick: Vec2) -> Result<EntityKind> {
|
||||
if sp.closed {
|
||||
return Err(other("Cannot extend a closed spline."));
|
||||
}
|
||||
if !sp.is_valid() {
|
||||
return Err(other("Invalid spline."));
|
||||
}
|
||||
let (lo, hi) = sp.domain();
|
||||
let at_end = pick.dist(sp.eval(hi)) <= pick.dist(sp.eval(lo));
|
||||
let (end, dir) = if at_end { (sp.eval(hi), sp.unit_tangent(hi)) } else { (sp.eval(lo), -sp.unit_tangent(lo)) };
|
||||
if dir == Vec2::ZERO {
|
||||
return Err(other("The spline has no direction at that end."));
|
||||
}
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let d = cut.iter().flat_map(|c| intersect_ext(&ray, c, false)).map(|x| (x - end).dot(dir)).filter(|d| *d > 1e-9).fold(f64::INFINITY, f64::min);
|
||||
if !d.is_finite() {
|
||||
return Err(other("Object does not intersect an edge."));
|
||||
}
|
||||
sp.extended(at_end, d).map(EntityKind::Spline).ok_or_else(|| other("Cannot extend that spline."))
|
||||
}
|
||||
|
||||
/// TRIM a closed spline at the hits `ts` (sorted parameters) around the pick parameter `tp`:
|
||||
/// the piece between the two hits around the pick goes, the rest stays as one open spline.
|
||||
pub(super) fn trim_closed_spline(sp: &Spline, ts: &[f64], tp: f64) -> Result<Vec<EntityKind>> {
|
||||
let (Some(&first), Some(&last)) = (ts.first(), ts.last()) else {
|
||||
return Err(other("Spline must intersect cutting edges at two or more points."));
|
||||
};
|
||||
if ts.len() < 2 {
|
||||
return Err(other("Spline must intersect cutting edges at two or more points."));
|
||||
}
|
||||
let fail = || other("Cannot trim that spline.");
|
||||
let (lo_d, hi_d) = sp.domain();
|
||||
let kept = if tp > first && tp < last {
|
||||
// The removed piece lies inside the domain: keep the run from the next hit round the
|
||||
// seam to the previous one.
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(first, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(last, f64::min);
|
||||
let tail = sp.clamped().and_then(|c| c.sub_between(hi, hi_d)).ok_or_else(fail)?;
|
||||
let head = sp.clamped().and_then(|c| c.sub_between(lo_d, lo)).ok_or_else(fail)?;
|
||||
tail.joined(&head).ok_or_else(fail)?
|
||||
} else {
|
||||
// The removed piece crosses the seam.
|
||||
sp.sub_between(first, last).ok_or_else(fail)?
|
||||
};
|
||||
Ok(vec![EntityKind::Spline(Spline { closed: false, ..kept })])
|
||||
}
|
||||
|
||||
fn too_short() -> crate::EngineError {
|
||||
other("Lengthening would make the object zero length or negative.")
|
||||
}
|
||||
|
||||
/// LENGTHEN an elliptical arc along its ellipse at the end nearest `pick`.
|
||||
pub(super) fn lengthen_ellipse(e: &cadcraft_doc::Ellipse, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
|
||||
let g = ellipse_of(e);
|
||||
if g.is_full() {
|
||||
return Err(other("Cannot lengthen a closed ellipse."));
|
||||
}
|
||||
let at_end = pick.is_none_or(|p| p.dist(g.at_param(g.end)) <= p.dist(g.at_param(g.start)));
|
||||
let len = g.length();
|
||||
let target = match mode {
|
||||
LenMode::Delta(d) => len + d,
|
||||
LenMode::Percent(p) => len * p / 100.0,
|
||||
LenMode::Total(t) => t,
|
||||
LenMode::To(q) => {
|
||||
let t = g.param_of(q);
|
||||
let sweep = if at_end { cadcraft_geom::ccw_sweep(g.start, t) } else { cadcraft_geom::ccw_sweep(t, g.end) };
|
||||
if !(sweep > 1e-9 && sweep < TAU - 1e-9) {
|
||||
return Err(too_short());
|
||||
}
|
||||
return Ok(ellipse_kind(&if at_end { Ellipse { end: t, ..g } } else { Ellipse { start: t, ..g } }));
|
||||
}
|
||||
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
|
||||
};
|
||||
if !(target.is_finite() && target > 1e-12) {
|
||||
return Err(too_short());
|
||||
}
|
||||
let full = Ellipse { start: 0.0, end: TAU, ..g }.length();
|
||||
if target >= full - 1e-9 * full.max(1.0) {
|
||||
return Err(other("The new elliptical arc would be a full ellipse or longer."));
|
||||
}
|
||||
let t = if at_end { g.param_at_length(g.start, target) } else { g.param_at_length(g.end, -target) };
|
||||
let t = norm_angle(t.ok_or_else(too_short)?);
|
||||
Ok(ellipse_kind(&if at_end { Ellipse { end: t, ..g } } else { Ellipse { start: t, ..g } }))
|
||||
}
|
||||
|
||||
/// LENGTHEN an open spline at the end nearest `pick`: shorter cuts it along the curve, longer
|
||||
/// runs it on along its end tangent.
|
||||
pub(super) fn lengthen_spline(sp: &Spline, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
|
||||
if sp.closed {
|
||||
return Err(other("Cannot lengthen a closed spline."));
|
||||
}
|
||||
if !sp.is_valid() {
|
||||
return Err(other("Invalid spline."));
|
||||
}
|
||||
let (lo, hi) = sp.domain();
|
||||
let at_end = pick.is_none_or(|p| p.dist(sp.eval(hi)) <= p.dist(sp.eval(lo)));
|
||||
let len = sp.length();
|
||||
let target = match mode {
|
||||
LenMode::Delta(d) => len + d,
|
||||
LenMode::Percent(p) => len * p / 100.0,
|
||||
LenMode::Total(t) => t,
|
||||
LenMode::To(q) => {
|
||||
let (end_t, dir) = if at_end { (hi, sp.unit_tangent(hi)) } else { (lo, -sp.unit_tangent(lo)) };
|
||||
let t = curves::spline_param_of(sp, q);
|
||||
let beyond = (q - sp.eval(end_t)).dot(dir);
|
||||
if (t - end_t).abs() <= 1e-9 * (hi - lo) && beyond > 1e-9 {
|
||||
len + beyond
|
||||
} else if at_end {
|
||||
sp.length_between(lo, t)
|
||||
} else {
|
||||
sp.length_between(t, hi)
|
||||
}
|
||||
}
|
||||
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
|
||||
};
|
||||
if !(target.is_finite() && target > 1e-9) {
|
||||
return Err(too_short());
|
||||
}
|
||||
let fail = || other("Cannot lengthen that spline.");
|
||||
let out = if target > len + 1e-12 {
|
||||
sp.extended(at_end, target - len).ok_or_else(fail)?
|
||||
} else if target >= len - 1e-12 {
|
||||
sp.clone()
|
||||
} else if at_end {
|
||||
sp.sub_between(lo, sp.param_at_length(lo, target)).ok_or_else(fail)?
|
||||
} else {
|
||||
sp.sub_between(sp.param_at_length(hi, -target), hi).ok_or_else(fail)?
|
||||
};
|
||||
Ok(EntityKind::Spline(out))
|
||||
}
|
||||
|
||||
/// Parameter offset that maps `b`'s parameters onto `a`'s when both are the same ellipse
|
||||
/// (`a_param = b_param + offset`).
|
||||
fn same_ellipse(a: &Ellipse, b: &Ellipse) -> Option<f64> {
|
||||
let tol = 1e-6 * a.major.len().max(1.0);
|
||||
if !a.center.near(b.center, tol) || (a.ratio - b.ratio).abs() > 1e-9 || (a.major.len() - b.major.len()).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
if (a.ratio - 1.0).abs() <= 1e-9 {
|
||||
return Some(b.major.angle() - a.major.angle());
|
||||
}
|
||||
if a.major.near(b.major, tol) {
|
||||
Some(0.0)
|
||||
} else if a.major.near(-b.major, tol) {
|
||||
Some(std::f64::consts::PI)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Elliptical arcs on `src`'s ellipse (as start and sweep in `src`'s parameters) merged into one
|
||||
/// arc: everything but the widest gap between them, or the whole ellipse when no gap is left.
|
||||
fn merge_arcs(src: &Ellipse, arcs: &[(f64, f64)]) -> Ellipse {
|
||||
let tol = 1e-9;
|
||||
let mut iv: Vec<(f64, f64)> = arcs.iter().map(|(s, w)| (norm_angle(s - src.start), *w)).collect();
|
||||
iv.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut gaps: Vec<(f64, f64)> = Vec::new();
|
||||
let mut end = f64::NEG_INFINITY;
|
||||
for (s, w) in iv {
|
||||
if end.is_finite() && s > end + tol {
|
||||
gaps.push((end, s));
|
||||
}
|
||||
end = if end.is_finite() { end.max(s + w) } else { s + w };
|
||||
}
|
||||
if end < TAU - tol {
|
||||
gaps.push((end, TAU));
|
||||
} else if end > TAU {
|
||||
// An arc running past the source's start covers the beginning of later gaps.
|
||||
let over = end - TAU;
|
||||
gaps = gaps.into_iter().map(|(a, b)| (a.max(over), b)).filter(|(a, b)| b - a > tol).collect();
|
||||
}
|
||||
match gaps.into_iter().max_by(|a, b| (a.1 - a.0).total_cmp(&(b.1 - b.0))) {
|
||||
Some((a, b)) => Ellipse { start: norm_angle(src.start + b), end: norm_angle(src.start + a), ..*src },
|
||||
None => Ellipse { start: 0.0, end: TAU, ..*src },
|
||||
}
|
||||
}
|
||||
|
||||
/// A joinable entity as exact spline pieces (lines, arcs and polyline segments become splines).
|
||||
fn spline_pieces(k: &EntityKind) -> Vec<Spline> {
|
||||
let arc = |c: Vec2, r: f64, start: f64, sweep: f64| Spline::from_conic_arc(c, Vec2::X * r, Vec2::Y * r, start, sweep);
|
||||
match k {
|
||||
EntityKind::Line(l) if !l.a.xy().near(l.b.xy(), 1e-12) => vec![Spline::from_line(l.a.xy(), l.b.xy(), 1)],
|
||||
EntityKind::Arc(a) => {
|
||||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
arc(g.center, g.radius, g.start, g.sweep()).into_iter().collect()
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed => Polyline { vertices: p.vertices.clone(), closed: false }
|
||||
.segments()
|
||||
.into_iter()
|
||||
.filter(|sg| sg.len() > 1e-12)
|
||||
.filter_map(|sg| match sg {
|
||||
Segment::Line(l) => Some(Spline::from_line(l.a, l.b, 1)),
|
||||
Segment::Arc { arc: a, ccw: true } => arc(a.center, a.radius, a.start, a.sweep()),
|
||||
Segment::Arc { arc: a, ccw: false } => arc(a.center, a.radius, a.end, -a.sweep()),
|
||||
})
|
||||
.collect(),
|
||||
EntityKind::Ellipse(e) => {
|
||||
let g = ellipse_of(e);
|
||||
if g.is_full() { Vec::new() } else { Spline::from_ellipse(&g).into_iter().collect() }
|
||||
}
|
||||
EntityKind::Spline(sp) if !sp.closed => sp.clamped().into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ends(sp: &Spline) -> (Vec2, Vec2) {
|
||||
let (lo, hi) = sp.domain();
|
||||
(sp.eval(lo), sp.eval(hi))
|
||||
}
|
||||
|
||||
/// JOIN for selections holding elliptical arcs or splines; `None` when there are neither (the
|
||||
/// polyline join handles those). Elliptical arcs that all lie on one ellipse merge into a
|
||||
/// larger arc or the full ellipse (gaps between them close). Otherwise the objects joined end
|
||||
/// to end with the first one become one spline. The first joined object keeps its handle.
|
||||
pub(crate) fn join_curves(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
let d = s.doc()?;
|
||||
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
|
||||
if !ents.iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
|
||||
return Ok(None);
|
||||
}
|
||||
if ents.iter().any(|e| curves::is_locked(s, e.handle)) {
|
||||
return Err(other("An object is on a locked layer."));
|
||||
}
|
||||
let ells: Vec<(Handle, Ellipse)> =
|
||||
ents.iter().filter_map(|e| if let EntityKind::Ellipse(x) = &e.kind { Some((e.handle, ellipse_of(x))) } else { None }).collect();
|
||||
if let Some((src_h, src)) = ells.first().copied()
|
||||
&& ells.len() == ents.len()
|
||||
&& ells.len() >= 2
|
||||
{
|
||||
let offsets: Option<Vec<f64>> = ells.iter().map(|(_, e)| same_ellipse(&src, e)).collect();
|
||||
if let Some(offsets) = offsets {
|
||||
let arcs: Vec<(f64, f64)> = ells.iter().zip(&offsets).map(|((_, e), o)| (e.start + o, e.sweep())).collect();
|
||||
let merged = merge_arcs(&src, &arcs);
|
||||
set_kind(s, src_h, ellipse_kind(&merged))?;
|
||||
let doc = s.doc_mut()?;
|
||||
for (h, _) in ells.iter().skip(1) {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Some(src_h));
|
||||
}
|
||||
}
|
||||
// A spline through the objects that connect end to end with the first one.
|
||||
let mut pool: Vec<(Handle, Spline)> = ents.iter().flat_map(|e| spline_pieces(&e.kind).into_iter().map(move |p| (e.handle, p))).collect();
|
||||
if pool.is_empty() {
|
||||
return Err(other("Select at least two objects to join."));
|
||||
}
|
||||
let (first_h, first) = pool.remove(0);
|
||||
let (mut head, mut tail) = ends(&first);
|
||||
let mut chain = std::collections::VecDeque::from([first]);
|
||||
let mut used = vec![first_h];
|
||||
let tol = 1e-6;
|
||||
let touches = |p: &Spline, head: Vec2, tail: Vec2| {
|
||||
let (a, b) = ends(p);
|
||||
a.near(tail, tol) || b.near(tail, tol) || a.near(head, tol) || b.near(head, tol)
|
||||
};
|
||||
while let Some(i) = pool.iter().position(|(_, p)| touches(p, head, tail)) {
|
||||
let (h, p) = pool.remove(i);
|
||||
let (a, b) = ends(&p);
|
||||
if a.near(tail, tol) {
|
||||
tail = b;
|
||||
chain.push_back(p);
|
||||
} else if b.near(tail, tol) {
|
||||
tail = a;
|
||||
chain.push_back(p.reversed());
|
||||
} else if b.near(head, tol) {
|
||||
head = a;
|
||||
chain.push_front(p);
|
||||
} else {
|
||||
head = b;
|
||||
chain.push_front(p.reversed());
|
||||
}
|
||||
used.push(h);
|
||||
}
|
||||
if chain.len() < 2 {
|
||||
return Err(other("Objects are not connected end to end."));
|
||||
}
|
||||
// A polyline joins whole or not at all.
|
||||
if pool.iter().any(|(h, _)| used.contains(h)) {
|
||||
return Err(other("Only part of a polyline connects; join it whole or explode it first."));
|
||||
}
|
||||
used.sort();
|
||||
used.dedup();
|
||||
let mut it = chain.into_iter();
|
||||
let mut out = it.next().ok_or_else(|| other("Nothing to join."))?;
|
||||
for p in it {
|
||||
out = out.joined(&p).ok_or_else(|| other("Cannot join these objects into a spline."))?;
|
||||
}
|
||||
let (a, b) = ends(&out);
|
||||
out.closed = a.near(b, tol);
|
||||
let keep = first_h;
|
||||
set_kind(s, keep, EntityKind::Spline(out))?;
|
||||
let doc = s.doc_mut()?;
|
||||
for h in used.iter().filter(|h| **h != keep) {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Some(keep))
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! TRIM, EXTEND, JOIN and LENGTHEN on elliptical arcs and splines (#466).
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::geom::{Ellipse, Spline, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn h(r: &Value) -> String {
|
||||
r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap().to_string()
|
||||
}
|
||||
|
||||
fn kind(s: &Session, h: &str) -> EntityKind {
|
||||
s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind.clone()
|
||||
}
|
||||
|
||||
fn spline(s: &Session, h: &str) -> Spline {
|
||||
match kind(s, h) {
|
||||
EntityKind::Spline(sp) => sp,
|
||||
k => panic!("not a spline: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ellipse(s: &Session, h: &str) -> Ellipse {
|
||||
match kind(s, h) {
|
||||
EntityKind::Ellipse(e) => Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end },
|
||||
k => panic!("not an ellipse: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ends(sp: &Spline) -> (Vec2, Vec2) {
|
||||
let (lo, hi) = sp.domain();
|
||||
(sp.eval(lo), sp.eval(hi))
|
||||
}
|
||||
|
||||
fn samples(sp: &Spline, n: usize) -> Vec<Vec2> {
|
||||
let (lo, hi) = sp.domain();
|
||||
(0..=n).map(|i| sp.eval(lo + (hi - lo) * i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
/// Distance from `p` to the spline (dense sampling, then a ternary search round the best sample).
|
||||
fn dist_to(sp: &Spline, p: Vec2) -> f64 {
|
||||
let (lo, hi) = sp.domain();
|
||||
let n = 4000;
|
||||
let step = (hi - lo) / n as f64;
|
||||
let best = (0..=n).map(|i| lo + step * i as f64).min_by(|a, b| sp.eval(*a).dist(p).total_cmp(&sp.eval(*b).dist(p))).unwrap();
|
||||
let (mut a, mut b) = ((best - step).max(lo), (best + step).min(hi));
|
||||
for _ in 0..100 {
|
||||
let (m1, m2) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
|
||||
if sp.eval(m1).dist(p) < sp.eval(m2).dist(p) {
|
||||
b = m2;
|
||||
} else {
|
||||
a = m1;
|
||||
}
|
||||
}
|
||||
sp.eval((a + b) / 2.0).dist(p)
|
||||
}
|
||||
|
||||
fn count(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.iter().count()
|
||||
}
|
||||
|
||||
fn length(s: &mut Session, h: &str) -> f64 {
|
||||
s.execute("lengthen", &json!({ "handle": h })).unwrap()["length"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
/// An S-shaped open cubic from (0,0) to (9,0); it leaves its end heading along (1,1).
|
||||
fn s_curve(s: &mut Session) -> String {
|
||||
h(&s.execute("spline", &json!({ "control": [[0, 0], [3, 3], [6, -3], [9, 0]] })).unwrap())
|
||||
}
|
||||
|
||||
// ---------------- EXTEND ----------------
|
||||
|
||||
#[test]
|
||||
fn extend_runs_a_spline_on_along_its_end_tangent() {
|
||||
let mut s = Session::new();
|
||||
let sp = s_curve(&mut s);
|
||||
s.execute("line", &json!({ "points": [[12, -10], [12, 20]] })).unwrap();
|
||||
let before = spline(&s, &sp);
|
||||
s.execute("extend", &json!({ "handle": sp, "pick": [8.9, 0] })).unwrap();
|
||||
let after = spline(&s, &sp);
|
||||
let (a, b) = ends(&after);
|
||||
assert!(a.near(Vec2::ZERO, 1e-9), "{a:?}");
|
||||
assert!(b.near(Vec2::new(12.0, 3.0), 1e-9), "{b:?}");
|
||||
// The original curve is still there, unchanged.
|
||||
for p in samples(&before, 20) {
|
||||
assert!(dist_to(&after, p) < 1e-9, "{p:?}");
|
||||
}
|
||||
// The start end extends backwards along its tangent, (-1,-1) here.
|
||||
s.execute("line", &json!({ "points": [[-10, -3], [10, -3]] })).unwrap();
|
||||
s.execute("extend", &json!({ "handle": sp, "pick": [0.1, 0.1] })).unwrap();
|
||||
assert!(ends(&spline(&s, &sp)).0.near(Vec2::new(-3.0, -3.0), 1e-9));
|
||||
// Nothing ahead: an error, the spline unchanged.
|
||||
let now = spline(&s, &sp);
|
||||
assert!(s.execute("extend", &json!({ "handle": sp, "pick": [11.9, 2.9] })).is_err());
|
||||
assert_eq!(spline(&s, &sp), now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_refuses_closed_splines() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4], [0, 4]], "closed": true })).unwrap());
|
||||
s.execute("line", &json!({ "points": [[10, -10], [10, 10]] })).unwrap();
|
||||
assert!(s.execute("extend", &json!({ "handle": sp, "pick": [4, 2] })).is_err());
|
||||
}
|
||||
|
||||
// ---------------- TRIM ----------------
|
||||
|
||||
#[test]
|
||||
fn trimming_a_closed_spline_leaves_one_open_spline() {
|
||||
for (pick, right) in [([10.0, 5.0], false), ([0.0, 5.0], true)] {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({ "fit": [[0, 0], [10, 0], [10, 10], [0, 10]], "closed": true })).unwrap());
|
||||
s.execute("line", &json!({ "points": [[5, -5], [5, 15]] })).unwrap();
|
||||
let r = s.execute("trim", &json!({ "handle": sp, "pick": pick })).unwrap();
|
||||
let hs = r["handles"].as_array().unwrap();
|
||||
assert_eq!(hs.len(), 1, "{pick:?}: {r}");
|
||||
let piece = spline(&s, hs[0].as_str().unwrap());
|
||||
assert!(!piece.closed);
|
||||
let (a, b) = ends(&piece);
|
||||
assert!((a.x - 5.0).abs() < 1e-6 && (b.x - 5.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
// Only the half away from the pick is left.
|
||||
for p in samples(&piece, 100) {
|
||||
if right {
|
||||
assert!(p.x > 5.0 - 1e-6, "{pick:?}: {p:?}");
|
||||
} else {
|
||||
assert!(p.x < 5.0 + 1e-6, "{pick:?}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- LENGTHEN ----------------
|
||||
|
||||
/// A quarter of the ellipse x²/16 + y²/4 = 1, from (4,0) to (0,2).
|
||||
fn quarter_ellipse(s: &mut Session) -> String {
|
||||
h(&s.execute("ellipse", &json!({ "center": [0, 0], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_elliptical_arcs_along_the_ellipse() {
|
||||
let mut s = Session::new();
|
||||
let e = quarter_ellipse(&mut s);
|
||||
let l0 = length(&mut s, &e);
|
||||
assert!((l0 - ellipse(&s, &e).length()).abs() < 1e-9);
|
||||
// DElta at the end nearest the pick (the (0,2) end); the start stays.
|
||||
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "delta": 1.0 })).unwrap();
|
||||
assert!((length(&mut s, &e) - (l0 + 1.0)).abs() < 1e-6);
|
||||
assert!(ellipse(&s, &e).start.abs() < 1e-12);
|
||||
// Total, Percent; picking the start end moves the start.
|
||||
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "total": l0 })).unwrap();
|
||||
assert!((ellipse(&s, &e).end - FRAC_PI_2).abs() < 1e-6);
|
||||
s.execute("lengthen", &json!({ "handle": e, "pick": [4, 0], "percent": 200 })).unwrap();
|
||||
assert!((length(&mut s, &e) - 2.0 * l0).abs() < 1e-6);
|
||||
assert!((ellipse(&s, &e).end - FRAC_PI_2).abs() < 1e-6);
|
||||
assert!((ellipse(&s, &e).start - 1.5 * PI).abs() < 1e-6, "{}", ellipse(&s, &e).start);
|
||||
// DYnamic / To: the end follows the point round the ellipse.
|
||||
s.execute("lengthen", &json!({ "handle": e, "pick": [0, 2], "to": [-4, 0] })).unwrap();
|
||||
assert!((ellipse(&s, &e).end - PI).abs() < 1e-9);
|
||||
// Too long (a full ellipse), too short, angle options: errors, the arc unchanged.
|
||||
let before = ellipse(&s, &e);
|
||||
for p in [json!({ "total": 1000.0 }), json!({ "delta": -1000.0 }), json!({ "deltaAngle": 10 }), json!({ "percent": f64::MIN_POSITIVE })] {
|
||||
let mut q = p.clone();
|
||||
q["handle"] = json!(e);
|
||||
q["pick"] = json!([0, 2]);
|
||||
assert!(s.execute("lengthen", &q).is_err(), "{q}");
|
||||
assert_eq!(ellipse(&s, &e), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_open_splines() {
|
||||
let mut s = Session::new();
|
||||
let sp = s_curve(&mut s);
|
||||
let before = spline(&s, &sp);
|
||||
let l0 = length(&mut s, &sp);
|
||||
// Longer: runs on along the end tangent.
|
||||
s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": 2f64.sqrt() })).unwrap();
|
||||
assert!(ends(&spline(&s, &sp)).1.near(Vec2::new(10.0, 1.0), 1e-9));
|
||||
assert!((length(&mut s, &sp) - l0 - 2f64.sqrt()).abs() < 1e-6);
|
||||
// Shorter from the start: the far end stays put.
|
||||
s.execute("lengthen", &json!({ "handle": sp, "pick": [0, 0], "total": 5.0 })).unwrap();
|
||||
let now = spline(&s, &sp);
|
||||
assert!((length(&mut s, &sp) - 5.0).abs() < 1e-6);
|
||||
assert!(ends(&now).1.near(Vec2::new(10.0, 1.0), 1e-9));
|
||||
assert!(dist_to(&before, ends(&now).0) < 1e-9, "the new start lies on the old curve");
|
||||
// To a point on the curve: cut there.
|
||||
let mut s = Session::new();
|
||||
let sp = s_curve(&mut s);
|
||||
let mid = before.eval(0.5);
|
||||
s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "to": [mid.x, mid.y] })).unwrap();
|
||||
assert!(ends(&spline(&s, &sp)).1.near(mid, 1e-6));
|
||||
// Closed splines and nonsense lengths are refused.
|
||||
let c = h(&s.execute("spline", &json!({ "fit": [[0, 0], [4, 0], [4, 4]], "closed": true })).unwrap());
|
||||
assert!(s.execute("lengthen", &json!({ "handle": c, "delta": 1 })).is_err());
|
||||
assert!(s.execute("lengthen", &json!({ "handle": sp, "pick": [9, 0], "delta": -1e9 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengthen_at_the_command_line_on_an_elliptical_arc() {
|
||||
let mut s = Session::new();
|
||||
let e = quarter_ellipse(&mut s);
|
||||
let l0 = ellipse(&s, &e).length();
|
||||
s.script("LENGTHEN DE 1 0,2\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!((ellipse(&s, &e).length() - l0 - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// ---------------- JOIN ----------------
|
||||
|
||||
fn arc_on_ellipse(s: &mut Session, start: f64, end: f64) -> String {
|
||||
h(&s.execute("ellipse", &json!({ "center": [1, 1], "major": [4, 0], "ratio": 0.5, "start": start, "end": end })).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_merges_elliptical_arcs_on_one_ellipse() {
|
||||
// Touching arcs: one larger arc.
|
||||
let mut s = Session::new();
|
||||
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
||||
let b = arc_on_ellipse(&mut s, 90.0, 180.0);
|
||||
let r = s.execute("join", &json!({ "handles": [a, b] })).unwrap();
|
||||
assert_eq!(h(&r), a, "the source arc is kept");
|
||||
let m = ellipse(&s, &a);
|
||||
assert!(m.start.abs() < 1e-9 && (m.end - PI).abs() < 1e-9, "{m:?}");
|
||||
assert_eq!(count(&s), 1);
|
||||
// A gap closes; the widest gap stays open.
|
||||
let mut s = Session::new();
|
||||
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
||||
let b = arc_on_ellipse(&mut s, 120.0, 270.0);
|
||||
s.execute("join", &json!({ "handles": [a, b] })).unwrap();
|
||||
let m = ellipse(&s, &a);
|
||||
assert!(m.start.abs() < 1e-9 && (m.end - 1.5 * PI).abs() < 1e-9, "{m:?}");
|
||||
// Two halves: the full ellipse. The same ellipse with its major axis the other way round
|
||||
// (parameters shifted by half a turn) counts.
|
||||
let mut s = Session::new();
|
||||
let a = arc_on_ellipse(&mut s, 0.0, 180.0);
|
||||
let b = h(&s.execute("ellipse", &json!({ "center": [1, 1], "major": [-4, 0], "ratio": 0.5, "start": 0, "end": 180 })).unwrap());
|
||||
s.execute("join", &json!({ "handles": [a, b] })).unwrap();
|
||||
assert!(ellipse(&s, &a).is_full());
|
||||
assert_eq!(count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_makes_one_spline_from_lines_arcs_polylines_and_splines() {
|
||||
let mut s = Session::new();
|
||||
let sp = s_curve(&mut s);
|
||||
let before = spline(&s, &sp);
|
||||
let line = h(&s.execute("line", &json!({ "points": [[-5, 0], [0, 0]] })).unwrap());
|
||||
// An arc leaving (9,0) and a polyline (with an arc segment) after it, picked in any order.
|
||||
let arc = h(&s.execute("arc", &json!({ "center": [9, 3], "radius": 3, "start": 270, "end": 0 })).unwrap());
|
||||
let pl = h(&s.execute("pline", &json!({ "vertices": [[12, 3], { "p": [12, 6], "bulge": 0.5 }, [14, 6]] })).unwrap());
|
||||
let far = h(&s.execute("line", &json!({ "points": [[50, 50], [60, 60]] })).unwrap());
|
||||
s.execute("join", &json!({ "handles": [line, pl, sp, arc, far] })).unwrap();
|
||||
let j = spline(&s, &line);
|
||||
let (a, b) = ends(&j);
|
||||
assert!(a.near(Vec2::new(-5.0, 0.0), 1e-9), "{a:?}");
|
||||
assert!(b.near(Vec2::new(14.0, 6.0), 1e-9), "{b:?}");
|
||||
// Every joined piece lies on the result, exactly for the arc.
|
||||
for p in samples(&before, 20) {
|
||||
assert!(dist_to(&j, p) < 1e-9, "{p:?}");
|
||||
}
|
||||
for k in 0..=8 {
|
||||
let t = -FRAC_PI_2 + FRAC_PI_2 * k as f64 / 8.0;
|
||||
assert!(dist_to(&j, Vec2::new(9.0 + 3.0 * t.cos(), 3.0 + 3.0 * t.sin())) < 1e-9);
|
||||
}
|
||||
// The unconnected line stays; the others are consumed.
|
||||
assert_eq!(count(&s), 2);
|
||||
assert!(matches!(kind(&s, &far), EntityKind::Line(_)));
|
||||
assert!(s.doc().unwrap().entity(Handle::parse_hex(&sp).unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_at_the_command_line_names_the_result() {
|
||||
let mut s = Session::new();
|
||||
s_curve(&mut s);
|
||||
s.execute("line", &json!({ "points": [[9, 0], [12, 0]] })).unwrap();
|
||||
s.script("JOIN ALL\n\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(s.log.iter().any(|l| l.contains("2 objects joined into 1 spline")), "{:?}", s.log);
|
||||
assert!(matches!(s.doc().unwrap().model.last().unwrap().kind, EntityKind::Spline(_)));
|
||||
assert_eq!(count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_refuses_unconnected_curves() {
|
||||
let mut s = Session::new();
|
||||
let sp = s_curve(&mut s);
|
||||
let l = h(&s.execute("line", &json!({ "points": [[20, 0], [30, 0]] })).unwrap());
|
||||
assert!(s.execute("join", &json!({ "handles": [sp, l] })).is_err());
|
||||
assert_eq!(count(&s), 2);
|
||||
// Arcs on different ellipses that do not touch.
|
||||
let a = arc_on_ellipse(&mut s, 0.0, 90.0);
|
||||
let b = h(&s.execute("ellipse", &json!({ "center": [40, 40], "major": [4, 0], "ratio": 0.5, "start": 0, "end": 90 })).unwrap());
|
||||
assert!(s.execute("join", &json!({ "handles": [a, b] })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_curve_edits_do_not_panic() {
|
||||
let mut s = Session::new();
|
||||
let sp = s_curve(&mut s);
|
||||
let e = quarter_ellipse(&mut s);
|
||||
// A degenerate spline (all control points equal) and a huge-degree one.
|
||||
let d = h(&s.execute("spline", &json!({ "control": [[1, 1], [1, 1], [1, 1], [1, 1]] })).unwrap());
|
||||
s.execute("line", &json!({ "points": [[0, -50], [0, 50]] })).unwrap();
|
||||
for target in [&sp, &e, &d] {
|
||||
for p in [
|
||||
json!({ "delta": 1e308 }),
|
||||
json!({ "delta": f64::MAX }),
|
||||
json!({ "total": 1e-300 }),
|
||||
json!({ "percent": 1e300 }),
|
||||
json!({ "to": [1e308, -1e308] }),
|
||||
json!({ "to": [0, 0] }),
|
||||
] {
|
||||
let mut q = p.clone();
|
||||
q["handle"] = json!(target);
|
||||
let _ = s.execute("lengthen", &q);
|
||||
}
|
||||
let _ = s.execute("extend", &json!({ "handle": target, "pick": [1e308, 1e308] }));
|
||||
let _ = s.execute("trim", &json!({ "handle": target, "pick": [-1e308, 0] }));
|
||||
let _ = s.execute("join", &json!({ "handles": [target, sp, e, d] }));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod bounds;
|
||||
mod curve;
|
||||
mod intersect;
|
||||
mod mat;
|
||||
mod nurbs;
|
||||
pub mod region;
|
||||
mod spline;
|
||||
mod spline_fit;
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
//! NURBS operations for the editing commands (TRIM, EXTEND, JOIN, LENGTHEN, FILLET): first
|
||||
//! derivatives, knot insertion (Boehm), splitting, clamping, Bézier decomposition, degree
|
||||
//! elevation, end-to-end joining, rational splines for lines and circular/elliptical arcs,
|
||||
//! straight end extensions and arc length; plus arc length along an ellipse.
|
||||
//!
|
||||
//! Textbook algorithms written from the mathematics. Every loop is capped and degenerate input
|
||||
//! gives `None` (or a zero vector), never a panic or NaN.
|
||||
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
use crate::spline::{basis, find_span};
|
||||
use crate::{EPS, Ellipse, Spline, TAU, Vec2};
|
||||
|
||||
/// Highest degree the editing operations work with.
|
||||
pub const MAX_EDIT_DEGREE: usize = 25;
|
||||
/// Most control points a single knot insertion grows a spline to.
|
||||
const MAX_EDIT_CONTROL: usize = 50_000;
|
||||
/// Most knot insertions one split makes (the degree of any spline drawn in practice).
|
||||
const MAX_SPLIT_INSERTS: usize = 64;
|
||||
/// Most control points a Bézier decomposition (and so a degree elevation or join) produces.
|
||||
const MAX_DECOMPOSED: usize = 10_000;
|
||||
|
||||
/// A homogeneous control point: (w·P, w).
|
||||
type H = (Vec2, f64);
|
||||
|
||||
fn hlerp(a: H, b: H, t: f64) -> H {
|
||||
(a.0 * (1.0 - t) + b.0 * t, a.1 * (1.0 - t) + b.1 * t)
|
||||
}
|
||||
|
||||
fn hpoint(h: H) -> Vec2 {
|
||||
if h.1.abs() > 1e-300 { h.0 / h.1 } else { h.0 }
|
||||
}
|
||||
|
||||
fn poly_len(pts: &[Vec2]) -> f64 {
|
||||
pts.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).sum()
|
||||
}
|
||||
|
||||
/// Five-point Gauss–Legendre nodes and weights on [-1, 1].
|
||||
const GAUSS: [(f64, f64); 5] = [
|
||||
(0.0, 0.568_888_888_888_888_9),
|
||||
(-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||||
(0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||||
(-0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||||
(0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||||
];
|
||||
|
||||
fn gauss(a: f64, b: f64, f: impl Fn(f64) -> f64) -> f64 {
|
||||
let (m, h) = ((a + b) / 2.0, (b - a) / 2.0);
|
||||
GAUSS.iter().map(|(x, w)| w * f(m + h * x)).sum::<f64>() * h
|
||||
}
|
||||
|
||||
/// Clamped knots over Bézier pieces meeting at `breaks`, for degree `p`.
|
||||
fn bezier_knots(breaks: &[f64], p: usize) -> Vec<f64> {
|
||||
let mut k = Vec::with_capacity(breaks.len() * p + 2);
|
||||
let n = breaks.len();
|
||||
for (i, b) in breaks.iter().enumerate() {
|
||||
let m = if i == 0 || i + 1 == n { p + 1 } else { p };
|
||||
k.extend(std::iter::repeat_n(*b, m));
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// One degree elevation of a homogeneous Bézier piece.
|
||||
fn elevate_bezier(p: &[H]) -> Vec<H> {
|
||||
let n = p.len();
|
||||
let zero = (Vec2::ZERO, 0.0);
|
||||
(0..=n)
|
||||
.map(|i| {
|
||||
let prev = i.checked_sub(1).and_then(|j| p.get(j)).copied().unwrap_or(zero);
|
||||
let cur = p.get(i).copied().unwrap_or(zero);
|
||||
hlerp(cur, prev, i as f64 / n as f64)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Spline {
|
||||
fn weight(&self, i: usize) -> f64 {
|
||||
if self.weights.is_empty() { 1.0 } else { self.weights.get(i).copied().unwrap_or(1.0) }
|
||||
}
|
||||
|
||||
/// True when the spline carries weights (a NURBS rather than a plain B-spline).
|
||||
pub fn is_rational(&self) -> bool {
|
||||
!self.weights.is_empty()
|
||||
}
|
||||
|
||||
fn homogeneous(&self) -> Vec<H> {
|
||||
self.control
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let w = self.weight(i);
|
||||
(*c * w, w)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from_homogeneous(degree: usize, knots: Vec<f64>, h: &[H], rational: bool) -> Spline {
|
||||
Spline {
|
||||
degree,
|
||||
knots,
|
||||
control: h.iter().map(|x| hpoint(*x)).collect(),
|
||||
weights: if rational { h.iter().map(|x| x.1).collect() } else { Vec::new() },
|
||||
..Spline::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// First derivative dC/dt (zero on an invalid spline).
|
||||
pub fn derivative(&self, t: f64) -> Vec2 {
|
||||
if !self.is_valid() {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let p = self.degree;
|
||||
let n = self.control.len() - 1;
|
||||
let (lo, hi) = self.domain();
|
||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||
let span = find_span(n, p, t, &self.knots);
|
||||
let nb = basis(span, t, p, &self.knots);
|
||||
let nd = basis(span, t, p - 1, &self.knots);
|
||||
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
|
||||
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
|
||||
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
|
||||
for j in 0..=p {
|
||||
let Some(i) = (span + j).checked_sub(p) else { continue };
|
||||
let Some(c) = self.control.get(i) else { continue };
|
||||
let wi = self.weight(i);
|
||||
let n0 = nb.get(j).copied().unwrap_or(0.0);
|
||||
// N'_{i,p} = p (N_{i,p-1} / (u_{i+p} - u_i) - N_{i+1,p-1} / (u_{i+p+1} - u_{i+1})).
|
||||
let left = if j >= 1 { ratio(nd.get(j - 1), k(i + p) - k(i)) } else { 0.0 };
|
||||
let right = if j < p { ratio(nd.get(j), k(i + p + 1) - k(i + 1)) } else { 0.0 };
|
||||
let dn = p as f64 * (left - right);
|
||||
a += *c * (n0 * wi);
|
||||
w += n0 * wi;
|
||||
da += *c * (dn * wi);
|
||||
dw += dn * wi;
|
||||
}
|
||||
if w.abs() < 1e-300 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let d = (da - a / w * dw) / w;
|
||||
if d.is_finite() { d } else { Vec2::ZERO }
|
||||
}
|
||||
|
||||
/// Unit tangent at `t` in the direction of increasing parameter; a central difference stands
|
||||
/// in where the derivative vanishes. Zero when the curve does not move at all.
|
||||
pub fn unit_tangent(&self, t: f64) -> Vec2 {
|
||||
let d = self.derivative(t).normalized();
|
||||
if d != Vec2::ZERO {
|
||||
return d;
|
||||
}
|
||||
let (lo, hi) = self.domain();
|
||||
let h = (hi - lo) * 1e-6;
|
||||
(self.eval((t + h).min(hi)) - self.eval((t - h).max(lo))).normalized()
|
||||
}
|
||||
|
||||
/// Insert knot `t` once (Boehm's algorithm, in homogeneous coordinates). The result has no
|
||||
/// fit data and is open.
|
||||
pub fn insert_knot(&self, t: f64) -> Option<Spline> {
|
||||
if !self.is_valid() || !t.is_finite() || self.control.len() >= MAX_EDIT_CONTROL {
|
||||
return None;
|
||||
}
|
||||
let p = self.degree;
|
||||
let n = self.control.len();
|
||||
let (lo, hi) = self.domain();
|
||||
let t = t.clamp(lo, hi);
|
||||
// Span k: the last knot index in p..n with knots[k] <= t.
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if *self.knots.get(i)? <= t {
|
||||
k = i;
|
||||
}
|
||||
}
|
||||
let h = self.homogeneous();
|
||||
let mut out = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
let pt = if i + p <= k {
|
||||
*h.get(i)?
|
||||
} else if i > k {
|
||||
*h.get(i - 1)?
|
||||
} else {
|
||||
let ki = *self.knots.get(i)?;
|
||||
let kip = *self.knots.get(i + p)?;
|
||||
let a = if (kip - ki).abs() < 1e-300 { 0.0 } else { (t - ki) / (kip - ki) };
|
||||
hlerp(*h.get(i.checked_sub(1)?)?, *h.get(i)?, a)
|
||||
};
|
||||
out.push(pt);
|
||||
}
|
||||
let mut knots = self.knots.clone();
|
||||
knots.insert(k + 1, t);
|
||||
Some(Spline::from_homogeneous(p, knots, &out, self.is_rational()))
|
||||
}
|
||||
|
||||
/// Cut at `t` (inserting it to multiplicity `degree`); either side may be invalid when `t`
|
||||
/// is at or beyond an end.
|
||||
fn split_raw(&self, t: f64) -> Option<(Spline, Spline)> {
|
||||
let same = |k: &f64| (*k - t).abs() < 1e-12;
|
||||
let mut s = self.clone();
|
||||
s.fit.clear();
|
||||
let mut guard = 0;
|
||||
while s.knots.iter().filter(|k| same(k)).count() < s.degree && guard < MAX_SPLIT_INSERTS {
|
||||
s = s.insert_knot(t)?;
|
||||
guard += 1;
|
||||
}
|
||||
let p = s.degree;
|
||||
if s.knots.iter().filter(|k| same(k)).count() != p {
|
||||
return None;
|
||||
}
|
||||
let first = s.knots.iter().position(same)?;
|
||||
let mut lk = s.knots.get(..first)?.to_vec();
|
||||
lk.extend(std::iter::repeat_n(t, p + 1));
|
||||
let lc = s.control.get(..first)?.to_vec();
|
||||
let lw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(..first)?.to_vec() };
|
||||
let rs = first.checked_sub(1)?;
|
||||
let mut rk = vec![t; p + 1];
|
||||
rk.extend(s.knots.get(first + p..)?.iter().copied());
|
||||
let rc = s.control.get(rs..)?.to_vec();
|
||||
let rw = if s.weights.is_empty() { Vec::new() } else { s.weights.get(rs..)?.to_vec() };
|
||||
Some((
|
||||
Spline { degree: p, knots: lk, control: lc, weights: lw, ..Spline::default() },
|
||||
Spline { degree: p, knots: rk, control: rc, weights: rw, ..Spline::default() },
|
||||
))
|
||||
}
|
||||
|
||||
/// Split at parameter `t` into (before, after). `None` at the ends or on invalid input.
|
||||
pub fn split_at(&self, t: f64) -> Option<(Spline, Spline)> {
|
||||
let (lo, hi) = self.domain();
|
||||
if !(t > lo + 1e-12 && t < hi - 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let (l, r) = self.split_raw(t)?;
|
||||
(l.is_valid() && r.is_valid()).then_some((l, r))
|
||||
}
|
||||
|
||||
/// The open piece between parameters `a < b`.
|
||||
pub fn sub_between(&self, a: f64, b: f64) -> Option<Spline> {
|
||||
let (lo, hi) = self.domain();
|
||||
let mut s = self.clone();
|
||||
s.fit.clear();
|
||||
s.closed = false;
|
||||
if b < hi - 1e-12 {
|
||||
s = s.split_at(b)?.0;
|
||||
}
|
||||
if a > lo + 1e-12 {
|
||||
s = s.split_at(a)?.1;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
/// The same curve with clamped ends (end knots repeated degree + 1 times), so it starts and
|
||||
/// ends on its first and last control points. Fit data is dropped.
|
||||
pub fn clamped(&self) -> Option<Spline> {
|
||||
if !self.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let p = self.degree;
|
||||
let (lo, hi) = self.domain();
|
||||
let mut s = self.clone();
|
||||
s.fit.clear();
|
||||
s.fit_opts = Default::default();
|
||||
if !s.knots.get(..=p).is_some_and(|k| k.iter().all(|x| (*x - lo).abs() < 1e-12)) {
|
||||
s = s.split_raw(lo)?.1;
|
||||
}
|
||||
let n = s.control.len();
|
||||
if !s.knots.get(n..).is_some_and(|k| k.iter().all(|x| (*x - hi).abs() < 1e-12)) {
|
||||
s = s.split_raw(hi)?.0;
|
||||
}
|
||||
s.closed = self.closed;
|
||||
s.is_valid().then_some(s)
|
||||
}
|
||||
|
||||
/// Reversed direction: control points, weights, fit points and knots (and the fit end
|
||||
/// tangents swap ends and turn round).
|
||||
pub fn reversed(&self) -> Spline {
|
||||
let (lo, hi) = (self.knots.first().copied().unwrap_or(0.0), self.knots.last().copied().unwrap_or(1.0));
|
||||
Spline {
|
||||
degree: self.degree,
|
||||
knots: self.knots.iter().rev().map(|k| lo + hi - k).collect(),
|
||||
control: self.control.iter().rev().copied().collect(),
|
||||
weights: self.weights.iter().rev().copied().collect(),
|
||||
fit: self.fit.iter().rev().copied().collect(),
|
||||
closed: self.closed,
|
||||
fit_opts: crate::FitOptions {
|
||||
start_tangent: self.fit_opts.end_tangent.map(|t| -t),
|
||||
end_tangent: self.fit_opts.start_tangent.map(|t| -t),
|
||||
..self.fit_opts
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Bézier decomposition: the homogeneous control points of each polynomial piece
|
||||
/// (degree + 1 each) and the parameter breaks between them (pieces + 1 values).
|
||||
fn bezier_pieces(&self) -> Option<(Vec<Vec<H>>, Vec<f64>)> {
|
||||
let mut s = self.clamped()?;
|
||||
let p = s.degree;
|
||||
if p > MAX_EDIT_DEGREE {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = s.domain();
|
||||
let mut interior: Vec<f64> = s.knots.iter().copied().filter(|k| *k > lo + 1e-12 && *k < hi - 1e-12).collect();
|
||||
interior.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
|
||||
if (interior.len() + 1).saturating_mul(p) >= MAX_DECOMPOSED {
|
||||
return None;
|
||||
}
|
||||
for &u in &interior {
|
||||
let mut guard = 0;
|
||||
while s.knots.iter().filter(|k| (**k - u).abs() < 1e-12).count() < p && guard <= MAX_EDIT_DEGREE {
|
||||
s = s.insert_knot(u)?;
|
||||
guard += 1;
|
||||
}
|
||||
}
|
||||
let h = s.homogeneous();
|
||||
let pieces = interior.len() + 1;
|
||||
// A knot of multiplicity above the degree (a break in the curve) leaves extra points.
|
||||
if h.len() != pieces * p + 1 {
|
||||
return None;
|
||||
}
|
||||
let segs = (0..pieces).map(|j| h.get(j * p..=j * p + p).map(<[H]>::to_vec)).collect::<Option<Vec<_>>>()?;
|
||||
let mut breaks = vec![lo];
|
||||
breaks.extend(interior);
|
||||
breaks.push(hi);
|
||||
Some((segs, breaks))
|
||||
}
|
||||
|
||||
/// The same curve at a higher (or equal) degree, clamped.
|
||||
pub fn elevated(&self, degree: usize) -> Option<Spline> {
|
||||
if degree < self.degree || degree > MAX_EDIT_DEGREE {
|
||||
return None;
|
||||
}
|
||||
if degree == self.degree {
|
||||
return self.clamped();
|
||||
}
|
||||
let (pieces, breaks) = self.bezier_pieces()?;
|
||||
let mut out: Vec<H> = Vec::with_capacity(pieces.len() * degree + 1);
|
||||
for (j, pc) in pieces.iter().enumerate() {
|
||||
let mut q = pc.clone();
|
||||
while q.len() < degree + 1 {
|
||||
q = elevate_bezier(&q);
|
||||
}
|
||||
out.extend(q.into_iter().skip(usize::from(j > 0)));
|
||||
}
|
||||
let mut s = Spline::from_homogeneous(degree, bezier_knots(&breaks, degree), &out, self.is_rational());
|
||||
s.closed = self.closed;
|
||||
s.is_valid().then_some(s)
|
||||
}
|
||||
|
||||
/// `other` joined on at this spline's end (its start should lie on this end; the shared
|
||||
/// point becomes their midpoint). Degrees are matched by elevation, weights rescaled so the
|
||||
/// junction carries one weight, and `other`'s parameters follow on at a similar speed. The
|
||||
/// junction is a knot of multiplicity `degree`, so the curve passes through it exactly.
|
||||
pub fn joined(&self, other: &Spline) -> Option<Spline> {
|
||||
let deg = self.degree.max(other.degree);
|
||||
let a = self.elevated(deg)?;
|
||||
let b = other.elevated(deg)?;
|
||||
let (ha, hb) = (a.homogeneous(), b.homogeneous());
|
||||
let (la, fb) = (*ha.last()?, *hb.first()?);
|
||||
let scale = if fb.1.abs() > 1e-300 { la.1 / fb.1 } else { 1.0 };
|
||||
if !(scale.is_finite() && scale > 0.0) || ha.len() + hb.len() > MAX_DECOMPOSED * 2 {
|
||||
return None;
|
||||
}
|
||||
let ((alo, ahi), (blo, bhi)) = (a.domain(), b.domain());
|
||||
let speed = (ahi - alo) / poly_len(&a.control).max(EPS);
|
||||
let span = poly_len(&b.control) * speed;
|
||||
let k = if span.is_finite() && span > 1e-12 && bhi - blo > 1e-300 { span / (bhi - blo) } else { 1.0 };
|
||||
let mut knots: Vec<f64> = a.knots.get(..a.knots.len().checked_sub(1)?)?.to_vec();
|
||||
knots.extend(b.knots.get(deg + 1..)?.iter().map(|u| ahi + (u - blo) * k));
|
||||
let mid = hpoint(la).mid(hpoint(fb));
|
||||
let mut h = ha;
|
||||
if let Some(l) = h.last_mut() {
|
||||
*l = (mid * l.1, l.1);
|
||||
}
|
||||
h.extend(hb.iter().skip(1).map(|x| (x.0 * scale, x.1 * scale)));
|
||||
let s = Spline::from_homogeneous(deg, knots, &h, a.is_rational() || b.is_rational());
|
||||
(s.is_valid() && s.control.iter().all(|c| c.is_finite())).then_some(s)
|
||||
}
|
||||
|
||||
/// A straight line from `a` to `b` as a clamped spline of `degree` (evenly spaced control
|
||||
/// points, parameter = distance).
|
||||
pub fn from_line(a: Vec2, b: Vec2, degree: usize) -> Spline {
|
||||
let p = degree.clamp(1, MAX_EDIT_DEGREE);
|
||||
let control = (0..=p).map(|i| a.lerp(b, i as f64 / p as f64)).collect();
|
||||
let len = a.dist(b);
|
||||
let hi = if len.is_finite() && len > 1e-12 { len } else { 1.0 };
|
||||
let mut knots = vec![0.0; p + 1];
|
||||
knots.extend(std::iter::repeat_n(hi, p + 1));
|
||||
Spline { degree: p, knots, control, ..Spline::default() }
|
||||
}
|
||||
|
||||
/// The elliptical arc `center + u·cos θ + v·sin θ` for θ from `start` over `sweep` (negative
|
||||
/// runs clockwise) as an exact rational quadratic spline, in pieces of at most a quarter
|
||||
/// turn. Circular arcs are the case `|u| = |v|`, `u ⟂ v`.
|
||||
pub fn from_conic_arc(center: Vec2, u: Vec2, v: Vec2, start: f64, sweep: f64) -> Option<Spline> {
|
||||
if !(start.is_finite() && sweep.is_finite() && center.is_finite() && u.is_finite() && v.is_finite())
|
||||
|| sweep.abs() < 1e-12
|
||||
|| sweep.abs() > TAU + 1e-9
|
||||
|| u.cross(v).abs() < 1e-300
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let k = ((sweep.abs() / FRAC_PI_2) - 1e-9).ceil().clamp(1.0, 4.0) as usize;
|
||||
let d = sweep / k as f64;
|
||||
let at = |t: f64| center + u * t.cos() + v * t.sin();
|
||||
let w = (d / 2.0).cos();
|
||||
let mut control = vec![at(start)];
|
||||
let mut weights = vec![1.0];
|
||||
for i in 0..k {
|
||||
let a0 = start + d * i as f64;
|
||||
let m = a0 + d / 2.0;
|
||||
control.push(center + (u * m.cos() + v * m.sin()) / w);
|
||||
weights.push(w);
|
||||
control.push(at(a0 + d));
|
||||
weights.push(1.0);
|
||||
}
|
||||
let step = (d.abs() * (u.len() + v.len()) / 2.0).max(1e-9);
|
||||
let breaks: Vec<f64> = (0..=k).map(|i| step * i as f64).collect();
|
||||
let s = Spline { degree: 2, knots: bezier_knots(&breaks, 2), control, weights, ..Spline::default() };
|
||||
s.is_valid().then_some(s)
|
||||
}
|
||||
|
||||
/// An ellipse or elliptical arc as an exact rational spline (counter-clockwise).
|
||||
pub fn from_ellipse(e: &Ellipse) -> Option<Spline> {
|
||||
let s = Spline::from_conic_arc(e.center, e.major, e.minor(), e.start, e.sweep())?;
|
||||
Some(Spline { closed: e.is_full(), ..s })
|
||||
}
|
||||
|
||||
/// Run the spline on in a straight line along its end tangent by `dist` > 0: past the end
|
||||
/// (`at_end`) or before the start. The result is clamped, open and has no fit data.
|
||||
pub fn extended(&self, at_end: bool, dist: f64) -> Option<Spline> {
|
||||
if !(dist.is_finite() && dist > 0.0) || self.closed {
|
||||
return None;
|
||||
}
|
||||
let s = self.clamped()?;
|
||||
let (lo, hi) = s.domain();
|
||||
let t = if at_end { hi } else { lo };
|
||||
let e = s.eval(t);
|
||||
let dir = s.unit_tangent(t) * if at_end { 1.0 } else { -1.0 };
|
||||
if dir == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
let far = e + dir * dist;
|
||||
if at_end { s.joined(&Spline::from_line(e, far, s.degree)) } else { Spline::from_line(far, e, s.degree).joined(&s) }
|
||||
}
|
||||
|
||||
/// Integration intervals for arc length between `a < b`: each knot span cut in four.
|
||||
fn length_intervals(&self, a: f64, b: f64) -> Vec<(f64, f64)> {
|
||||
let mut cuts = vec![a];
|
||||
cuts.extend(self.knots.iter().copied().filter(|k| *k > a && *k < b));
|
||||
cuts.push(b);
|
||||
cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-15);
|
||||
let mut out = Vec::with_capacity(cuts.len() * 4);
|
||||
for w in cuts.windows(2) {
|
||||
let (Some(&x0), Some(&x1)) = (w.first(), w.get(1)) else { continue };
|
||||
for i in 0..4 {
|
||||
out.push((x0 + (x1 - x0) * i as f64 / 4.0, x0 + (x1 - x0) * (i + 1) as f64 / 4.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Arc length between parameters `a` and `b` (Gauss–Legendre over each knot span).
|
||||
pub fn length_between(&self, a: f64, b: f64) -> f64 {
|
||||
if !self.is_valid() || !(a.is_finite() && b.is_finite()) {
|
||||
return 0.0;
|
||||
}
|
||||
let (lo, hi) = self.domain();
|
||||
let (a, b) = (a.min(b).clamp(lo, hi), a.max(b).clamp(lo, hi));
|
||||
let l: f64 = self.length_intervals(a, b).into_iter().map(|(x, y)| gauss(x, y, |t| self.derivative(t).len())).sum();
|
||||
if l.is_finite() { l } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Arc length of the whole spline.
|
||||
pub fn length(&self) -> f64 {
|
||||
let (lo, hi) = self.domain();
|
||||
self.length_between(lo, hi)
|
||||
}
|
||||
|
||||
/// The parameter `len` along the curve from parameter `from` (forward for positive `len`,
|
||||
/// backward for negative), clamped to the domain.
|
||||
pub fn param_at_length(&self, from: f64, len: f64) -> f64 {
|
||||
let (lo, hi) = self.domain();
|
||||
let from = if from.is_finite() { from.clamp(lo, hi) } else { lo };
|
||||
if !self.is_valid() || !len.is_finite() || len == 0.0 {
|
||||
return from;
|
||||
}
|
||||
let speed = |t: f64| self.derivative(t).len();
|
||||
let (a, b, target) = if len > 0.0 { (from, hi, len) } else { (lo, from, -len) };
|
||||
let mut ivs = self.length_intervals(a, b);
|
||||
if len < 0.0 {
|
||||
ivs.reverse();
|
||||
}
|
||||
let mut acc = 0.0;
|
||||
for (x0, x1) in ivs {
|
||||
let l = gauss(x0, x1, speed);
|
||||
if acc + l >= target && l > 0.0 {
|
||||
// Bisect inside this interval, measuring from its near end.
|
||||
let rest = target - acc;
|
||||
let (near, far) = if len > 0.0 { (x0, x1) } else { (x1, x0) };
|
||||
let (mut s0, mut s1) = (near, far);
|
||||
for _ in 0..60 {
|
||||
let m = (s0 + s1) / 2.0;
|
||||
let lm = gauss(near.min(m), near.max(m), speed);
|
||||
if lm < rest {
|
||||
s0 = m;
|
||||
} else {
|
||||
s1 = m;
|
||||
}
|
||||
}
|
||||
return (s0 + s1) / 2.0;
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
if len > 0.0 { hi } else { lo }
|
||||
}
|
||||
}
|
||||
|
||||
impl Ellipse {
|
||||
/// dP/dθ at parameter `t`.
|
||||
pub fn derivative(&self, t: f64) -> Vec2 {
|
||||
self.minor() * t.cos() - self.major * t.sin()
|
||||
}
|
||||
|
||||
/// Arc length from parameter `t0` counter-clockwise over `sweep` (radians, >= 0).
|
||||
pub fn arc_length_from(&self, t0: f64, sweep: f64) -> f64 {
|
||||
if !(t0.is_finite() && sweep.is_finite()) || sweep <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = ((sweep / TAU) * 128.0).ceil().clamp(1.0, 512.0) as usize;
|
||||
let h = sweep / n as f64;
|
||||
let l: f64 = (0..n).map(|i| gauss(t0 + h * i as f64, t0 + h * (i + 1) as f64, |t| self.derivative(t).len())).sum();
|
||||
if l.is_finite() { l } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Arc length of the ellipse or elliptical arc.
|
||||
pub fn length(&self) -> f64 {
|
||||
self.arc_length_from(self.start, self.sweep())
|
||||
}
|
||||
|
||||
/// The parameter reached `len` along the ellipse from `t0`: counter-clockwise for positive
|
||||
/// `len`, clockwise for negative. `None` for a whole turn or more, or degenerate input.
|
||||
pub fn param_at_length(&self, t0: f64, len: f64) -> Option<f64> {
|
||||
let full = self.arc_length_from(0.0, TAU);
|
||||
if !(t0.is_finite() && len.is_finite() && full > EPS) || len.abs() >= full {
|
||||
return None;
|
||||
}
|
||||
let target = len.abs();
|
||||
let along = |s: f64| if len >= 0.0 { self.arc_length_from(t0, s) } else { self.arc_length_from(t0 - s, s) };
|
||||
let (mut a, mut b) = (0.0, TAU);
|
||||
for _ in 0..80 {
|
||||
let m = (a + b) / 2.0;
|
||||
if along(m) < target {
|
||||
a = m;
|
||||
} else {
|
||||
b = m;
|
||||
}
|
||||
}
|
||||
let s = (a + b) / 2.0;
|
||||
Some(if len >= 0.0 { t0 + s } else { t0 - s })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cubic() -> Spline {
|
||||
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0), Vec2::new(12.0, 2.0)], 3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_matches_finite_differences() {
|
||||
let mut s = cubic();
|
||||
s.weights = vec![1.0, 2.0, 0.5, 1.0, 1.5];
|
||||
let (lo, hi) = s.domain();
|
||||
for i in 1..20 {
|
||||
let t = lo + (hi - lo) * i as f64 / 20.0;
|
||||
let h = 1e-6;
|
||||
let fd = (s.eval(t + h) - s.eval(t - h)) / (2.0 * h);
|
||||
assert!(s.derivative(t).near(fd, 1e-4), "{t}: {:?} vs {fd:?}", s.derivative(t));
|
||||
}
|
||||
// Clamped end: the derivative points along the last control leg.
|
||||
assert!(s.unit_tangent(hi).near((Vec2::new(12.0, 2.0) - Vec2::new(9.0, 0.0)).normalized(), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_and_sub_keep_the_curve() {
|
||||
let s = cubic();
|
||||
let (l, r) = s.split_at(0.4).unwrap();
|
||||
assert!(l.eval(l.domain().1).near(s.eval(0.4), 1e-12));
|
||||
assert!(r.eval(r.domain().0).near(s.eval(0.4), 1e-12));
|
||||
assert!(r.eval(0.7).near(s.eval(0.7), 1e-12));
|
||||
let m = s.sub_between(0.2, 0.6).unwrap();
|
||||
assert!(m.eval(m.domain().0).near(s.eval(0.2), 1e-12) && m.eval(m.domain().1).near(s.eval(0.6), 1e-12));
|
||||
assert!(s.split_at(0.0).is_none() && s.split_at(f64::NAN).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_spline_clamps_to_the_same_curve() {
|
||||
let fit = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 1.0), Vec2::new(5.0, 4.0), Vec2::new(1.0, 5.0)];
|
||||
let p = Spline::from_fit(&fit, true);
|
||||
assert!(p.is_periodic());
|
||||
let c = p.clamped().unwrap();
|
||||
assert!(!c.is_periodic());
|
||||
let ((plo, phi), (clo, chi)) = (p.domain(), c.domain());
|
||||
for i in 0..=20 {
|
||||
let f = i as f64 / 20.0;
|
||||
assert!(p.eval(plo + (phi - plo) * f).near(c.eval(clo + (chi - clo) * f), 1e-9));
|
||||
}
|
||||
assert!(c.control.first().unwrap().near(p.eval(plo), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevation_keeps_the_curve() {
|
||||
let s = cubic();
|
||||
let e = s.elevated(5).unwrap();
|
||||
assert_eq!(e.degree, 5);
|
||||
for i in 0..=20 {
|
||||
let t = i as f64 / 20.0;
|
||||
assert!(e.eval(t).near(s.eval(t), 1e-9), "{t}");
|
||||
}
|
||||
// A rational quarter circle elevated to a cubic stays on the circle.
|
||||
let q = Spline::from_conic_arc(Vec2::ZERO, Vec2::X * 2.0, Vec2::Y * 2.0, 0.0, 3.0).unwrap();
|
||||
let q3 = q.elevated(3).unwrap();
|
||||
let (lo, hi) = q3.domain();
|
||||
for i in 0..=20 {
|
||||
assert!((q3.eval(lo + (hi - lo) * i as f64 / 20.0).len() - 2.0).abs() < 1e-9);
|
||||
}
|
||||
assert!(s.elevated(2).is_none() && s.elevated(1000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conic_arcs_are_exact() {
|
||||
let e = Ellipse { center: Vec2::new(1.0, 2.0), major: Vec2::new(4.0, 3.0), ratio: 0.5, start: 0.3, end: 5.5 };
|
||||
let s = Spline::from_ellipse(&e).unwrap();
|
||||
let (lo, hi) = s.domain();
|
||||
assert!(s.eval(lo).near(e.at_param(e.start), 1e-9) && s.eval(hi).near(e.at_param(e.end), 1e-9));
|
||||
// Every point satisfies the ellipse equation.
|
||||
let (a, b) = (e.major.len(), e.major.len() * e.ratio);
|
||||
let ux = e.major.normalized();
|
||||
for i in 0..=40 {
|
||||
let p = s.eval(lo + (hi - lo) * i as f64 / 40.0) - e.center;
|
||||
let (x, y) = (p.dot(ux), p.dot(ux.perp()));
|
||||
assert!(((x / a).powi(2) + (y / b).powi(2) - 1.0).abs() < 1e-9);
|
||||
}
|
||||
// Clockwise arcs run the other way.
|
||||
let cw = Spline::from_conic_arc(Vec2::ZERO, Vec2::X, Vec2::Y, 0.0, -1.0).unwrap();
|
||||
assert!(cw.eval(cw.domain().1).near(Vec2::new(1f64.cos(), -(1f64.sin())), 1e-12));
|
||||
assert!(Spline::from_conic_arc(Vec2::ZERO, Vec2::X, Vec2::X, 0.0, 1.0).is_none());
|
||||
assert!(Spline::from_conic_arc(Vec2::ZERO, Vec2::X, Vec2::Y, 0.0, f64::NAN).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_line_arc_and_spline() {
|
||||
let line = Spline::from_line(Vec2::new(-5.0, 0.0), Vec2::ZERO, 1);
|
||||
let s = cubic();
|
||||
let j = line.joined(&s).unwrap();
|
||||
assert_eq!(j.degree, 3);
|
||||
let (lo, hi) = j.domain();
|
||||
assert!(j.eval(lo).near(Vec2::new(-5.0, 0.0), 1e-12) && j.eval(hi).near(Vec2::new(12.0, 2.0), 1e-12));
|
||||
// The line part is straight (parameter = distance), the spline part unchanged.
|
||||
for i in 0..=10 {
|
||||
assert!(j.eval(lo + 0.5 * i as f64).near(Vec2::new(-5.0 + 0.5 * i as f64, 0.0), 1e-12));
|
||||
let f = i as f64 / 10.0;
|
||||
assert!(j.eval(5.0 + (hi - 5.0) * f).near(s.eval(f), 1e-9));
|
||||
}
|
||||
assert!((j.length() - (5.0 + s.length())).abs() < 1e-6);
|
||||
// A rational arc joined to a non-rational line.
|
||||
let arc = Spline::from_conic_arc(Vec2::new(12.0, 5.0), Vec2::new(0.0, -3.0), Vec2::new(3.0, 0.0), 0.0, 1.0).unwrap();
|
||||
let k = j.joined(&arc).unwrap();
|
||||
assert!(k.is_rational());
|
||||
assert!(k.eval(k.domain().1).near(arc.eval(arc.domain().1), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_runs_on_along_the_tangent() {
|
||||
let s = cubic();
|
||||
let (lo, hi) = s.domain();
|
||||
let t_end = s.unit_tangent(hi);
|
||||
let e = s.extended(true, 3.0).unwrap();
|
||||
assert!(e.eval(e.domain().1).near(s.eval(hi) + t_end * 3.0, 1e-9));
|
||||
assert!((e.length() - s.length() - 3.0).abs() < 1e-6);
|
||||
let b = s.extended(false, 2.0).unwrap();
|
||||
assert!(b.eval(b.domain().0).near(s.eval(lo) - s.unit_tangent(lo) * 2.0, 1e-9));
|
||||
assert!(s.extended(true, -1.0).is_none() && s.extended(true, f64::NAN).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_arc_length_and_inverse() {
|
||||
let l = Spline::from_line(Vec2::ZERO, Vec2::new(3.0, 4.0), 3);
|
||||
assert!((l.length() - 5.0).abs() < 1e-12);
|
||||
let q = Spline::from_conic_arc(Vec2::ZERO, Vec2::X * 2.0, Vec2::Y * 2.0, 0.0, std::f64::consts::PI).unwrap();
|
||||
assert!((q.length() - 2.0 * std::f64::consts::PI).abs() < 1e-6);
|
||||
let (lo, hi) = q.domain();
|
||||
let t = q.param_at_length(lo, std::f64::consts::PI);
|
||||
assert!(q.eval(t).near(Vec2::new(0.0, 2.0), 1e-6));
|
||||
let t = q.param_at_length(hi, -std::f64::consts::PI / 2.0);
|
||||
assert!(q.eval(t).near(Vec2::new(-2f64.sqrt(), 2f64.sqrt()), 1e-6));
|
||||
assert_eq!(q.param_at_length(lo, 1e9), hi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_arc_length() {
|
||||
let c = Ellipse::full(Vec2::ZERO, Vec2::new(3.0, 0.0), 1.0);
|
||||
assert!((c.length() - TAU * 3.0).abs() < 1e-9);
|
||||
let e = Ellipse::full(Vec2::ZERO, Vec2::new(5.0, 0.0), 0.6);
|
||||
// Ramanujan's approximation is good to ~1e-5 relative here.
|
||||
let (a, b) = (5.0f64, 3.0f64);
|
||||
let h = ((a - b) / (a + b)).powi(2);
|
||||
let ram = std::f64::consts::PI * (a + b) * (1.0 + 3.0 * h / (10.0 + (4.0 - 3.0 * h).sqrt()));
|
||||
assert!((e.length() - ram).abs() < 1e-4);
|
||||
let t = e.param_at_length(0.0, e.length() / 4.0).unwrap();
|
||||
assert!((t - FRAC_PI_2).abs() < 1e-9);
|
||||
let t = e.param_at_length(0.0, -e.length() / 4.0).unwrap();
|
||||
assert!((t + FRAC_PI_2).abs() < 1e-9);
|
||||
assert!(e.param_at_length(0.0, e.length() * 2.0).is_none());
|
||||
assert!(e.param_at_length(f64::NAN, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ do them first.
|
||||
| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 |
|
||||
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 |
|
||||
| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 |
|
||||
| F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 |
|
||||
| F6 | FILLET doesn't handle ellipses and splines; FILLET/CHAMFER of a polyline segment with another object or a second segment of the same polyline (TRIM/EXTEND/JOIN/LENGTHEN on ellipses and splines done, #466; FILLET Trim/Multiple and CHAMFER Angle/mEthod/Polyline done, #248) | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--55) | Medium | 5–8 |
|
||||
| F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 |
|
||||
| F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 |
|
||||
| F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 |
|
||||
|
||||
+10
-9
@@ -1,6 +1,6 @@
|
||||
# Geometry parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first audit of the geometry kernel's precision against AutoCAD) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; a closed spline trims to one piece, #466) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right
|
||||
to the last displayed digit or the tool is unusable for production drawings. This document
|
||||
@@ -76,17 +76,17 @@ constraints 10%.
|
||||
| Polyline with arcs | exact, with self-intersection trimming, OFFSETGAPTYPE (extend, fillet, chamfer) | segments offset and joined at neighbours' intersections; **arc bulges not recomputed** after endpoints move (comment at L413), **no loop cleanup**, collapsed segments not removed; OFFSETGAPTYPE only echoed |
|
||||
| Options: Through, Erase, Layer, Multiple | yes | see [gaps.md](gaps.md) |
|
||||
|
||||
## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 45%)
|
||||
## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 55%)
|
||||
|
||||
| Command | AutoCAD supports | CADCraft supports |
|
||||
|---|---|---|
|
||||
| TRIM | every curve type, quick and standard modes | line, arc, circle, polyline; ellipse and spline via sampled cutting (`modify.rs:485-656`, `modify2.rs:1996`) |
|
||||
| EXTEND | every open curve | line, arc, polyline, ellipse; **no spline** |
|
||||
| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode; **no ellipse or spline** (`modify2.rs:1804`) |
|
||||
| CHAMFER | lines, polylines, xlines, rays | lines (`modify.rs:756`); polyline chamfer not confirmed |
|
||||
| TRIM | every curve type, quick and standard modes | line, arc, circle, polyline; ellipse and spline via sampled cutting (`modify.rs:485-656`, `modify2.rs` `trim_other`); a closed spline leaves one open spline |
|
||||
| EXTEND | every open curve | line, arc, polyline, elliptical arc, open spline (runs on along its end tangent, `modify2/curve_edits.rs`) |
|
||||
| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode, Trim/No trim, Multiple; **no ellipse or spline**, no polyline segment with another object (`modify2.rs` `fillet_curves`) |
|
||||
| CHAMFER | lines, polylines, xlines, rays | lines, Distance/Angle, whole polylines (Polyline option); no polyline segment with another object |
|
||||
| BREAK | every curve | line, arc, circle, polyline |
|
||||
| JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline |
|
||||
| LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline |
|
||||
| JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline into a polyline; elliptical arcs on one ellipse into a larger arc or the full ellipse (gaps close); splines with lines, arcs, polylines and elliptical arcs into one exact NURBS (degree elevation, rational arcs; `geom/src/nurbs.rs`); no helixes |
|
||||
| LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline; elliptical arc (arc length along the ellipse); open spline (cut along the curve, or run on along its end tangent) |
|
||||
|
||||
Where line and arc are supported the results are exact.
|
||||
|
||||
@@ -138,7 +138,7 @@ exist for parsing, not for numerics.
|
||||
spline/ellipse offset as fitted splines within a tolerance. 15–25 h.
|
||||
3. A tolerance context relative to the drawing's extents, and a known-answer test suite at
|
||||
origin and at 1e6–1e7 coordinates. 8–12 h.
|
||||
4. FILLET/JOIN/LENGTHEN/EXTEND for ellipses and splines; CHAMFER on polylines. 10–15 h.
|
||||
4. FILLET for ellipses and splines; FILLET/CHAMFER between a polyline segment and another object. 5–8 h.
|
||||
5. Exact areas and MASSPROP for bulge polylines, regions (2D) and hatch loops. 6–10 h.
|
||||
6. Hatch gap tolerance and curved boundaries. 8–12 h.
|
||||
7. Solver: DOF reporting, G2 Smooth, ellipse/spline geometry, constraint inference. 20–30 h.
|
||||
@@ -151,3 +151,4 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | First geometry-precision audit: tolerances, curves, intersections, offsets, modify commands, measurement, hatch, solver |
|
||||
| 2026-10-11 | minor | EXTEND, JOIN and LENGTHEN on elliptical arcs and splines; closed splines trim to one piece; trim/extend section 45% → 55% (#466) |
|
||||
Reference in new issue
Block a user