feat: Spline support for TRIM, EXTEND, OFFSET, BREAK, and LENGTHEN

Add shared spline_ops module with:
- spline_to_bspline / bspline_to_spline: acadrust ↔ truck BSplineCurve conversion
- spline_sample_xy: sample DXF-XY projection at N+1 points with parameters
- spline_nearest_t: nearest-parameter search via coarse+bisection
- spline_pts_wire: world-space wire points for WireModel preview

TRIM: Spline entities can now be used as cutting boundaries (Geo::Spline
sampled into polyline segments) and as entities being trimmed; uses
BSplineCurve::cut() to produce surviving pieces.

EXTEND: extend_spline() shoots a tangent ray from the nearest endpoint
and appends a control point at the first boundary hit.

OFFSET: offset_spline() offsets each sample point along the local
perpendicular normal and rebuilds a new spline from the offset control pts.

BREAK: break_spline() uses spline_nearest_t() + BSplineCurve::cut() to
split the spline at one or two click points.

LENGTHEN: lengthen_spline() uses numerical arc-length integration +
bisection to cut the spline at the parameter corresponding to the new
requested length.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-07 15:00:22 +03:00
commit 083eb5cbb5
7 changed files with 591 additions and 11 deletions

View file

@ -10,13 +10,17 @@
use std::f64::consts::TAU;
use acadrust::entities::{Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, Ray as RayEnt, XLine as XLineEnt};
use acadrust::entities::{Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, Ray as RayEnt, Spline as SplineEnt, XLine as XLineEnt};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
use truck_modeling::base::{BoundedCurve, Cut, ParametricCurve};
use crate::command::{CadCommand, CmdResult};
use crate::modules::IconKind;
use crate::modules::home::modify::spline_ops::{
bspline_to_spline, spline_nearest_t, spline_sample_xy, spline_to_bspline, spline_pts_wire, t_to_rel,
};
use crate::scene::wire_model::WireModel;
// ── Dropdown constants ─────────────────────────────────────────────────────
@ -233,6 +237,11 @@ enum Geo {
t0: f64, // start parameter
t1: f64, // end parameter (may be > 2π if wrapped)
},
/// Spline represented as sampled polyline segments (DXF XY).
Spline {
handle: Handle,
segs: Vec<([f64; 2], [f64; 2])>,
},
}
fn build_geos(entities: &[EntityType]) -> Vec<Geo> {
@ -286,6 +295,14 @@ fn build_geos(entities: &[EntityType]) -> Vec<Geo> {
if t1 <= t0 { t1 += TAU; }
Some(Geo::Ellipse { handle: h, cx: e.center.x, cy: e.center.y, a, b, nx, ny, t0, t1 })
}
EntityType::Spline(s) => {
let (_, pts) = spline_sample_xy(s, 64);
if pts.len() < 2 { return None; }
let segs = pts.windows(2)
.map(|w| ([w[0][0], w[0][1]], [w[1][0], w[1][1]]))
.collect();
Some(Geo::Spline { handle: h, segs })
}
_ => None,
}
})
@ -370,6 +387,18 @@ fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo])
}
}
}
Geo::Spline { handle, segs } => {
if *handle == target { continue; }
for (p1, p2) in segs {
let ex = p2[0] - p1[0];
let ey = p2[1] - p1[1];
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if (-1e-9..=1.0 + 1e-9).contains(&u) && (-1e-9..=1.0 + 1e-9).contains(&t) {
ts.push(t.clamp(0.0, 1.0));
}
}
}
}
}
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
@ -450,6 +479,22 @@ fn arc_seg_ts(
// Sample the arc and find where it crosses the ellipse boundary.
ellipse_boundary_angles_for_arc(cx, cy, r, a0, a1, *ecx, *ecy, *ea, *eb, *nx, *ny, *et0, *et1)
}
Geo::Spline { handle, segs } => {
if *handle == target { continue; }
// Intersect arc circle with each spline segment.
let mut hit_angles = vec![];
for (p1, p2) in segs {
let ldx = p2[0] - p1[0];
let ldy = p2[1] - p1[1];
for u in lc(p1[0], p1[1], ldx, ldy, cx, cy, r) {
if !(-1e-9..=1.0 + 1e-9).contains(&u) { continue; }
let ix = p1[0] + u * ldx;
let iy = p1[1] + u * ldy;
hit_angles.push((iy - cy).atan2(ix - cx));
}
}
hit_angles
}
};
for a in angles {
if in_arc(a, a0, a1) {
@ -657,6 +702,20 @@ fn ellipse_seg_ts(
}
}
}
Geo::Spline { handle, segs } => {
if *handle == target { continue; }
// Ellipse × Spline: sign-change detection on each spline segment
for (p1, p2) in segs {
let ldx = p2[0] - p1[0];
let ldy = p2[1] - p1[1];
for (s, t_ell) in le(p1[0], p1[1], ldx, ldy, cx, cy, a, b, nx, ny) {
if !(-1e-9..=1.0 + 1e-9).contains(&s) { continue; }
if in_arc(t_ell, t0, t1) {
ts.push(arc_t(t_ell, t0, t0 + span));
}
}
}
}
}
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
@ -734,6 +793,112 @@ fn ellipse_pts(cx: f64, cy: f64, a: f64, b: f64, nx: f64, ny: f64, t0: f64, t1:
}).collect()
}
// ── Spline trim / extend ──────────────────────────────────────────────────
/// Find normalised t-params ∈ [0,1] where a Spline intersects boundary geos.
/// Uses sampled polyline segments for intersection detection.
fn spline_seg_ts(spl: &SplineEnt, target: Handle, geos: &[Geo]) -> Vec<f64> {
let bs = match spline_to_bspline(spl) { Some(b) => b, None => return vec![] };
let (t0, t1) = bs.range_tuple();
let range = t1 - t0;
if range < 1e-12 { return vec![]; }
let (ts_spl, pts) = spline_sample_xy(spl, 64);
let mut out = vec![];
for i in 0..pts.len().saturating_sub(1) {
let ax = pts[i][0];
let ay = pts[i][1];
let bx = pts[i + 1][0];
let by = pts[i + 1][1];
let seg_ts = line_seg_ts(ax, ay, bx, by, target, geos);
for u in seg_ts {
// u is a t-param on this polyline segment; map to spline knot range, then normalise.
let t_spline = ts_spl[i] + u * (ts_spl[i + 1] - ts_spl[i]);
out.push(t_to_rel(t_spline, t0, t1));
}
}
out.sort_by(|a, b| a.partial_cmp(b).unwrap());
out.dedup_by(|a, b| (*a - *b).abs() < 1e-4);
out
}
/// Trim a Spline entity. Returns surviving spline pieces (one or two).
fn trim_spline(spl: &SplineEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
let bs = match spline_to_bspline(spl) { Some(b) => b, None => return vec![] };
let (t0, t1) = bs.range_tuple();
trim_intervals(ts, t_click)
.into_iter()
.filter_map(|(ta, tb)| {
let t_lo = t0 + ta * (t1 - t0);
let t_hi = t0 + tb * (t1 - t0);
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)))
})
.collect()
}
/// Extend a Spline toward the nearest boundary (nearest endpoint to pick).
fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option<EntityType> {
// Sample spline and treat it like a polyline; look for intersections beyond
// the current start (t<0 virtual) or end (t>1 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 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])
} 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 ep = bs.subs(ep_t);
let (dx, dy) = (tang_dir[0], tang_dir[1]);
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 { return None; }
let (dx, dy) = (dx / len, dy / len);
// Shoot a ray from the endpoint along the tangent and find nearest boundary.
let ray_end_x = ep.x + dx * TRIM_EXTENT;
let ray_end_y = ep.y + dy * TRIM_EXTENT;
let seg_ts = line_seg_ts(ep.x, ep.y, ray_end_x, ray_end_y, spl.common.handle, geos);
let best_t = seg_ts.into_iter()
.filter(|&t| t > 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;
// 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);
let mut new_spl = spl.clone();
new_spl.common.handle = Handle::NULL;
new_spl.fit_points.clear();
if extend_end {
new_spl.control_points.push(acadrust::types::Vector3::new(hit_x, hit_y, z));
} else {
new_spl.control_points.insert(0, acadrust::types::Vector3::new(hit_x, hit_y, z));
}
// Rebuild knots (uniform) for the extended control polygon.
let degree = new_spl.degree as usize;
let n = new_spl.control_points.len();
let kv = truck_modeling::KnotVec::uniform_knot(degree, n - 1);
new_spl.knots = kv.iter().copied().collect();
Some(EntityType::Spline(new_spl))
}
// ── Trim helpers ──────────────────────────────────────────────────────────
/// Remove the t-interval containing `t_click` from sorted ts. Returns surviving pieces.
@ -911,6 +1076,18 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option<EntityType>
if !extend_end && t < -1e-6 && t > best_t { best_t = t; }
}
}
Geo::Spline { handle, segs } => {
if *handle == target { continue; }
for (p1, p2) in segs {
let ex = p2[0] - p1[0];
let ey = p2[1] - p1[1];
if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) {
if !(-1e-9..=1.0 + 1e-9).contains(&u) { continue; }
if extend_end && t > 1.0 + 1e-6 && t < best_t { best_t = t; }
if !extend_end && t < -1e-6 && t > best_t { best_t = t; }
}
}
}
}
}
@ -1090,6 +1267,7 @@ fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> {
if t1 <= t0 { t1 += TAU; }
ellipse_pts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, e.center.z)
}
EntityType::Spline(s) => spline_pts_wire(s),
// For preview, show a 20-unit section of semi-infinite results
EntityType::Ray(r) => {
let bx = r.base_point.x;
@ -1227,6 +1405,18 @@ impl CadCommand for TrimCommand {
let t_click = arc_t(t_ell, t0, t1);
Some(trim_ellipse(e, &ts, t_click))
}
Some(EntityType::Spline(s)) => {
let ts = spline_seg_ts(s, handle, &self.geos);
if ts.is_empty() { return CmdResult::NeedPoint; }
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();
Some(t_to_rel(t_actual, t0, t1))
})
.unwrap_or(0.5);
Some(trim_spline(s, &ts, t_click))
}
_ => None,
};
@ -1264,6 +1454,7 @@ impl CadCommand for TrimCommand {
EntityType::Ray(r) => r.common.handle = h,
EntityType::XLine(x) => x.common.handle = h,
EntityType::Ellipse(e) => e.common.handle = h,
EntityType::Spline(s) => s.common.handle = h,
_ => {}
}
}
@ -1416,6 +1607,26 @@ impl CadCommand for TrimCommand {
}
out
}
Some(EntityType::Spline(s)) => {
let ts = spline_seg_ts(s, handle, &self.geos);
if ts.is_empty() { return vec![]; }
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();
Some(t_to_rel(t_actual, t0, t1))
})
.unwrap_or(0.5);
let orig_pts = spline_pts_wire(s);
let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false);
let survivors = trim_spline(s, &ts, t_click);
let mut out = vec![removed];
for (i, ent) in survivors.iter().enumerate() {
let pts = entity_pts(ent);
out.push(WireModel::solid(format!("trim_keep_{i}"), pts, WireModel::CYAN, false));
}
out
}
_ => vec![],
}
}
@ -1513,6 +1724,16 @@ impl CadCommand for ExtendCommand {
let _ = span;
extend_ellipse(e, t_click, &self.geos)
}
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();
Some(t_to_rel(t_actual, t0, t1))
})
.unwrap_or(0.5);
extend_spline(s, t_click, &self.geos)
}
_ => None,
};
@ -1535,6 +1756,7 @@ impl CadCommand for ExtendCommand {
match &mut new_entity {
EntityType::Line(l) => l.common.handle = new_handle,
EntityType::Ellipse(e) => e.common.handle = new_handle,
EntityType::Spline(s) => s.common.handle = new_handle,
_ => {}
}
if let Some(pos) = self
@ -1588,6 +1810,18 @@ 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();
Some(t_to_rel(t_actual, t0, t1))
})
.unwrap_or(0.5);
if let Some(ext) = extend_spline(s, t_click, &self.geos) {
return vec![WireModel::solid("extend_prev".into(), entity_pts(&ext), WireModel::CYAN, false)];
}
}
_ => {}
}
vec![]