From 083eb5cbb500d34408d5f6b2f003270bb5274a66 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 7 Apr 2026 15:00:22 +0300 Subject: [PATCH] feat: Spline support for TRIM, EXTEND, OFFSET, BREAK, and LENGTHEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ROADMAP.md | 12 +- src/modules/home/modify/break_cmd.rs | 51 +++++- src/modules/home/modify/lengthen.rs | 93 +++++++++- src/modules/home/modify/mod.rs | 1 + src/modules/home/modify/offset.rs | 71 +++++++- src/modules/home/modify/spline_ops.rs | 138 +++++++++++++++ src/modules/home/modify/trim.rs | 236 +++++++++++++++++++++++++- 7 files changed, 591 insertions(+), 11 deletions(-) create mode 100644 src/modules/home/modify/spline_ops.rs diff --git a/ROADMAP.md b/ROADMAP.md index 09f8b972f..2e8c4a61f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/src/modules/home/modify/break_cmd.rs b/src/modules/home/modify/break_cmd.rs index d48cd0d25..b9f9beeb0 100644 --- a/src/modules/home/modify/break_cmd.rs +++ b/src/modules/home/modify/break_cmd.rs @@ -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 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 { + // 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 diff --git a/src/modules/home/modify/lengthen.rs b/src/modules/home/modify/lengthen.rs index 6cad943e0..068e31b3f 100644 --- a/src/modules/home/modify/lengthen.rs +++ b/src/modules/home/modify/lengthen.rs @@ -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 { 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 { + 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))) + } +} diff --git a/src/modules/home/modify/mod.rs b/src/modules/home/modify/mod.rs index 681a61553..93c8e751d 100644 --- a/src/modules/home/modify/mod.rs +++ b/src/modules/home/modify/mod.rs @@ -15,4 +15,5 @@ pub mod rotate; pub mod scale; pub mod stretch; pub mod translate; +pub mod spline_ops; pub mod trim; diff --git a/src/modules/home/modify/offset.rs b/src/modules/home/modify/offset.rs index 3bdc0273b..f5b932f21 100644 --- a/src/modules/home/modify/offset.rs +++ b/src/modules/home/modify/offset.rs @@ -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 Option { + 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 = 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 = 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 { @@ -300,6 +367,7 @@ fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option 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![], } } diff --git a/src/modules/home/modify/spline_ops.rs b/src/modules/home/modify/spline_ops.rs new file mode 100644 index 000000000..2077fec44 --- /dev/null +++ b/src/modules/home/modify/spline_ops.rs @@ -0,0 +1,138 @@ +// 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 + +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`. +/// 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 + .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`. +/// The `template` spline provides the entity common data, degree, and Z-elevation. +pub fn bspline_to_spline(bs: &BSplineCurve, 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, 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 = (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 { + 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() +} diff --git a/src/modules/home/modify/trim.rs b/src/modules/home/modify/trim.rs index 33453df5e..2a84c9158 100644 --- a/src/modules/home/modify/trim.rs +++ b/src/modules/home/modify/trim.rs @@ -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 { @@ -286,6 +295,14 @@ fn build_geos(entities: &[EntityType]) -> Vec { 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 { + 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 { + 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 { + // 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 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![]