Files
OneCAD-Draft/src/entities/spline.rs

1420 lines
48 KiB
Rust

use acadrust::entities::Spline;
use cadkernel::geom2d::Curve as KernelCurve;
use cadkernel::space::{NurbsCurve3, Parameterization};
use crate::command::EntityTransform;
use crate::entities::common::{
dropdown_grip, edit_prop as edit, edit_scalar_prop as edit_scalar, parse_f64,
ro_prop as ro, round_grip, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::t;
pub(crate) fn uses_fit_method(spline: &Spline) -> bool {
let degree = spline.degree.max(1) as usize;
!spline.fit_points.is_empty() && spline.control_points.len() <= degree
}
pub(crate) fn shows_fit_points(spline: &Spline) -> bool {
!spline.fit_points.is_empty() && (!spline.cv_frame_visible || spline.flags.periodic)
}
pub(crate) fn shows_control_vertices(spline: &Spline) -> bool {
!shows_fit_points(spline)
&& !(uses_fit_method(spline) && spline.flags.periodic)
&& control_vertices(spline).len() >= 2
}
pub(crate) fn fit_nurbs3(spline: &Spline) -> Option<NurbsCurve3> {
if !uses_fit_method(spline) {
return None;
}
let mut points: Vec<_> = spline.fit_points.iter()
.map(|point| [point.x, point.y, point.z]).collect();
let parameterization = match spline.knot_parameterization {
1 => Parameterization::Centripetal,
2 => Parameterization::Uniform,
_ => Parameterization::Chord,
};
if spline.flags.periodic {
return NurbsCurve3::interpolate_periodic(&points, parameterization);
}
if spline.flags.closed && points.first() != points.last() {
if let Some(first) = points.first().copied() { points.push(first); }
}
let tangent = |value: acadrust::types::Vector3| {
let value = [value.x, value.y, value.z];
(value.iter().map(|component| component * component).sum::<f64>() > 1e-18)
.then_some(value)
};
NurbsCurve3::interpolate_fit(&points, tangent(spline.begin_tangent),
tangent(spline.end_tangent), parameterization)
}
pub(crate) fn nurbs3(spline: &Spline) -> Option<NurbsCurve3> {
let curve = if uses_fit_method(spline) {
fit_nurbs3(spline)?
} else {
let degree = usize::try_from(spline.degree).ok()?;
let weights = if spline.weights.is_empty() {
vec![1.0; spline.control_points.len()]
} else {
spline.weights.clone()
};
NurbsCurve3::new_strict(
degree,
spline.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(),
spline.knots.clone(),
weights,
)?
};
Some(curve.with_periodicity(spline.flags.closed || spline.flags.periodic))
}
/// NURBS knot locations on a control-point spline, evaluated on its own
/// curve: distinct interior knot values (the clamped ends already snap as
/// endpoints). Fit-method splines invent their knots at the fit nodes, which
/// already snap as endpoints, so they contribute nothing — re-offering them
/// would double every marker.
pub(crate) fn spline_knot_points(spline: &Spline) -> Vec<glam::DVec3> {
const MAX_KNOT_SNAPS: usize = 1024;
if uses_fit_method(spline) {
return Vec::new();
}
let Some(curve) = nurbs3(spline) else {
return Vec::new();
};
let (from, to) = curve.domain();
if !(from.is_finite() && to.is_finite()) || !(to > from) {
return Vec::new();
}
let span = to - from;
let mut knots: Vec<f64> = curve
.knots()
.iter()
.copied()
.filter(|k| k.is_finite() && *k > from + span * 1e-9 && *k < to - span * 1e-9)
.collect();
knots.sort_by(f64::total_cmp);
knots.dedup();
if knots.len() > MAX_KNOT_SNAPS {
let step = knots.len().div_ceil(MAX_KNOT_SNAPS);
knots = knots.into_iter().step_by(step).collect();
}
knots
.into_iter()
.map(|k| glam::DVec3::from_array(curve.point_at_knot(k)))
.filter(|p| p.is_finite())
.collect()
}
pub(crate) fn replace_with_nurbs(spline: &mut Spline, curve: &NurbsCurve3) {
let mut fit_points = spline.fit_points.clone();
spline.degree = curve.degree() as i32;
spline.knots = curve.knots().to_vec();
spline.control_points = curve.control_points().iter()
.map(|point| acadrust::types::Vector3::new(point[0], point[1], point[2])).collect();
spline.weights = curve.weights().to_vec();
if !fit_points.is_empty() {
if let (Some(first), Some(point)) = (fit_points.first_mut(), spline.control_points.first()) {
*first = *point;
}
if let (Some(last), Some(point)) = (fit_points.last_mut(), spline.control_points.last()) {
*last = *point;
}
}
spline.fit_points = fit_points;
spline.begin_tangent = acadrust::types::Vector3::ZERO;
spline.end_tangent = acadrust::types::Vector3::ZERO;
spline.flags.rational = spline.weights.windows(2)
.any(|pair| (pair[0] - pair[1]).abs() > 1e-12);
spline.flags.closed = false;
spline.flags.periodic = false;
spline.dwg_flags1 &= !1;
spline.dxf_flags &= !(32 | 1024);
}
/// Conservative world bounds of a fit-method spline. Fit points are interpolation
/// constraints, not control-hull vertices, so their box can exclude the curve.
/// The shared kernel reconstructs the same spatial interpolation used by the
/// curve consumers, then its control hull bounds every point of that curve.
pub(crate) fn fit_geometry_bounds(spline: &Spline) -> Option<cadkernel::brep::Aabb> {
let curve = fit_nurbs3(spline)?;
cadkernel::brep::Aabb::around(curve.control_points().iter().copied())
}
fn to_render(spl: &Spline) -> RenderEntity {
let n = spl.control_points.len();
if n < 2 {
// A fit-point spline (DWG scenario 2 / R2013+) stores only the points
// the curve passes through — no control points or knots. Interpolate a
// smooth Catmull-Rom curve through them and hand it back as a dense
// polyline so it still draws.
if spl.fit_points.len() >= 2 {
// Drawn from the same interpolation TRIM and snap use, so that
// what is on screen is the curve those commands cut. The two had
// parted: this drew a C² cubic solved here (or a Catmull-Rom for a
// closed spline), while the kernel interpolated its own — near
// enough to look right and far enough apart that a trim landed
// beside the line it was aimed at.
let planar = crate::entities::curve::spline_curve(spl);
let pts = match &planar {
Some(curve) => crate::entities::curve::curve_points(curve),
// A fit spline through points in space is not a planar curve,
// so the kernel has nothing to say about it and the solve
// here remains the only description of its shape.
None if spl.flags.periodic => periodic_fit_spline_polyline(spl),
None if spl.flags.closed => {
catmull_rom_polyline(&spl.fit_points, true)
}
None => fit_spline_polyline(spl),
};
let snap = planar
.as_ref()
.map(crate::entities::curve::snap_from)
.unwrap_or_default();
// Fit points are on the curve, so they stay on offer as ends in
// their own right — but never through `key_vertices`, whose
// consecutive entries the snap engine joins and offers the middle
// of. The chord between two fit points of a curvy spline does not
// run along it.
let mut snap_pts = snap.snap_pts;
snap_pts.extend(spl.fit_points.iter().map(|p| {
(
glam::DVec3::new(p.x, p.y, p.z),
crate::scene::model::wire_model::SnapHint::Endpoint,
)
}));
snap_pts.extend(
spline_knot_points(spl)
.into_iter()
.map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)),
);
let key_vertices = if planar.is_some() {
Vec::new()
} else {
spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect()
};
return RenderEntity {
pick_tris: Vec::new(),
object: RenderObject::Lines(pts),
snap_pts,
tangent_geoms: vec![],
key_vertices,
fill_tris: vec![],
};
}
return RenderEntity {
pick_tris: Vec::new(),
object: RenderObject::Lines(Vec::new()),
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
};
}
// Snap sources from the spline's own curve. A spline is not a chain of
// straight segments, so nothing goes in `key_vertices`: consecutive
// entries there are joined and their midpoints offered, and the chord
// between two fit points of a curvy spline does not run along it.
//
// Fit points are on the curve and stay on offer, as ends in their own
// right. Control points are not on the curve and are dropped — snapping
// to one put the cursor in empty space beside the geometry.
let curve_snap = crate::entities::curve::spline_curve(spl)
.map(|curve| crate::entities::curve::snap_from(&curve));
let (snap_pts, key_vertices) = match curve_snap {
Some(snap) => {
let mut points = snap.snap_pts;
if uses_fit_method(spl) {
points.extend(spl.fit_points.iter().map(|p| {
(
glam::DVec3::new(p.x, p.y, p.z),
crate::scene::model::wire_model::SnapHint::Endpoint,
)
}));
}
points.extend(
spline_knot_points(spl)
.into_iter()
.map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)),
);
(points, Vec::new())
}
// A spline through points in space is not a planar curve, so the
// kernel has nothing to say about it. Its stored points remain the
// only thing to offer.
None => {
let source = if uses_fit_method(spl) {
&spl.fit_points
} else {
&spl.control_points
};
(
Vec::new(),
source.iter().map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>(),
)
}
};
// Sampled through the spline's own curve — the same definition EXTRUDE
// and REVOLVE read — and closed back onto its start when the flags say
// it is periodic and the two ends do not already meet.
let is_closed = spl.flags.closed || spl.flags.periodic;
let mut points = crate::entities::curve::spline_curve(spl)
.map(|planar| crate::entities::curve::curve_points(&planar))
.unwrap_or_else(|| measurement_polyline(spl));
if is_closed {
if let (Some(first), Some(last)) = (points.first().copied(), points.last().copied()) {
let gap = ((last[0] - first[0]).powi(2)
+ (last[1] - first[1]).powi(2)
+ (last[2] - first[2]).powi(2))
.sqrt();
if gap > 1e-6 {
points.push(first);
}
}
}
let object = RenderObject::Lines(points);
RenderEntity {
pick_tris: Vec::new(),
object,
snap_pts,
tangent_geoms: vec![],
key_vertices,
fill_tris: vec![],
}
}
pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> {
if uses_fit_method(spl) {
if spl.fit_points.len() < 2 {
return spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect();
}
return if spl.flags.periodic {
periodic_fit_spline_polyline(spl)
} else if spl.flags.closed {
catmull_rom_polyline(&spl.fit_points, true)
} else {
fit_spline_polyline(spl)
};
}
let count = spl.control_points.len();
if count < 2 {
return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect();
}
let degree = spl.degree.max(0) as usize;
if degree == 0 || degree >= count {
return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect();
}
// The kernel's space curve holds rational and polynomial curves alike.
let controls: Vec<[f64; 3]> = spl
.control_points
.iter()
.map(|point| [point.x, point.y, point.z])
.collect();
let weights = (spl.weights.len() == count).then(|| {
spl.weights
.iter()
.map(|weight| if weight.abs() < 1e-12 { 1.0 } else { *weight })
.collect()
});
match NurbsCurve3::new(degree, controls, spl.knots.clone(), weights) {
Some(curve) => {
curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)
}
None => spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(),
}
}
/// Sample a Catmull-Rom spline through `pts` into a dense polyline. The curve
/// passes through every input point; open ends use reflected phantom points so
/// they don't kink, closed curves wrap around.
fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[f64; 3]> {
let n = pts.len();
if n < 2 {
return pts.iter().map(|p| [p.x, p.y, p.z]).collect();
}
let get = |i: isize| -> [f64; 3] {
let j = if closed {
let m = n as isize;
(((i % m) + m) % m) as usize
} else {
i.clamp(0, n as isize - 1) as usize
};
[pts[j].x, pts[j].y, pts[j].z]
};
let seg_count = if closed { n } else { n - 1 };
let mut out = Vec::new();
for seg in 0..seg_count {
let p1 = get(seg as isize);
let p2 = get(seg as isize + 1);
// Reflect at open ends so the tangent isn't pulled toward a clamped dup.
let p0 = if !closed && seg == 0 {
[
2.0 * p1[0] - p2[0],
2.0 * p1[1] - p2[1],
2.0 * p1[2] - p2[2],
]
} else {
get(seg as isize - 1)
};
let p3 = if !closed && seg == seg_count - 1 {
[
2.0 * p2[0] - p1[0],
2.0 * p2[1] - p1[1],
2.0 * p2[2] - p1[2],
]
} else {
get(seg as isize + 2)
};
let point_at = |t: f64| {
let (t2, t3) = (t * t, t * t * t);
let mut q = [0.0f64; 3];
for k in 0..3 {
q[k] = 0.5
* (2.0 * p1[k]
+ (-p0[k] + p2[k]) * t
+ (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2
+ (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3);
}
q
};
let tangent_at = |t: f64| {
let mut q = [0.0; 3];
for k in 0..3 {
let a = -p0[k] + p2[k];
let b = 2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k];
let c = -p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k];
q[k] = 0.5 * (a + 2.0 * b * t + 3.0 * c * t * t);
}
q
};
let sampled = cadkernel::tessellation::sample_curve3_angle(
point_at,
tangent_at,
cadkernel::tessellation::DEFAULT_ANGLE,
);
out.extend(sampled.into_iter().skip(usize::from(seg > 0)));
}
out
}
/// Interpolate an open fit-point spline into a dense polyline: the C² cubic that
/// passes through every fit point, clamped to the stored start/end tangents when
/// present (natural end otherwise), so its ends follow the specified tangents
/// instead of the local slopes Catmull-Rom would use.
fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> {
let p: Vec<[f64; 3]> = spl.fit_points.iter().map(|q| [q.x, q.y, q.z]).collect();
let n = p.len();
if n < 2 {
return p;
}
let Some((h, slopes)) = fit_spline_slopes(spl, &p) else {
return p;
};
// Evaluate each segment as a cubic Hermite (dP/du = slope · h_i).
let mut out = Vec::new();
for i in 0..n - 1 {
let point_at = |u: f64| {
let (u2, u3) = (u * u, u * u * u);
let h00 = 2.0 * u3 - 3.0 * u2 + 1.0;
let h10 = u3 - 2.0 * u2 + u;
let h01 = -2.0 * u3 + 3.0 * u2;
let h11 = u3 - u2;
let mut q = [0.0f64; 3];
for k in 0..3 {
let m0 = slopes[k][i] * h[i];
let m1 = slopes[k][i + 1] * h[i];
q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1;
}
q
};
let tangent_at = |u: f64| {
let u2 = u * u;
let (h00, h10, h01, h11) = (
6.0 * u2 - 6.0 * u,
3.0 * u2 - 4.0 * u + 1.0,
-6.0 * u2 + 6.0 * u,
3.0 * u2 - 2.0 * u,
);
let mut q = [0.0; 3];
for k in 0..3 {
let m0 = slopes[k][i] * h[i];
let m1 = slopes[k][i + 1] * h[i];
q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1;
}
q
};
let sampled = cadkernel::tessellation::sample_curve3_angle(
point_at,
tangent_at,
cadkernel::tessellation::DEFAULT_ANGLE,
);
out.extend(sampled.into_iter().skip(usize::from(i > 0)));
}
out
}
fn periodic_fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> {
let points: Vec<[f64; 3]> = spl
.fit_points
.iter()
.map(|point| [point.x, point.y, point.z])
.collect();
let parameterization = match spl.knot_parameterization {
2 => Parameterization::Uniform,
1 => Parameterization::Centripetal,
_ => Parameterization::Chord,
};
NurbsCurve3::interpolate_periodic(&points, parameterization)
.map(|curve| curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE))
.unwrap_or_else(|| catmull_rom_polyline(&spl.fit_points, true))
}
/// Slopes used by spatial fit interpolation.
fn fit_spline_slopes(spl: &Spline, p: &[[f64; 3]]) -> Option<(Vec<f64>, [Vec<f64>; 3])> {
let n = p.len();
if n < 2 {
return None;
}
// Parameterise the fit points (unnormalised, so a unit end tangent — how the
// tangents are stored — is a consistent dP/dt). Match the spline's knot
// parameterisation: 2 = uniform, 1 = centripetal (√chord), else chord.
let dist = |a: [f64; 3], b: [f64; 3]| {
let (dx, dy, dz) = (b[0] - a[0], b[1] - a[1], b[2] - a[2]);
(dx * dx + dy * dy + dz * dz).sqrt()
};
let mut t = vec![0.0f64; n];
for i in 1..n {
let d = dist(p[i - 1], p[i]).max(1e-9);
let step = match spl.knot_parameterization {
2 => 1.0,
1 => d.sqrt(),
_ => d,
};
t[i] = t[i - 1] + step;
}
let h: Vec<f64> = (0..n - 1).map(|i| (t[i + 1] - t[i]).max(1e-9)).collect();
let nonzero = |v: &acadrust::types::Vector3| v.x * v.x + v.y * v.y + v.z * v.z > 1e-18;
let begin = nonzero(&spl.begin_tangent).then(|| {
[
spl.begin_tangent.x,
spl.begin_tangent.y,
spl.begin_tangent.z,
]
});
let end = nonzero(&spl.end_tangent)
.then(|| [spl.end_tangent.x, spl.end_tangent.y, spl.end_tangent.z]);
// Solve for the knot slopes m_i = dP/dt per coordinate. The tridiagonal is
// the C² continuity system; the end rows are the clamped tangent (m fixed)
// or the natural condition (S'' = 0) when no tangent is stored.
let mut slopes = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]];
for k in 0..3 {
let seg_slope = |i: usize| (p[i + 1][k] - p[i][k]) / h[i];
let (mut a, mut b, mut c, mut d) = (vec![0.0; n], vec![0.0; n], vec![0.0; n], vec![0.0; n]);
match begin {
Some(bt) => {
b[0] = 1.0;
d[0] = bt[k];
}
None => {
b[0] = 2.0;
c[0] = 1.0;
d[0] = 3.0 * seg_slope(0);
}
}
for i in 1..n - 1 {
a[i] = h[i];
b[i] = 2.0 * (h[i - 1] + h[i]);
c[i] = h[i - 1];
d[i] = 3.0 * (h[i] * seg_slope(i - 1) + h[i - 1] * seg_slope(i));
}
match end {
Some(et) => {
b[n - 1] = 1.0;
d[n - 1] = et[k];
}
None => {
a[n - 1] = 1.0;
b[n - 1] = 2.0;
d[n - 1] = 3.0 * seg_slope(n - 2);
}
}
thomas_solve(&a, &b, &c, &mut d);
slopes[k] = d;
}
Some((h, slopes))
}
/// In-place Thomas solve for a tridiagonal system (`a` sub-, `b` main-, `c`
/// super-diagonal; `d` right-hand side, overwritten with the solution).
fn thomas_solve(a: &[f64], b: &[f64], c: &[f64], d: &mut [f64]) {
let n = d.len();
let mut cp = vec![0.0f64; n];
cp[0] = c[0] / b[0];
d[0] /= b[0];
for i in 1..n {
let m = b[i] - a[i] * cp[i - 1];
cp[i] = c[i] / m;
d[i] = (d[i] - a[i] * d[i - 1]) / m;
}
for i in (0..n - 1).rev() {
d[i] -= cp[i] * d[i + 1];
}
}
pub(crate) const SPLINE_MODE_GRIP_ID: usize = usize::MAX - 1;
fn control_vertices(spline: &Spline) -> Vec<acadrust::types::Vector3> {
if !uses_fit_method(spline) {
return spline.control_points.clone();
}
let Some(planar) = crate::entities::curve::spline_curve(spline) else {
return Vec::new();
};
let KernelCurve::Nurbs(curve) = &planar.curve else {
return Vec::new();
};
curve
.control_points()
.iter()
.map(|point| {
let point = planar.plane.point_at(*point);
acadrust::types::Vector3::new(point[0], point[1], point[2])
})
.collect()
}
pub(crate) fn control_vertex_count(spline: &Spline) -> usize {
control_vertices(spline).len()
}
fn choice_prop(
label: &str,
field: &'static str,
selected: &str,
options: &[&str],
) -> Property {
Property {
label: label.into(),
field,
value: PropValue::Choice {
selected: selected.to_string(),
options: options.iter().map(|option| option.to_string()).collect(),
},
}
}
fn index_prop(label: &str, field: &'static str, index: usize, count: usize) -> Property {
Property {
label: label.into(),
field,
value: PropValue::EditText(if count == 0 {
"0".to_string()
} else {
(index + 1).to_string()
}),
}
}
fn convert_to_control_method(spline: &mut Spline) -> bool {
if !uses_fit_method(spline) {
let valid = spline.control_points.len() >= 2;
if valid {
spline.fit_points.clear();
spline.begin_tangent = acadrust::types::Vector3::ZERO;
spline.end_tangent = acadrust::types::Vector3::ZERO;
spline.dwg_flags1 &= !1;
spline.dxf_flags &= !(32 | 1024);
}
return valid;
}
let Some(planar) = crate::entities::curve::spline_curve(spline) else {
return false;
};
let KernelCurve::Nurbs(curve) = &planar.curve else {
return false;
};
spline.degree = curve.degree() as i32;
spline.knots = curve.knots().to_vec();
spline.control_points = curve
.control_points()
.iter()
.map(|point| {
let point = planar.plane.point_at(*point);
acadrust::types::Vector3::new(point[0], point[1], point[2])
})
.collect();
spline.weights = if curve.is_rational() {
curve.weights().to_vec()
} else {
Vec::new()
};
spline.flags.rational = curve.is_rational();
spline.flags.planar = true;
spline.fit_points.clear();
spline.begin_tangent = acadrust::types::Vector3::ZERO;
spline.end_tangent = acadrust::types::Vector3::ZERO;
true
}
fn convert_to_fit_method(spline: &mut Spline) -> bool {
if uses_fit_method(spline) {
return true;
}
if spline.fit_points.len() < 2 {
return false;
}
spline.control_points.clear();
spline.knots.clear();
spline.weights.clear();
spline.flags.rational = false;
spline.dwg_flags1 |= 1;
spline.dxf_flags |= 32 | 1024;
true
}
fn derived_fit_points(spline: &Spline) -> Vec<acadrust::types::Vector3> {
if spline.flags.closed || spline.flags.periodic {
return Vec::new();
}
let Some(curve) = nurbs3(spline) else {
return Vec::new();
};
let (start, end) = curve.domain();
if !(start.is_finite() && end.is_finite() && end > start) {
return Vec::new();
}
let tolerance = (end - start).abs().max(1.0) * 1.0e-12;
let mut parameters = vec![start];
for knot in curve.knots().iter().copied() {
if knot > start + tolerance
&& knot < end - tolerance
&& parameters
.last()
.is_none_or(|previous| (knot - *previous).abs() > tolerance)
{
parameters.push(knot);
}
}
parameters.push(end);
parameters
.into_iter()
.map(|parameter| {
let point = curve.point_at_knot(parameter);
acadrust::types::Vector3::new(point[0], point[1], point[2])
})
.collect()
}
fn can_show_fit_points(spline: &Spline) -> bool {
spline.fit_points.len() >= 2 || derived_fit_points(spline).len() >= 2
}
fn prepare_fit_point_view(spline: &mut Spline) -> bool {
if spline.fit_points.len() < 2 {
spline.fit_points = derived_fit_points(spline);
}
spline.fit_points.len() >= 2
}
fn grips(spline: &Spline) -> Vec<GripDef> {
let derived_control = control_vertices(spline);
let show_fit = shows_fit_points(spline);
let show_control_vertices = !show_fit
&& !(uses_fit_method(spline) && spline.flags.periodic)
&& derived_control.len() >= 2;
let source = if show_control_vertices {
derived_control
} else if show_fit {
spline.fit_points.clone()
} else {
spline.control_points.clone()
};
let mut grips: Vec<_> = source
.iter()
.enumerate()
.map(|(i, p)| {
let world = glam::DVec3::new(p.x, p.y, p.z);
if show_control_vertices {
round_grip(i, world)
} else {
square_grip(i, world)
}
})
.collect();
if let Some(first) = source.first() {
grips.push(dropdown_grip(
SPLINE_MODE_GRIP_ID,
glam::DVec3::new(first.x, first.y, first.z),
));
}
grips
}
fn properties(spline: &Spline) -> Vec<PropSection> {
let fit_method = uses_fit_method(spline);
let show_fit = shows_fit_points(spline);
let method = if show_fit { "Fit" } else { "Control Vertices" };
let closed = spline.flags.closed || spline.flags.periodic;
let can_show_control = control_vertices(spline).len() >= 2
&& !(fit_method && spline.flags.periodic);
let yes_no = |b: bool| if b { "Yes" } else { "No" };
let knot_param = match spline.knot_parameterization {
0 => "Chord",
1 => "Square Root",
2 => "Uniform",
15 => "Custom",
_ => "Custom",
};
let current = crate::scene::view::dispatch::prop_current_vertex();
let mut data_points = if show_fit {
let count = spline.fit_points.len();
let index = current.min(count.saturating_sub(1));
let point = spline.fit_points.get(index);
vec![
ro(
t!("Number of fit points").as_ref(),
"fit_pt_count",
count.to_string(),
),
index_prop(
t!("Current fit point").as_ref(),
"current_fit_point",
index,
count,
),
edit(
t!("Fit point X").as_ref(),
"fit_pt_x",
point.map(|value| value.x).unwrap_or(0.0),
),
edit(
t!("Fit point Y").as_ref(),
"fit_pt_y",
point.map(|value| value.y).unwrap_or(0.0),
),
edit(
t!("Fit point Z").as_ref(),
"fit_pt_z",
point.map(|value| value.z).unwrap_or(0.0),
),
if matches!(spline.knot_parameterization, 0..=2) {
choice_prop(
t!("Knot parameterization").as_ref(),
"knot_param",
knot_param,
&["Chord", "Square Root", "Uniform"],
)
} else {
ro(
t!("Knot parameterization").as_ref(),
"knot_param",
crate::i18n::translate(knot_param).into_owned(),
)
},
]
} else {
let controls = control_vertices(spline);
let count = controls.len();
let index = current.min(count.saturating_sub(1));
let point = controls.get(index);
let weight = spline.weights.get(index).copied().unwrap_or(1.0);
vec![
ro(
t!("Number of control points").as_ref(),
"ctrl_pt_count",
count.to_string(),
),
index_prop(
t!("Current Control point").as_ref(),
"current_control_point",
index,
count,
),
edit(
t!("Control point X").as_ref(),
"ctrl_pt_x",
point.map(|value| value.x).unwrap_or(0.0),
),
edit(
t!("Control point Y").as_ref(),
"ctrl_pt_y",
point.map(|value| value.y).unwrap_or(0.0),
),
edit(
t!("Control point Z").as_ref(),
"ctrl_pt_z",
point.map(|value| value.z).unwrap_or(0.0),
),
edit_scalar(t!("Weight").as_ref(), "weight", weight),
]
};
data_points.push(if can_show_control {
choice_prop(
t!("CV frame").as_ref(),
"cv_frame",
if spline.cv_frame_visible { "Show" } else { "Hide" },
&["Hide", "Show"],
)
} else {
ro(t!("CV frame").as_ref(), "cv_frame", t!("Hide").into_owned())
});
let method_prop = if fit_method && can_show_control {
choice_prop(
t!("Method").as_ref(),
"spline_method",
method,
&["Fit", "Control Vertices"],
)
} else {
ro(
t!("Method").as_ref(),
"spline_method",
crate::i18n::translate(method).into_owned(),
)
};
let mut misc = vec![
method_prop,
ro(t!("Degree").as_ref(), "degree", spline.degree.to_string()),
ro(
t!("Closed").as_ref(),
"closed",
crate::i18n::translate(yes_no(closed)).into_owned(),
),
ro(
t!("Periodic").as_ref(),
"periodic",
crate::i18n::translate(yes_no(spline.flags.periodic)).into_owned(),
),
ro(
t!("Planar").as_ref(),
"planar",
crate::i18n::translate(yes_no(crate::entities::curve::spline_is_planar(spline)))
.into_owned(),
),
];
if show_fit && !spline.flags.periodic {
misc.extend([
edit_scalar(
t!("Start tangent vector X").as_ref(),
"start_tan_x",
spline.begin_tangent.x,
),
edit_scalar(
t!("Start tangent vector Y").as_ref(),
"start_tan_y",
spline.begin_tangent.y,
),
edit_scalar(
t!("Start tangent vector Z").as_ref(),
"start_tan_z",
spline.begin_tangent.z,
),
edit_scalar(
t!("End tangent vector X").as_ref(),
"end_tan_x",
spline.end_tangent.x,
),
edit_scalar(
t!("End tangent vector Y").as_ref(),
"end_tan_y",
spline.end_tangent.y,
),
edit_scalar(
t!("End tangent vector Z").as_ref(),
"end_tan_z",
spline.end_tangent.z,
),
]);
}
if show_fit {
misc.push(edit(
t!("Fit tolerance").as_ref(),
"fit_tolerance",
spline.fit_tolerance,
));
}
vec![
PropSection {
title: t!("Data Points").into_owned(),
props: data_points,
},
PropSection {
title: t!("Misc").into_owned(),
props: misc,
},
]
}
fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
match field {
"spline_method" => {
if value == "Fit" {
if prepare_fit_point_view(spline) {
spline.cv_frame_visible = false;
}
} else if value == "Control Vertices" {
if !spline.flags.periodic && control_vertices(spline).len() >= 2 {
spline.cv_frame_visible = true;
}
}
return;
}
"knot_param" => {
if shows_fit_points(spline)
&& !uses_fit_method(spline)
&& !convert_to_fit_method(spline)
{
return;
}
spline.knot_parameterization = match value {
"Chord" => 0,
"Square Root" => 1,
"Uniform" => 2,
_ => return,
};
return;
}
"cv_frame" => {
spline.cv_frame_visible = value == "Show"
&& !(uses_fit_method(spline) && spline.flags.periodic)
&& control_vertices(spline).len() >= 2;
return;
}
_ => {}
}
if uses_fit_method(spline)
&& spline.flags.periodic
&& matches!(
field,
"ctrl_pt_x"
| "ctrl_pt_y"
| "ctrl_pt_z"
| "weight"
| "start_tan_x"
| "start_tan_y"
| "start_tan_z"
| "end_tan_x"
| "end_tan_y"
| "end_tan_z"
)
{
return;
}
if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight") {
if !shows_fit_points(spline) && !spline.fit_points.is_empty() {
if !convert_to_control_method(spline) {
return;
}
} else if uses_fit_method(spline) && !convert_to_control_method(spline) {
return;
}
}
if shows_fit_points(spline)
&& !uses_fit_method(spline)
&& matches!(
field,
"fit_pt_x"
| "fit_pt_y"
| "fit_pt_z"
| "start_tan_x"
| "start_tan_y"
| "start_tan_z"
| "end_tan_x"
| "end_tan_y"
| "end_tan_z"
)
&& !convert_to_fit_method(spline)
{
return;
}
let Some(v) = parse_f64(value) else { return };
let control_index = crate::scene::view::dispatch::prop_current_vertex()
.min(spline.control_points.len().saturating_sub(1));
let fit_index = crate::scene::view::dispatch::prop_current_vertex()
.min(spline.fit_points.len().saturating_sub(1));
match field {
"ctrl_pt_x" => {
if let Some(cp) = spline.control_points.get_mut(control_index) {
cp.x = v;
}
}
"ctrl_pt_y" => {
if let Some(cp) = spline.control_points.get_mut(control_index) {
cp.y = v;
}
}
"ctrl_pt_z" => {
if let Some(cp) = spline.control_points.get_mut(control_index) {
cp.z = v;
}
}
"weight" => {
if v > 0.0 && !spline.control_points.is_empty() {
if spline.weights.len() != spline.control_points.len() {
spline.weights = vec![1.0; spline.control_points.len()];
}
spline.weights[control_index] = v;
spline.flags.rational = spline
.weights
.iter()
.any(|weight| (*weight - spline.weights[0]).abs() > 1e-12);
}
}
"fit_pt_x" => {
if let Some(fp) = spline.fit_points.get_mut(fit_index) {
fp.x = v;
}
}
"fit_pt_y" => {
if let Some(fp) = spline.fit_points.get_mut(fit_index) {
fp.y = v;
}
}
"fit_pt_z" => {
if let Some(fp) = spline.fit_points.get_mut(fit_index) {
fp.z = v;
}
}
"fit_tolerance" => {
if v.is_finite() && v >= 0.0 {
spline.fit_tolerance = v;
}
}
"start_tan_x" | "start_tan_y" | "start_tan_z" => {
match field {
"start_tan_x" => spline.begin_tangent.x = v,
"start_tan_y" => spline.begin_tangent.y = v,
"start_tan_z" => spline.begin_tangent.z = v,
_ => {}
}
}
"end_tan_x" | "end_tan_y" | "end_tan_z" => {
match field {
"end_tan_x" => spline.end_tangent.x = v,
"end_tan_y" => spline.end_tangent.y = v,
"end_tan_z" => spline.end_tangent.z = v,
_ => {}
}
}
_ => {}
}
spline.flags.planar = crate::entities::curve::spline_is_planar(spline);
}
fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) {
if grip_id == SPLINE_MODE_GRIP_ID {
return;
}
// First CV edit makes the displayed kernel controls authoritative.
let editing_fit = shows_fit_points(spline);
if editing_fit && !uses_fit_method(spline) {
if !convert_to_fit_method(spline) {
return;
}
} else if !editing_fit
&& (uses_fit_method(spline) || !spline.fit_points.is_empty())
&& !spline.flags.periodic
{
if !convert_to_control_method(spline) {
return;
}
}
let target = if editing_fit {
spline.fit_points.get_mut(grip_id)
} else {
spline.control_points.get_mut(grip_id)
};
if let Some(cp) = target {
match apply {
GripApply::Absolute(p) => {
cp.x = p.x as f64;
cp.y = p.y as f64;
cp.z = p.z as f64;
}
GripApply::Translate(d) => {
cp.x += d.x as f64;
cp.y += d.y as f64;
cp.z += d.z as f64;
}
}
}
}
fn apply_transform(spline: &mut Spline, t: &EntityTransform) {
if let EntityTransform::Mirror {
p1,
p2,
working_normal,
} = t
{
let transform = crate::scene::view::transform::reflection_about_working_line(
*p1,
*p2,
*working_normal,
);
acadrust::Entity::apply_transform(spline, &transform);
spline.flags.planar = crate::entities::curve::spline_is_planar(spline);
return;
}
crate::scene::view::transform::apply_standard_entity_transform(spline, t, |entity, p1, p2| {
for cp in &mut entity.control_points {
crate::scene::view::transform::reflect_xy_point(&mut cp.x, &mut cp.y, p1, p2);
}
for fp in &mut entity.fit_points {
crate::scene::view::transform::reflect_xy_point(&mut fp.x, &mut fp.y, p1, p2);
}
});
spline.flags.planar = crate::entities::curve::spline_is_planar(spline);
}
impl RenderConvertible for Spline {
fn to_render(&self, _document: &acadrust::CadDocument) -> Option<RenderEntity> {
Some(to_render(self))
}
}
impl crate::entities::traits::Grippable for Spline {
fn grips(&self) -> Vec<GripDef> {
grips(self)
}
fn apply_grip(&mut self, grip_id: usize, apply: GripApply) {
apply_grip(self, grip_id, apply);
}
fn grip_menu(&self, grip_id: usize) -> Vec<crate::scene::model::object::GripMenuItem> {
use crate::scene::model::object::{GripMenuAction, GripMenuItem};
if grip_id == SPLINE_MODE_GRIP_ID {
let fit_method = uses_fit_method(self);
let can_show_control = control_vertices(self).len() >= 2
&& !(fit_method && self.flags.periodic);
let can_show_fit = can_show_fit_points(self);
return if !shows_fit_points(self) {
vec![
can_show_fit.then_some(GripMenuItem {
label: "Fit",
action: GripMenuAction::ShowFit,
}),
can_show_control.then_some(GripMenuItem {
label: "✓ Control Vertices",
action: GripMenuAction::ShowControlVertices,
}),
]
.into_iter()
.flatten()
.collect()
} else {
vec![
Some(GripMenuItem {
label: "✓ Fit",
action: GripMenuAction::ShowFit,
}),
can_show_control.then_some(GripMenuItem {
label: "Control Vertices",
action: GripMenuAction::ShowControlVertices,
}),
]
.into_iter()
.flatten()
.collect()
};
}
vec![GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
}]
}
fn apply_grip_menu(
&mut self,
_grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
) {
use crate::scene::model::object::GripMenuAction as A;
match action {
A::ShowFit => {
if prepare_fit_point_view(self) {
self.cv_frame_visible = false;
return;
}
}
A::ShowControlVertices
if !(uses_fit_method(self) && self.flags.periodic)
&& control_vertices(self).len() >= 2 =>
{
self.cv_frame_visible = true;
return;
}
_ => {}
}
}
}
impl crate::entities::traits::PropertyEditable for Spline {
fn geometry_properties(&self, _text_style_names: &[String]) -> Vec<PropSection> {
properties(self)
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
apply_geom_prop(self, field, value);
}
}
impl crate::entities::traits::Transformable for Spline {
fn apply_transform(&mut self, t: &EntityTransform) {
apply_transform(self, t);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn knot_points_evaluate_distinct_interior_knots() {
let mut spline = Spline::default();
spline.degree = 3;
spline.control_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(1.0, 2.0, 0.0),
acadrust::types::Vector3::new(3.0, 3.0, 1.0),
acadrust::types::Vector3::new(5.0, 2.0, 0.0),
acadrust::types::Vector3::new(6.0, 0.0, 1.0),
];
spline.knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0];
let pts = spline_knot_points(&spline);
assert_eq!(pts.len(), 1, "one interior knot value: {pts:?}");
let curve = nurbs3(&spline).expect("control-point spline builds");
let expected = curve.point_at_knot(0.5);
let got = [pts[0].x, pts[0].y, pts[0].z];
let err = ((got[0] - expected[0]).powi(2)
+ (got[1] - expected[1]).powi(2)
+ (got[2] - expected[2]).powi(2))
.sqrt();
assert!(err < 1e-9, "knot point off the curve: {err}");
}
#[test]
fn fit_method_splines_emit_no_knot_snaps() {
// Interpolated curves invent their own knots at the fit nodes, which
// already snap as endpoints — re-offering them as knots would double
// every marker.
let mut spline = Spline::default();
spline.degree = 3;
spline.fit_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(4.0, 0.0, 1.0),
acadrust::types::Vector3::new(4.0, 3.0, 2.0),
];
assert!(spline_knot_points(&spline).is_empty());
}
#[test]
fn fit_bounds_accept_closed_curves_with_unset_tangents() {
let mut spline = Spline::default();
spline.degree = 3;
spline.flags.closed = true;
spline.control_points.push(acadrust::types::Vector3::ZERO);
spline.fit_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(4.0, 0.0, 1.0),
acadrust::types::Vector3::new(4.0, 3.0, 2.0),
acadrust::types::Vector3::new(0.0, 3.0, 1.0),
];
let bounds = fit_geometry_bounds(&spline).unwrap();
for point in &spline.fit_points {
for (value, min, max) in [
(point.x, bounds.min[0], bounds.max[0]),
(point.y, bounds.min[1], bounds.max[1]),
(point.z, bounds.min[2], bounds.max[2]),
] {
assert!(value >= min - 1e-9 && value <= max + 1e-9);
}
}
}
#[test]
fn fit_tolerance_is_editable_and_rejects_invalid_values() {
let mut spline = Spline::default();
spline.fit_points = vec![
acadrust::types::Vector3::ZERO,
acadrust::types::Vector3::new(1.0, 0.0, 0.0),
];
spline.fit_tolerance = 0.25;
let tolerance = properties(&spline)
.into_iter()
.flat_map(|section| section.props)
.find(|property| property.field == "fit_tolerance")
.unwrap();
assert!(matches!(tolerance.value, PropValue::EditText(_)));
apply_geom_prop(&mut spline, "fit_tolerance", "1.5");
assert_eq!(spline.fit_tolerance, 1.5);
for invalid in ["-1", "NaN", "inf"] {
apply_geom_prop(&mut spline, "fit_tolerance", invalid);
assert_eq!(spline.fit_tolerance, 1.5);
}
}
#[test]
fn tangent_properties_edit_only_the_stored_component() {
let mut spline = Spline::default();
spline.fit_points = vec![
acadrust::types::Vector3::ZERO,
acadrust::types::Vector3::new(1.0, 1.0, 0.0),
];
let start_y = properties(&spline)
.into_iter()
.flat_map(|section| section.props)
.find(|property| property.field == "start_tan_y")
.unwrap();
assert_eq!(start_y.value, PropValue::EditText("0".into()));
apply_geom_prop(&mut spline, "start_tan_y", "2.5");
assert_eq!(
spline.begin_tangent,
acadrust::types::Vector3::new(0.0, 2.5, 0.0)
);
}
#[test]
fn showing_derived_fit_points_does_not_change_the_curve() {
let mut spline = Spline::default();
spline.degree = 3;
spline.control_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(1.0, 3.0, 0.0),
acadrust::types::Vector3::new(4.0, 3.0, 0.0),
acadrust::types::Vector3::new(5.0, 0.0, 0.0),
];
spline.knots = cadkernel::space::clamped_uniform_knots(3, 4);
let before = nurbs3(&spline).unwrap().point_at(0.37);
assert!(prepare_fit_point_view(&mut spline));
spline.cv_frame_visible = false;
assert!(shows_fit_points(&spline));
assert!(!uses_fit_method(&spline));
let after = nurbs3(&spline).unwrap().point_at(0.37);
assert!(
cadkernel::space::Vec3::from(before)
.distance(cadkernel::space::Vec3::from(after))
< 1.0e-12
);
}
}