From 0f8f4864bab91ed8de2ad9e0c8b61d1ce93ffda3 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 8 Aug 2026 16:55:07 +0300 Subject: [PATCH] refactor(spline): evaluate splines with cadkernel, weights and all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spline path ran on truck's BSplineCurve, which is not rational. Weights were dropped on the way in and cleared on every split, so a NURBS circle — which is how most circles arrive once they have been through a modeller — was evaluated as a parabola and stayed one after a break. cadkernel's NurbsCurve carries the weights and applies them in homogeneous coordinates, and `spline_cut` splits by raising the cut to full knot multiplicity, so both halves are exact and both stay rational. `spline_ops` keeps the knot-domain call shapes the commands were written against — `spline_range` replaces reaching for `range_tuple`, `spline_cut` replaces `cut` plus the rebuild — so TRIM, BREAK, LENGTHEN and OFFSET changed only where they named those. Knot vectors for a rebuilt control polygon come from the kernel too, so the clamped uniform vector is defined once rather than assumed in two places. truck is now absent from the whole spline path. It remains for 3D solids. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 +- src/modules/draw/modify/break_cmd.rs | 35 +++-- src/modules/draw/modify/lengthen.rs | 43 +++---- src/modules/draw/modify/offset.rs | 4 +- src/modules/draw/modify/spline_ops.rs | 178 ++++++++++++-------------- src/modules/draw/modify/trim.rs | 75 +++++------ 6 files changed, 154 insertions(+), 183 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ee3226e9..2924cbbff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,7 +945,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git#feed535a0f72f9479d618a4999dba9bafcea250f" +source = "git+https://github.com/HakanSeven12/cadkernel.git#33aa850ac2a27fdb2b305a67df27cb0eae23acc2" dependencies = [ "cavalier_contours", ] diff --git a/src/modules/draw/modify/break_cmd.rs b/src/modules/draw/modify/break_cmd.rs index 779de52f7..a236baf89 100644 --- a/src/modules/draw/modify/break_cmd.rs +++ b/src/modules/draw/modify/break_cmd.rs @@ -7,9 +7,7 @@ // // BREAK @ (at-sign as second point) → Break at a single point (splits without gap). -use crate::modules::draw::modify::spline_ops::{ - bspline_to_spline, spline_nearest_t, spline_to_bspline, -}; +use crate::modules::draw::modify::spline_ops::{spline_cut, spline_nearest_t, spline_range}; use acadrust::entities::{ Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt, }; @@ -17,7 +15,6 @@ use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; use crate::t; -use truck_modeling::base::{BoundedCurve, Cut}; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -307,37 +304,35 @@ fn break_spline(spl: &SplineEnt, p1: DVec3, p2: DVec3) -> Vec { None => return vec![EntityType::Spline(spl.clone())], }; - let bs = match spline_to_bspline(spl) { - Some(b) => b, - None => return vec![EntityType::Spline(spl.clone())], + let Some((t0, t_end)) = spline_range(spl) else { + return vec![EntityType::Spline(spl.clone())]; }; - let (t0, t_end) = bs.range_tuple(); let (ta, tb) = if t1 <= t2 { (t1, t2) } else { (t2, t1) }; // Single-point break (ta ≈ tb): split into two segments at that point. if (tb - ta).abs() < 1e-9 { - let mut piece = bs.clone(); - let right = piece.cut(ta); - return vec![ - EntityType::Spline(bspline_to_spline(&piece, spl)), - EntityType::Spline(bspline_to_spline(&right, spl)), - ]; + return match spline_cut(spl, ta) { + Some((left, right)) => { + vec![EntityType::Spline(left), EntityType::Spline(right)] + } + None => vec![EntityType::Spline(spl.clone())], + }; } // Two-point break: keep [t0..ta] and [tb..t_end], discard middle. let mut result = vec![]; // Left piece [t0, ta] if ta - t0 > 1e-9 { - let mut left = bs.clone(); - let _right = left.cut(ta); // left = [t0, ta] - result.push(EntityType::Spline(bspline_to_spline(&left, spl))); + if let Some((left, _)) = spline_cut(spl, ta) { + result.push(EntityType::Spline(left)); + } } // Right piece [tb, t_end] if t_end - tb > 1e-9 { - let mut full = bs.clone(); - let right = full.cut(tb); // right = [tb, t_end] - result.push(EntityType::Spline(bspline_to_spline(&right, spl))); + if let Some((_, right)) = spline_cut(spl, tb) { + result.push(EntityType::Spline(right)); + } } result } diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 42abf57dd..3044a2dd2 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -7,7 +7,7 @@ // // The entity is modified at whichever end is closest to the pick point. -use crate::modules::draw::modify::spline_ops::{bspline_to_spline, spline_to_bspline}; +use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs}; use acadrust::entities::{ Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt, }; @@ -15,7 +15,6 @@ use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::{DVec3, Vec3}; use crate::t; -use truck_modeling::base::{BoundedCurve, Cut}; use crate::command::{CadCommand, CmdResult}; @@ -411,23 +410,22 @@ fn lengthen_lwpoly(poly: &LwPolyline, pick_pt: Vec3, mode: &LenMode) -> Option Option { - let bs = spline_to_bspline(spl)?; - let (t0, t1) = bs.range_tuple(); + let curve = spline_to_nurbs(spl)?; + let (t0, t1) = curve.domain(); if (t1 - t0).abs() < 1e-12 { return None; } // Approximate arc length via 64-point numerical integration. - use truck_modeling::base::ParametricCurve; let arc_len = { let n = 64usize; let mut len = 0.0f64; for i in 0..n { let ta = t0 + (t1 - t0) * (i as f64 / n as f64); let tb = t0 + (t1 - t0) * ((i + 1) as f64 / n as f64); - let pa = bs.subs(ta); - let pb = bs.subs(tb); - len += (pb.x - pa.x).hypot(pb.y - pa.y); + let pa = curve.point_at_knot(ta); + let pb = curve.point_at_knot(tb); + len += (pb[0] - pa[0]).hypot(pb[1] - pa[1]); } len }; @@ -441,10 +439,10 @@ fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option Option Option Option = offset_pts; let n_ctrl = new_ctrl.len(); - let kv = truck_modeling::KnotVec::uniform_knot(degree, n_ctrl - 1); let mut new_spl = spl.clone(); new_spl.common.handle = Handle::NULL; new_spl.control_points = new_ctrl; - new_spl.knots = kv.iter().copied().collect(); + new_spl.knots = clamped_uniform_knots(degree, n_ctrl); new_spl.fit_points.clear(); new_spl.weights.clear(); Some(EntityType::Spline(new_spl)) diff --git a/src/modules/draw/modify/spline_ops.rs b/src/modules/draw/modify/spline_ops.rs index bf1e79a23..295a4e1ec 100644 --- a/src/modules/draw/modify/spline_ops.rs +++ b/src/modules/draw/modify/spline_ops.rs @@ -1,127 +1,114 @@ // Shared B-spline utilities for modify commands (TRIM, BREAK, OFFSET, LENGTHEN). // -// Provides: -// - spline_to_bspline : acadrust Spline → truck BSplineCurve -// - bspline_to_spline : truck BSplineCurve → acadrust Spline -// - spline_sample_xy : sample the DXF-XY projection at N+1 points (with t params) -// - spline_nearest_t : find the spline parameter t closest to a DXF-XY click -// - spline_pts_wire : world-space wire points for WireModel preview +// The evaluation lives in cadkernel; this file converts a drawing's SPLINE +// entity to and from it and keeps the knot-domain call shapes the commands +// were written against. +// +// Weights are carried through now. The previous path evaluated splines with a +// non-rational B-spline and cleared the weights on every split, which turns a +// piece of a NURBS circle into a piece of a parabola — a change that looks +// fine until it is measured. use acadrust::entities::Spline; +use acadrust::kernel::geom2d::NurbsCurve; use acadrust::types::Vector3; use acadrust::Handle; -use truck_modeling::base::{BoundedCurve, ParametricCurve}; -use truck_modeling::{BSplineCurve, KnotVec, Point3}; // ── Conversion ───────────────────────────────────────────────────────────── -/// Convert an acadrust `Spline` to a truck `BSplineCurve`. -/// Returns `None` if the spline has too few control points or inconsistent data. -pub fn spline_to_bspline(spl: &Spline) -> Option> { - let ctrl_pts: Vec = spl +/// Convert an acadrust `Spline` to a kernel `NurbsCurve`, weights included. +/// +/// Returns `None` when there are too few control points for the degree, which +/// is the one case that has no curve to evaluate. A knot vector of the wrong +/// length is repaired by the kernel rather than rejected. +pub fn spline_to_nurbs(spl: &Spline) -> Option { + let control_points: Vec<[f64; 2]> = spl .control_points .iter() - .map(|p| Point3::new(p.x, p.y, p.z)) + .map(|p| [p.x, p.y]) .collect(); - if ctrl_pts.len() < 2 { - return None; - } - let degree = spl.degree as usize; - let expected_knots = ctrl_pts.len() + degree + 1; - let knot_vec = if spl.knots.len() == expected_knots { - KnotVec::from(spl.knots.clone()) - } else { - KnotVec::uniform_knot(degree, ctrl_pts.len() - 1) - }; - Some(BSplineCurve::new(knot_vec, ctrl_pts)) + let degree = (spl.degree.max(1)) as usize; + let weights = (!spl.weights.is_empty()).then(|| spl.weights.clone()); + NurbsCurve::new(degree, control_points, spl.knots.clone(), weights) } -/// Rebuild an acadrust `Spline` from a trimmed `BSplineCurve`. -/// The `template` spline provides the entity common data, degree, and Z-elevation. -pub fn bspline_to_spline(bs: &BSplineCurve, template: &Spline) -> Spline { +/// Rebuild an acadrust `Spline` from a kernel curve. +/// +/// The `template` supplies entity common data and the Z elevation; splines +/// here are planar, so the kernel's 2D points are lifted back onto it. +pub fn nurbs_to_spline(curve: &NurbsCurve, template: &Spline) -> Spline { let z = template.control_points.first().map(|v| v.z).unwrap_or(0.0); let mut spl = template.clone(); spl.common.handle = Handle::NULL; - spl.degree = bs.degree() as i32; - spl.knots = bs.knot_vec().iter().copied().collect(); - spl.control_points = bs + spl.degree = curve.degree() as i32; + spl.knots = curve.knots().to_vec(); + spl.control_points = curve .control_points() .iter() - .map(|p| Vector3::new(p.x, p.y, z)) + .map(|p| Vector3::new(p[0], p[1], z)) .collect(); - spl.weights.clear(); // not rational after split - spl.fit_points.clear(); // fit points are no longer valid + spl.weights = if curve.is_rational() { + curve.weights().to_vec() + } else { + Vec::new() + }; + // Fit points describe the original interpolation, which a split no longer + // satisfies. + spl.fit_points.clear(); spl } +// ── Domain and splitting ─────────────────────────────────────────────────── + +/// The knot values the spline is defined over, `(start, end)`. +pub fn spline_range(spl: &Spline) -> Option<(f64, f64)> { + Some(spline_to_nurbs(spl)?.domain()) +} + +/// Split a spline at knot value `t`, returning `(left, right)`. +/// +/// Both halves are exact — the kernel raises the cut to full knot +/// multiplicity rather than refitting — and a rational spline stays rational. +/// `None` when the cut falls at or outside an end. +pub fn spline_cut(spl: &Spline, t: f64) -> Option<(Spline, Spline)> { + let curve = spline_to_nurbs(spl)?; + let (start, end) = curve.domain(); + if (end - start).abs() < 1e-12 { + return None; + } + let normalised = (t - start) / (end - start); + let (left, right) = curve.split_at(normalised)?; + Some(( + nurbs_to_spline(&left, spl), + nurbs_to_spline(&right, spl), + )) +} + // ── Sampling ─────────────────────────────────────────────────────────────── -/// Sample the spline's DXF-XY projection at `n+1` evenly-spaced parameter -/// values. Returns `(t_params, xy_points)` both of length `n+1`. +/// Sample the spline's XY projection at `n+1` evenly spaced knot values. +/// Returns `(knot_params, xy_points)`, both of length `n+1`. pub fn spline_sample_xy(spl: &Spline, n: usize) -> (Vec, Vec<[f64; 2]>) { - let bs = match spline_to_bspline(spl) { - Some(b) => b, - None => return (vec![], vec![]), + let Some(curve) = spline_to_nurbs(spl) else { + return (vec![], vec![]); }; - let (t0, t1) = bs.range_tuple(); + let (t0, t1) = curve.domain(); let ts: Vec = (0..=n) .map(|i| t0 + (t1 - t0) * (i as f64 / n as f64)) .collect(); - let pts: Vec<[f64; 2]> = ts - .iter() - .map(|&t| { - let p = bs.subs(t); - [p.x, p.y] - }) - .collect(); + let pts: Vec<[f64; 2]> = ts.iter().map(|t| curve.point_at_knot(*t)).collect(); (ts, pts) } -/// Find the spline parameter `t` (in the knot-vector range [t0, t1]) that is -/// closest to the DXF-XY point `(x, y)`. -/// Uses 64-sample coarse search followed by bisection. +/// The knot parameter closest to the XY point `(x, y)`. pub fn spline_nearest_t(spl: &Spline, x: f64, y: f64) -> Option { - let bs = spline_to_bspline(spl)?; - let (t0, t1) = bs.range_tuple(); - let n = 64usize; - let step = (t1 - t0) / n as f64; - - // Coarse search - let mut best_t = t0; - let mut best_d = f64::MAX; - for i in 0..=n { - let t = t0 + i as f64 * step; - let p = bs.subs(t); - let d = (p.x - x).powi(2) + (p.y - y).powi(2); - if d < best_d { - best_d = d; - best_t = t; - } - } - - // Bisection refinement in [best_t - step, best_t + step] - let lo = (best_t - step).max(t0); - let hi = (best_t + step).min(t1); - let mut a = lo; - let mut b = hi; - for _ in 0..32 { - let m1 = a + (b - a) / 3.0; - let m2 = b - (b - a) / 3.0; - let p1 = bs.subs(m1); - let p2 = bs.subs(m2); - let d1 = (p1.x - x).powi(2) + (p1.y - y).powi(2); - let d2 = (p2.x - x).powi(2) + (p2.y - y).powi(2); - if d1 < d2 { - b = m2; - } else { - a = m1; - } - } - Some((a + b) * 0.5) + let curve = spline_to_nurbs(spl)?; + let (t0, t1) = curve.domain(); + let normalised = curve.parameter_at([x, y]); + Some(t0 + normalised * (t1 - t0)) } -/// Returns the normalised parameter in [0, 1] given an actual B-spline -/// parameter `t_actual` and the range `[t0, t1]`. +/// Normalise an actual knot parameter into `0..=1` over `[t0, t1]`. pub fn t_to_rel(t_actual: f64, t0: f64, t1: f64) -> f64 { if (t1 - t0).abs() < 1e-12 { return 0.0; @@ -131,19 +118,22 @@ pub fn t_to_rel(t_actual: f64, t0: f64, t1: f64) -> f64 { // ── Wire preview ─────────────────────────────────────────────────────────── -/// World-space wire points for a Spline (samples 64 segments). -/// Drawing plane is world XY: DXF (x, y) → world (x, y), z = elevation. +/// World-space wire points for a Spline. +/// +/// Sampled per knot span rather than uniformly, so a curve whose knots bunch +/// up is cut finely where its shape actually changes. pub fn spline_pts_wire(spl: &Spline) -> Vec<[f32; 3]> { - let (_, pts) = spline_sample_xy(spl, 64); - if pts.is_empty() { + let Some(curve) = spline_to_nurbs(spl) else { return vec![]; - } + }; let elev = spl .control_points .first() .map(|v| v.z as f32) .unwrap_or(0.0); - pts.iter() + curve + .tessellate(16) + .iter() .map(|p| [p[0] as f32, p[1] as f32, elev]) .collect() } diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index 3354decc5..b34062b71 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -28,12 +28,12 @@ use acadrust::entities::{ use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; -use truck_modeling::base::{BoundedCurve, Cut, ParametricCurve}; +use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots; use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::modify::spline_ops::{ - bspline_to_spline, spline_nearest_t, spline_pts_wire, spline_sample_xy, spline_to_bspline, - t_to_rel, + spline_cut, spline_nearest_t, spline_pts_wire, spline_range, spline_sample_xy, + spline_to_nurbs, t_to_rel, }; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; @@ -943,11 +943,9 @@ fn extend_ellipse(orig: &EllipseEnt, t_click: f64, geos: &[Geo]) -> Option Vec { - let bs = match spline_to_bspline(spl) { - Some(b) => b, - None => return vec![], + let Some((t0, t1)) = spline_range(spl) else { + return vec![]; }; - let (t0, t1) = bs.range_tuple(); let range = t1 - t0; if range < 1e-12 { return vec![]; @@ -974,11 +972,9 @@ fn spline_seg_ts(spl: &SplineEnt, target: Handle, geos: &[Geo]) -> Vec { /// Trim a Spline entity. Returns surviving spline pieces (one or two). fn trim_spline(spl: &SplineEnt, ts: &[f64], t_click: f64) -> Vec { - let bs = match spline_to_bspline(spl) { - Some(b) => b, - None => return vec![], + let Some((t0, t1)) = spline_range(spl) else { + return vec![]; }; - let (t0, t1) = bs.range_tuple(); trim_intervals(ts, t_click) .into_iter() @@ -988,11 +984,15 @@ fn trim_spline(spl: &SplineEnt, ts: &[f64], t_click: f64) -> Vec { if t_hi - t_lo < 1e-9 { return None; } - let mut piece = bs.clone(); - let right = piece.cut(t_lo); // piece = [t0..t_lo] (discarded), right = [t_lo..t1] - let mut right = right; - let _tail = right.cut(t_hi); // right = [t_lo..t_hi], _tail discarded - Some(EntityType::Spline(bspline_to_spline(&right, spl))) + // Cut away everything before the interval, then everything after + // it, leaving [t_lo, t_hi]. + let (_, after_low) = spline_cut(spl, t_lo)?; + let kept = match spline_cut(&after_low, t_hi) { + Some((middle, _)) => middle, + // The interval reaches the far end, so nothing is left to cut. + None => after_low, + }; + Some(EntityType::Spline(kept)) }) .collect() } @@ -1003,22 +1003,22 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option1 virtual). // For splines we simply find whether the start (t=0) or end (t=1) is closer // to the click, then walk along that tangent direction to the nearest boundary. - let bs = spline_to_bspline(spl)?; - let (t0, t1) = bs.range_tuple(); + let curve = spline_to_nurbs(spl)?; + let (t0, t1) = curve.domain(); let extend_end = t_click >= 0.5; // Tangent at the endpoint (numerical, Δ = 1e-4 of range) let delta = (t1 - t0) * 1e-4; let (ep_t, tang_dir) = if extend_end { - let p0 = bs.subs(t1 - delta); - let p1 = bs.subs(t1); - (t1, [p1.x - p0.x, p1.y - p0.y]) + let p0 = curve.point_at_knot(t1 - delta); + let p1 = curve.point_at_knot(t1); + (t1, [p1[0] - p0[0], p1[1] - p0[1]]) } else { - let p0 = bs.subs(t0); - let p1 = bs.subs(t0 + delta); - (t0, [p0.x - p1.x, p0.y - p1.y]) // reverse for "before start" + let p0 = curve.point_at_knot(t0); + let p1 = curve.point_at_knot(t0 + delta); + (t0, [p0[0] - p1[0], p0[1] - p1[1]]) // reverse for "before start" }; - let ep = bs.subs(ep_t); + let ep = curve.point_at_knot(ep_t); let (dx, dy) = (tang_dir[0], tang_dir[1]); let len = (dx * dx + dy * dy).sqrt(); if len < 1e-12 { @@ -1027,14 +1027,14 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option 1e-6).reduce(f64::min)?; - let hit_x = ep.x + best_t * (ray_end_x - ep.x) * TRIM_EXTENT; - let hit_y = ep.y + best_t * (ray_end_y - ep.y) * TRIM_EXTENT; + let hit_x = ep[0] + best_t * (ray_end_x - ep[0]) * TRIM_EXTENT; + let hit_y = ep[1] + best_t * (ray_end_y - ep[1]) * TRIM_EXTENT; // Add a new control point at the hit location by appending/prepending. let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0); @@ -1053,8 +1053,7 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option { let t_click = spline_nearest_t(s, px, py) .and_then(|t_actual| { - let bs = spline_to_bspline(s)?; - let (t0, t1) = bs.range_tuple(); + let (t0, t1) = spline_range(s)?; Some(t_to_rel(t_actual, t0, t1)) }) .unwrap_or(0.5); @@ -3423,8 +3420,7 @@ impl CadCommand for TrimCommand { } let t_click = spline_nearest_t(s, pt.x as f64, pt.y as f64) .and_then(|t_actual| { - let bs = spline_to_bspline(s)?; - let (t0, t1) = bs.range_tuple(); + let (t0, t1) = spline_range(s)?; Some(t_to_rel(t_actual, t0, t1)) }) .unwrap_or(0.5); @@ -3918,8 +3914,7 @@ impl CadCommand for ExtendCommand { Some(EntityType::Spline(s)) => { let t_click = spline_nearest_t(s, pt.x as f64, pt.y as f64) .and_then(|t_actual| { - let bs = spline_to_bspline(s)?; - let (t0, t1) = bs.range_tuple(); + let (t0, t1) = spline_range(s)?; Some(t_to_rel(t_actual, t0, t1)) }) .unwrap_or(0.5);