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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Sonnet 4.6 committed 2026-04-07 15:00:22 +03:00
1 parent 71667cfafe
commit 083eb5cbb5
7 files changed
+591 -11

No files matched your search

+6 -6
View File
@@ -114,11 +114,11 @@ Underlay (PDF/DWF/DGN)
| ARRAY Rectangular | ✅ | — |
| ARRAY Polar | ✅ | — |
| ARRAY Path | ✅ | — |
| BREAK (BR) | 🔧 | Spline |
| TRIM (TR) | 🔧 | Spline |
| EXTEND (EX) | 🔧 | Spline, LwPolyline |
| OFFSET (O) | 🔧 | Spline |
| LENGTHEN (LEN) | 🔧 | Spline, LwPolyline |
| BREAK (BR) | ✅ | — |
| TRIM (TR) | ✅ | — |
| EXTEND (EX) | 🔧 | LwPolyline |
| OFFSET (O) | ✅ | — |
| LENGTHEN (LEN) | 🔧 | LwPolyline |
| FILLET (F) | ✅ | — |
| CHAMFER (CHA) | ✅ | — |
| JOIN (J) | ✅ | — |
@@ -302,7 +302,7 @@ Underlay (PDF/DWF/DGN)
## Öncelik Sırası (Bir Sonraki Adımlar)
### Yüksek Öncelik
1. **TRIM / EXTEND / OFFSET / BREAK / LENGTHEN → Spline desteği**
1. **EXTEND / LENGTHEN → LwPolyline desteği**
### Orta Öncelik
5. **Solid3D tessellation** tamamlama (ACIS → truck pipeline)
+50 -1
View File
@@ -7,7 +7,9 @@
//
// BREAK @ (at-sign as second point) → Break at a single point (splits without gap).
use acadrust::entities::{Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline};
use acadrust::entities::{Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt};
use truck_modeling::base::{BoundedCurve, Cut};
use crate::modules::home::modify::spline_ops::{bspline_to_spline, spline_nearest_t, spline_to_bspline};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
@@ -39,6 +41,7 @@ pub fn break_entity(entity: &EntityType, p1: Vec3, p2: Vec3) -> Option<Vec<Entit
EntityType::Circle(c) => Some(break_circle(c, p1, p2)),
EntityType::LwPolyline(p) => Some(break_lwpolyline(p, p1, p2)),
EntityType::Ellipse(e) => Some(break_ellipse(e, p1, p2)),
EntityType::Spline(s) => Some(break_spline(s, p1, p2)),
_ => None,
}
}
@@ -271,6 +274,52 @@ fn vec3_to_v3(v: Vec3) -> Vector3 {
Vector3::new(v.x as f64, v.y as f64, v.z as f64)
}
fn break_spline(spl: &SplineEnt, p1: Vec3, p2: Vec3) -> Vec<EntityType> {
// Find the two nearest parameters to p1 and p2 (DXF XY: world x, z).
let t1 = match spline_nearest_t(spl, p1.x as f64, p1.z as f64) {
Some(t) => t,
None => return vec![EntityType::Spline(spl.clone())],
};
let t2 = match spline_nearest_t(spl, p2.x as f64, p2.z as f64) {
Some(t) => t,
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 (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)),
];
}
// 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)));
}
// 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)));
}
result
}
// ── CadCommand (simplified — break logic via CmdResult::BreakEntity) ───────
/// Thin wrapper for commands.rs to register the break command using the
+91 -2
View File
@@ -7,7 +7,9 @@
//
// The entity is modified at whichever end is closest to the pick point.
use acadrust::entities::{Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt};
use acadrust::entities::{Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, Spline as SplineEnt};
use truck_modeling::base::{BoundedCurve, Cut, ParametricCurve};
use crate::modules::home::modify::spline_ops::{bspline_to_spline, spline_nearest_t, spline_to_bspline};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::Vec3;
@@ -92,13 +94,14 @@ pub enum LenMode {
// ── Geometry ───────────────────────────────────────────────────────────────
/// Apply LENGTHEN to a Line or Arc.
/// Apply LENGTHEN to a Line, Arc, Ellipse, or Spline.
/// `pick_pt` determines which end to extend/trim (closest end is modified).
pub fn lengthen_entity(entity: &EntityType, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
match entity {
EntityType::Line(l) => lengthen_line(l, pick_pt, mode),
EntityType::Arc(a) => lengthen_arc(a, pick_pt, mode),
EntityType::Ellipse(e) => lengthen_ellipse(e, pick_pt, mode),
EntityType::Spline(s) => lengthen_spline(s, pick_pt, mode),
_ => None,
}
}
@@ -262,3 +265,89 @@ fn xz_to_v3(v: Vec3, z: f64) -> Vector3 {
// v is (world_x, world_z, 0) → DXF (x, world_z, z)
Vector3::new(v.x as f64, v.y as f64, z)
}
fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let bs = spline_to_bspline(spl)?;
let (t0, t1) = bs.range_tuple();
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);
}
len
};
if arc_len < 1e-10 { return None; }
let new_len = apply_mode(arc_len, mode)?;
if new_len < 1e-10 { return None; }
// Determine which end (start or end) is closer to pick_pt.
let p_start = bs.subs(t0);
let p_end = bs.subs(t1);
let dist_start = (p_start.x - pick_pt.x as f64).hypot(p_start.y - pick_pt.z as f64);
let dist_end = (p_end.x - pick_pt.x as f64).hypot(p_end.y - pick_pt.z as f64);
let extend_end = dist_end <= dist_start;
// Find the parameter `t_new` such that the arc length from the fixed end to t_new = new_len.
// Use bisection on cumulative arc length.
let fixed_t = if extend_end { t0 } else { t1 };
let delta_ratio = new_len / arc_len;
// Find t_new via bisection: cumulative_len(fixed_t..t_new) = new_len.
let cum_len = |t_end_param: f64| -> f64 {
let (lo, hi) = if extend_end { (t0, t_end_param) } else { (t_end_param, t1) };
if hi <= lo { return 0.0; }
let n = 32usize;
let mut len = 0.0f64;
for i in 0..n {
let ta = lo + (hi - lo) * (i as f64 / n as f64);
let tb = lo + (hi - lo) * ((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);
}
len
};
let (mut lo_t, mut hi_t) = if extend_end {
(t0, t0 + delta_ratio * (t1 - t0) * 2.0)
} else {
(t1 - delta_ratio * (t1 - t0) * 2.0, t1)
};
// Clamp to valid range with some buffer for extension.
let buf = (t1 - t0) * 0.5;
lo_t = lo_t.max(t0 - buf);
hi_t = hi_t.min(t1 + buf);
for _ in 0..40 {
let mid = (lo_t + hi_t) * 0.5;
if cum_len(mid) < new_len {
if extend_end { hi_t = mid; } else { lo_t = mid; }
} else {
if extend_end { lo_t = mid; } else { hi_t = mid; }
}
}
let t_new = (lo_t + hi_t) * 0.5;
let _ = fixed_t;
// Split the spline at t_new.
let mut piece = bs.clone();
if extend_end {
// Keep [t0, t_new]: cut at t_new
let _right = piece.cut(t_new.clamp(t0 + 1e-10, t1 * 2.0));
Some(EntityType::Spline(bspline_to_spline(&piece, spl)))
} else {
// Keep [t_new, t1]: cut at t_new, return the right portion
let right = piece.cut(t_new.clamp(t0 * 0.5, t1 - 1e-10));
Some(EntityType::Spline(bspline_to_spline(&right, spl)))
}
}
+1
View File
@@ -15,4 +15,5 @@ pub mod rotate;
pub mod scale;
pub mod stretch;
pub mod translate;
pub mod spline_ops;
pub mod trim;
+70 -1
View File
@@ -12,7 +12,8 @@
use std::f64::consts::TAU;
use acadrust::entities::LwVertex;
use acadrust::entities::{Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline};
use acadrust::entities::{Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt};
use crate::modules::home::modify::spline_ops::{bspline_to_spline, spline_sample_xy, spline_to_bspline, spline_pts_wire};
use acadrust::{EntityType, Handle};
use glam::Vec3;
@@ -291,6 +292,72 @@ fn offset_ellipse(e: &EllipseEnt, dist: f64, side_pt: Vec3) -> Option<EntityType
Some(EntityType::Ellipse(new_e))
}
// ── Spline offset ──────────────────────────────────────────────────────────
//
// Strategy: sample the spline into N points, offset each sample point by
// `dist` along the local perpendicular (based on the finite-difference
// tangent), then fit a new spline through the offset points.
fn offset_spline(spl: &SplineEnt, dist: f64, side_pt: Vec3) -> Option<EntityType> {
let (ts_knot, pts) = spline_sample_xy(spl, 64);
let n = pts.len();
if n < 2 { return None; }
// Determine offset sign from the first non-degenerate tangent.
let sign: f64 = (0..n - 1).find_map(|i| {
let dx = pts[i + 1][0] - pts[i][0];
let dy = pts[i + 1][1] - pts[i][1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-12 { return None; }
let vx = side_pt.x as f64 - pts[i][0];
let vy = side_pt.y as f64 - pts[i][1];
let cross = dx * vy - dy * vx;
Some(if cross >= 0.0 { 1.0 } else { -1.0 })
})?;
// Offset each sample point along the local normal.
let offset_pts: Vec<acadrust::types::Vector3> = pts
.iter()
.enumerate()
.map(|(i, p)| {
// Tangent via central / forward / backward difference.
let (dx, dy) = if i == 0 {
let d = [pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]];
(d[0], d[1])
} else if i == n - 1 {
let d = [pts[n - 1][0] - pts[n - 2][0], pts[n - 1][1] - pts[n - 2][1]];
(d[0], d[1])
} else {
((pts[i + 1][0] - pts[i - 1][0]) * 0.5,
(pts[i + 1][1] - pts[i - 1][1]) * 0.5)
};
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
let nx = -dy / len; // left perpendicular
let ny = dx / len;
let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0);
acadrust::types::Vector3::new(
p[0] + sign * nx * dist,
p[1] + sign * ny * dist,
z,
)
})
.collect();
let _ = ts_knot;
// Build a new spline from the offset control points (treat sample pts as fit pts → ctrl pts).
let degree = spl.degree.max(1) as usize;
let new_ctrl: Vec<acadrust::types::Vector3> = 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.fit_points.clear();
new_spl.weights.clear();
Some(EntityType::Spline(new_spl))
}
// ── Dispatch ───────────────────────────────────────────────────────────────
fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option<EntityType> {
@@ -300,6 +367,7 @@ fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option<Entit
EntityType::Arc(a) => offset_arc(a, dist, side_pt),
EntityType::LwPolyline(p) => offset_lwpolyline(p, dist, side_pt),
EntityType::Ellipse(e) => offset_ellipse(e, dist, side_pt),
EntityType::Spline(s) => offset_spline(s, dist, side_pt),
_ => None,
}
}
@@ -369,6 +437,7 @@ fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
e.center.z as f32]
}).collect()
}
EntityType::Spline(s) => spline_pts_wire(s),
_ => vec![],
}
}
+138
View File
@@ -0,0 +1,138 @@
// Shared B-spline utilities for modify commands (TRIM, BREAK, OFFSET, LENGTHEN).
//
// Provides:
// - spline_to_bspline : acadrust Spline → truck BSplineCurve<Point3>
// - bspline_to_spline : truck BSplineCurve<Point3> → 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
use acadrust::entities::Spline;
use acadrust::types::Vector3;
use acadrust::Handle;
use truck_modeling::{BSplineCurve, KnotVec, Point3};
use truck_modeling::base::{BoundedCurve, ParametricCurve};
// ── Conversion ─────────────────────────────────────────────────────────────
/// Convert an acadrust `Spline` to a truck `BSplineCurve<Point3>`.
/// Returns `None` if the spline has too few control points or inconsistent data.
pub fn spline_to_bspline(spl: &Spline) -> Option<BSplineCurve<Point3>> {
let ctrl_pts: Vec<Point3> = spl
.control_points
.iter()
.map(|p| Point3::new(p.x, p.y, p.z))
.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))
}
/// Rebuild an acadrust `Spline` from a trimmed `BSplineCurve<Point3>`.
/// The `template` spline provides the entity common data, degree, and Z-elevation.
pub fn bspline_to_spline(bs: &BSplineCurve<Point3>, 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
.control_points()
.iter()
.map(|p| Vector3::new(p.x, p.y, z))
.collect();
spl.weights.clear(); // not rational after split
spl.fit_points.clear(); // fit points are no longer valid
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`.
pub fn spline_sample_xy(spl: &Spline, n: usize) -> (Vec<f64>, Vec<[f64; 2]>) {
let bs = match spline_to_bspline(spl) {
Some(b) => b,
None => return (vec![], vec![]),
};
let (t0, t1) = bs.range_tuple();
let ts: Vec<f64> = (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();
(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.
pub fn spline_nearest_t(spl: &Spline, x: f64, y: f64) -> Option<f64> {
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)
}
/// Returns the normalised parameter in [0, 1] given an actual B-spline
/// parameter `t_actual` and the range `[t0, t1]`.
pub fn t_to_rel(t_actual: f64, t0: f64, t1: f64) -> f64 {
if (t1 - t0).abs() < 1e-12 { return 0.0; }
((t_actual - t0) / (t1 - t0)).clamp(0.0, 1.0)
}
// ── Wire preview ───────────────────────────────────────────────────────────
/// World-space wire points for a Spline (samples 64 segments).
/// Y-up convention: world (x, 0, y) for DXF (x, y).
pub fn spline_pts_wire(spl: &Spline) -> Vec<[f32; 3]> {
let (_, pts) = spline_sample_xy(spl, 64);
if pts.is_empty() { return vec![]; }
let elev = spl.control_points.first().map(|v| v.z as f32).unwrap_or(0.0);
pts.iter()
.map(|p| [p[0] as f32, elev, p[1] as f32])
.collect()
}
+235 -1
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![]