// Trim / Extend — ribbon definitions + full command implementations. // // TRIM (TR): Click the segment you want to remove. The command finds all // intersections of that entity with every other entity and trims // out the clicked interval. Stays active — click more segments, // press Enter to finish. // // EXTEND (EX): Click near one end of an entity. The command extends that // endpoint to the nearest intersecting boundary. Stays active. use std::f64::consts::TAU; // The plane geometry these commands run on lives in opencadkernel; `geom` adapts // its call shapes to the loose scalars and f32 render vertices used here. use super::geom; use super::geom::{ arc_parameter as arc_t, arc_points as arc_pts, ellipse_closest_parameter, ellipse_points as ellipse_pts, lerp as lerp2, normalize_angle as norm, }; use crate::modules::draw::fence::{crossing_box_preview, FencePick}; use codec::entities::{ Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, LwVertex, Ray as RayEnt, Spline as SplineEnt, XLine as XLineEnt, }; use codec::types::Vector3; use codec::{EntityType, Handle}; use glam::DVec3; use kernel::geom2d::nurbs::clamped_uniform_knots; use kernel::geom2d::{ intersect as kernel_intersect, trim_spans as kernel_trim_spans, Arc as KernelArc, BulgeArc, Circle as KernelCircle, Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, Extent as KernelExtent, Line as KernelLine, NurbsCurve, Ray as KernelRay, Tolerance as KernelTolerance, XLine as KernelXLine, }; use crate::entities::curve::{entity_curve_xy, entity_with_lwpolyline_world_xy}; use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::modify::spline_ops::{ spline_cut, spline_nearest_t, spline_pts_wire, spline_range, spline_to_nurbs, t_to_rel, }; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; use crate::t; use super::entity_index::ModifyEntityIndex; // ── Dropdown constants ───────────────────────────────────────────────────── pub const DROPDOWN_ID: &str = "trim_extend"; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/trim.svg")); pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ ( "TRIM", "Trim", IconKind::Svg(include_bytes!("../../../../assets/icons/trim.svg")), ), ( "EXTEND", "Extend", IconKind::Svg(include_bytes!("../../../../assets/icons/extend.svg")), ), ]; // ══════════════════════════════════════════════════════════════════════════ // Geometry helpers // ══════════════════════════════════════════════════════════════════════════ // ── Boundary geometry ───────────────────────────────────────────────────── /// Virtual extent used to represent infinite ends of Ray / XLine. const TRIM_EXTENT: f64 = 1_000_000.0; /// Sampling density for the plan-view point lists the fence and preview /// passes walk. The renderer's own figure, so a preview cut lands where the /// drawn geometry is rather than a chord away from it. const SAMPLE_SEGMENTS_PER_RADIAN: f64 = kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN; /// If a trim interval endpoint is beyond this threshold it is treated as "infinite". #[derive(Clone)] enum Geo { Line { handle: Handle, p1: [f64; 2], p2: [f64; 2], }, Arc { handle: Handle, cx: f64, cy: f64, r: f64, a0: f64, a1: f64, }, Circle { handle: Handle, cx: f64, cy: f64, r: f64, }, /// Semi-infinite line from base in +direction. Ray { handle: Handle, bx: f64, by: f64, dx: f64, dy: f64, }, /// Fully-infinite line through base along direction. InfLine { handle: Handle, bx: f64, by: f64, dx: f64, dy: f64, }, /// Ellipse arc: center, semi-axes, unit major-axis direction, parameter range [t0,t1]. Ellipse { handle: Handle, cx: f64, cy: f64, a: f64, // semi-major b: f64, // semi-minor nx: f64, // unit major-axis X ny: f64, // unit major-axis Y t0: f64, // start parameter t1: f64, // end parameter (may be > 2π if wrapped) }, /// A NURBS boundary, carried exactly. Spline { handle: Handle, curve: Box, }, } impl Geo { fn handle(&self) -> Handle { match self { Self::Line { handle, .. } | Self::Arc { handle, .. } | Self::Circle { handle, .. } | Self::Ray { handle, .. } | Self::InfLine { handle, .. } | Self::Ellipse { handle, .. } | Self::Spline { handle, .. } => *handle, } } fn endpoints(&self) -> Vec<[f64; 2]> { match self { Self::Line { p1, p2, .. } => vec![*p1, *p2], Self::Arc { cx, cy, r, a0, a1, .. } => vec![ [cx + r * a0.cos(), cy + r * a0.sin()], [cx + r * a1.cos(), cy + r * a1.sin()], ], Self::Ray { bx, by, .. } => vec![[*bx, *by]], Self::Ellipse { cx, cy, a, b, nx, ny, t0, t1, .. } => { if (t1 - t0).abs() < TAU - 1e-6 { let pt_at = |t: f64| { let ct = t.cos(); let st = t.sin(); [ cx + nx * a * ct - ny * b * st, cy + ny * a * ct + nx * b * st, ] }; vec![pt_at(*t0), pt_at(*t1)] } else { vec![] } } Self::Spline { curve, .. } => vec![curve.point_at(0.0), curve.point_at(1.0)], Self::Circle { .. } | Self::InfLine { .. } => vec![], } } } pub fn is_trim_boundary_entity(entity: &EntityType) -> bool { matches!( entity, EntityType::Line(_) | EntityType::Arc(_) | EntityType::Circle(_) | EntityType::Ellipse(_) | EntityType::Ray(_) | EntityType::XLine(_) | EntityType::Spline(_) | EntityType::LwPolyline(_) | EntityType::Polyline(_) | EntityType::Polyline2D(_) | EntityType::Polyline3D(_) ) } fn build_geos(entities: &[EntityType]) -> Vec { let mut out = Vec::new(); for e in entities { let h = e.common().handle; match e { // A polyline acts as a boundary through its constituent edges, so // a Line/Arc/… can be trimmed against it. Explode into Line + Arc // segments and tag each with the polyline's own handle (so trim // still excludes it as the click target). EntityType::LwPolyline(_) | EntityType::Polyline(_) | EntityType::Polyline2D(_) | EntityType::Polyline3D(_) => { for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { if let Some(g) = geo_from_entity(h, &seg) { out.push(g); } } } _ => { if let Some(g) = geo_from_entity(h, e) { out.push(g); } } } } out } /// Convert a simple boundary entity (Line / Arc / Circle / Ray / XLine / /// Ellipse / Spline) into a `Geo`, tagged with `h`. Returns `None` for types /// that do not act as trim boundaries. fn geo_from_entity(h: Handle, e: &EntityType) -> Option { // The geometry itself comes from the one converter, in plan-view // coordinates. What is left here is only the shape of the boundary // record, which the edit options need to mutate — `imply_edge_geos` turns // a segment into an infinite line and opens an arc into a full circle, // neither of which a bare curve says. match entity_curve_xy(e)? { Curve::Line(line) => Some(Geo::Line { handle: h, p1: line.start, p2: line.end, }), Curve::Arc(arc) => Some(Geo::Arc { handle: h, cx: arc.centre[0], cy: arc.centre[1], r: arc.radius, a0: arc.start_angle, a1: arc.end_angle, }), Curve::Circle(circle) => Some(Geo::Circle { handle: h, cx: circle.centre[0], cy: circle.centre[1], r: circle.radius, }), Curve::Ray(ray) => Some(Geo::Ray { handle: h, bx: ray.origin[0], by: ray.origin[1], dx: ray.direction[0], dy: ray.direction[1], }), Curve::XLine(line) => Some(Geo::InfLine { handle: h, bx: line.base[0], by: line.base[1], dx: line.direction[0], dy: line.direction[1], }), Curve::Ellipse(arc) => { let ellipse = arc.ellipse; if ellipse.major_radius < 1e-9 { return None; } let t0 = arc.start_parameter; let mut t1 = arc.end_parameter; if t1 <= t0 { t1 += TAU; } Some(Geo::Ellipse { handle: h, cx: ellipse.centre[0], cy: ellipse.centre[1], a: ellipse.major_radius, b: ellipse.minor_radius, nx: ellipse.major_axis[0], ny: ellipse.major_axis[1], t0, t1, }) } Curve::Nurbs(curve) => Some(Geo::Spline { handle: h, curve: Box::new(curve), }), // A polyline reaches here already taken apart into its segments. Curve::Polyline(_) => None, } } // ── Intersection helpers ────────────────────────────────────────────────── // ── Boundary crossings ──────────────────────────────────────────────────── // // Every cut a trim or extend makes comes from one question: where does the // entity being edited meet the boundaries picked as cutting edges. The kernel // answers it for any pair of curves, so these four just describe the target as // a `Curve` and hand it over. /// The entity a boundary came from, so a curve is never cut by itself. fn geo_handle(geo: &Geo) -> Handle { match geo { Geo::Line { handle, .. } | Geo::Arc { handle, .. } | Geo::Circle { handle, .. } | Geo::Ray { handle, .. } | Geo::InfLine { handle, .. } | Geo::Ellipse { handle, .. } | Geo::Spline { handle, .. } => *handle, } } /// A boundary as a kernel curve. fn geo_to_curve(geo: &Geo) -> Option { Some(match geo { Geo::Line { p1, p2, .. } => Curve::Line(KernelLine { start: *p1, end: *p2, }), Geo::Arc { cx, cy, r, a0, a1, .. } => Curve::Arc(KernelArc { centre: [*cx, *cy], radius: *r, start_angle: *a0, end_angle: *a1, }), Geo::Circle { cx, cy, r, .. } => Curve::Circle(KernelCircle { centre: [*cx, *cy], radius: *r, }), Geo::Ray { bx, by, dx, dy, .. } => Curve::Ray(KernelRay { origin: [*bx, *by], direction: [*dx, *dy], }), Geo::InfLine { bx, by, dx, dy, .. } => Curve::XLine(KernelXLine { base: [*bx, *by], direction: [*dx, *dy], }), Geo::Ellipse { cx, cy, a, b, nx, ny, t0, t1, .. } => Curve::Ellipse(KernelEllipseArc { ellipse: KernelEllipse { centre: [*cx, *cy], major_radius: *a, minor_radius: *b, major_axis: [*nx, *ny], }, start_parameter: *t0, end_parameter: *t1, }), // Exactly, not through its sampling. The kernel intersects a NURBS by // subdivision against a bounding box, so a boundary spline cuts where // it actually runs rather than where a 64-chord approximation of it // does. Geo::Spline { curve, .. } => Curve::Nurbs((**curve).clone()), }) } /// Where `target` is cut by every boundary except its own entity, as /// parameters along it in `0..=1`. fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec { let tolerance = KernelTolerance::new(CUT_TOLERANCE); // A bounded target's parameters are pinned to its own span, as every // caller here expects. An unbounded one — a ray shot out to find the // nearest boundary — has to keep whatever it comes back with, since the // whole point is how far away the crossing is. let bounded = matches!(target.extent(), KernelExtent::Bounded); let mut ts: Vec = geos .iter() .filter(|geo| geo_handle(geo) != handle) .filter_map(geo_to_curve) .flat_map(|boundary| { kernel_intersect(target, &boundary, tolerance) .into_iter() .map(|hit| { if bounded { hit.t_a.clamp(0.0, 1.0) } else { hit.t_a } }) .collect::>() }) .collect(); // ── Endpoint contact detection ── // When a boundary entity terminates on (or within snap tolerance of) the // target curve, register that contact point as a cut parameter. const ENDPOINT_TOLERANCE: f64 = CUT_TOLERANCE; let mut contacts = Vec::new(); for geo in geos.iter().filter(|g| geo_handle(g) != handle) { // 1. Boundary endpoints touching target for pt in geo.endpoints() { let t = target.parameter_at(pt); let valid_t = if bounded { let slack = CUT_TOLERANCE; if (-slack..=1.0 + slack).contains(&t) { Some(t.clamp(0.0, 1.0)) } else { None } } else { Some(t) }; if let Some(t_cand) = valid_t { let proj = target.point_at(t_cand); let dx = pt[0] - proj[0]; let dy = pt[1] - proj[1]; if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE { contacts.push(t_cand); } } } // 2. Target endpoints touching boundary if bounded && !target.is_closed() { if let Some(boundary_curve) = geo_to_curve(geo) { let boundary_bounded = matches!(boundary_curve.extent(), KernelExtent::Bounded); for &t_end in &[0.0, 1.0] { let pt = target.point_at(t_end); let u = boundary_curve.parameter_at(pt); let valid_u = if boundary_bounded { let slack = CUT_TOLERANCE; if (-slack..=1.0 + slack).contains(&u) { Some(u.clamp(0.0, 1.0)) } else { None } } else { Some(u) }; if let Some(u_cand) = valid_u { let proj = boundary_curve.point_at(u_cand); let dx = pt[0] - proj[0]; let dy = pt[1] - proj[1]; if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE { contacts.push(t_end); } } } } } } // Prioritize exact endpoint contact parameters over raw ray intersections, // ensuring trimmed endpoints match boundary endpoints down to machine precision. let is_closed = target.is_closed(); for t_contact in contacts { if let Some(pos) = ts.iter().position(|&t| { let diff = (t - t_contact).abs(); let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff }; cyclic < 1e-6 }) { ts[pos] = t_contact; } else { ts.push(t_contact); } } ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); ts.dedup_by(|a, b| { let diff = (*a - *b).abs(); let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff }; cyclic < 1e-6 }); // For closed curves, ensure the first and last cuts don't duplicate across the 0/1 seam if is_closed && ts.len() >= 2 { let seam_diff = (ts[ts.len() - 1] - (ts[0] + 1.0)).abs(); if seam_diff < 1e-6 { ts.pop(); } } ts } /// A Ray entity as the kernel curve it is, so its parameter is the ray's own /// rather than a fraction of some invented length. fn ray_curve(r: &RayEnt) -> Curve { Curve::Ray(KernelRay { origin: [r.base_point.x, r.base_point.y], direction: [r.direction.x, r.direction.y], }) } /// An XLine entity as the kernel curve it is. fn xline_curve(x: &XLineEnt) -> Curve { Curve::XLine(KernelXLine { base: [x.base_point.x, x.base_point.y], direction: [x.direction.x, x.direction.y], }) } /// How close two points have to be to count as a crossing. /// /// Loose enough that a boundary drawn to meet an entity still registers when /// its endpoint is a rounding step away, which is the usual case in a drawing /// that was snapped together rather than computed. const CUT_TOLERANCE: f64 = 1e-7; fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo]) -> Vec { cut_params( &Curve::Line(KernelLine { start: [ax, ay], end: [bx, by], }), target, geos, ) } fn polyline_seg_ts( p0: [f64; 2], p1: [f64; 2], bulge: f64, target: Handle, geos: &[Geo], ) -> Vec { if let Some(ba) = BulgeArc::from_bulge(p0, p1, bulge) { let (from, to) = if ba.sweep >= 0.0 { (ba.start_angle, ba.start_angle + ba.sweep) } else { (ba.end_angle, ba.end_angle - ba.sweep) }; let arc_curve = Curve::Arc(KernelArc { centre: ba.center, radius: ba.radius, start_angle: from, end_angle: to, }); let ts = cut_params(&arc_curve, target, geos); let mut u_ts: Vec = if ba.sweep >= 0.0 { ts } else { ts.into_iter().map(|t| (1.0 - t).clamp(0.0, 1.0)).collect() }; u_ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); u_ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); u_ts } else { line_seg_ts(p0[0], p0[1], p1[0], p1[1], target, geos) } } fn arc_seg_ts( cx: f64, cy: f64, r: f64, a0: f64, a1: f64, target: Handle, geos: &[Geo], ) -> Vec { cut_params( &Curve::Arc(KernelArc { centre: [cx, cy], radius: r, start_angle: a0, end_angle: a1, }), target, geos, ) } #[allow(clippy::too_many_arguments)] fn ellipse_seg_ts( cx: f64, cy: f64, a: f64, b: f64, nx: f64, ny: f64, t0: f64, t1: f64, target: Handle, geos: &[Geo], ) -> Vec { cut_params( &Curve::Ellipse(KernelEllipseArc { ellipse: KernelEllipse { centre: [cx, cy], major_radius: a, minor_radius: b, major_axis: [nx, ny], }, start_parameter: t0, end_parameter: t1, }), target, geos, ) } /// Cuts on a spline, solved against the true curve rather than a sampling of /// it. The kernel subdivides and converges, so a cut lands where the boundary /// actually crosses instead of where a fixed number of samples said it did. fn spline_seg_ts(spl: &SplineEnt, target: Handle, geos: &[Geo]) -> Vec { let Some(curve) = spline_to_nurbs(spl) else { return vec![]; }; cut_params(&Curve::Nurbs(curve), target, geos) } /// Trim an Ellipse entity. Returns the surviving ellipse-arc segments. fn trim_ellipse(orig: &EllipseEnt, ts: &[f64], t_click: f64) -> Vec { let t0 = orig.start_parameter; let mut t1 = orig.end_parameter; if t1 <= t0 { t1 += TAU; } let span = t1 - t0; let angle_at = |t: f64| t0 + span * t; // A closed ellipse has no ends for `trim_intervals` to anchor on. Handing // it one makes it invent cuts at the parameter seam, so the survivor comes // back as two arcs either side of the seam instead of one joined across // it — and if the click lands in the wrapping piece, the wrong side goes. // Circles avoid this by treating the cuts cyclically; see `trim_circle`. if (span - TAU).abs() < 1e-9 { if ts.len() < 2 { return vec![]; } let click = t_click.rem_euclid(1.0); // The gap holding the click, with the last one wrapping past 1.0 back // to the first cut. let gap = (0..ts.len()).find_map(|i| { let from = ts[i]; let to = if i + 1 < ts.len() { ts[i + 1] } else { ts[0] + 1.0 }; let holds = |t: f64| t >= from && t < to; (holds(click) || holds(click + 1.0)).then_some((from, to)) }); let Some((from, to)) = gap else { return vec![]; }; // The survivor runs from the far edge of the removed gap all the way // round to its near edge. Leaving the end below the start is what // signals the wrap to everything downstream. let mut e = orig.clone(); e.common.handle = Handle::NULL; e.start_parameter = angle_at(to % 1.0); e.end_parameter = angle_at(from % 1.0); return vec![EntityType::Ellipse(e)]; } let major_radius = orig.major_axis.x.hypot(orig.major_axis.y); if major_radius < 1e-9 { return vec![]; } let curve = Curve::Ellipse(KernelEllipseArc { ellipse: KernelEllipse { centre: [orig.center.x, orig.center.y], major_radius, minor_radius: major_radius * orig.minor_axis_ratio, major_axis: [ orig.major_axis.x / major_radius, orig.major_axis.y / major_radius, ], }, start_parameter: t0, end_parameter: t1, }); trim_intervals(&curve, ts, t_click) .into_iter() .filter_map(|(ta, tb)| { if (tb - ta).abs() < 1e-6 { return None; } let mut e = orig.clone(); e.common.handle = Handle::NULL; e.start_parameter = angle_at(ta); e.end_parameter = angle_at(tb); Some(EntityType::Ellipse(e)) }) .collect() } /// Extend an Ellipse arc to the nearest boundary (along the arc direction). fn extend_ellipse(orig: &EllipseEnt, t_click: f64, geos: &[Geo]) -> Option { let t0 = orig.start_parameter; let mut t1 = orig.end_parameter; if t1 <= t0 { t1 += TAU; } let span = t1 - t0; let a = (orig.major_axis.x.powi(2) + orig.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { return None; } let b = a * orig.minor_axis_ratio; let (nx, ny) = (orig.major_axis.x / a, orig.major_axis.y / a); let cx = orig.center.x; let cy = orig.center.y; let ts = ellipse_seg_ts(cx, cy, a, b, nx, ny, t0, t1, orig.common.handle, geos); let extend_end = t_click >= 0.5; let best = if extend_end { ts.into_iter() .filter(|&t| t > 1.0 + 1e-6) .min_by(|x, y| x.partial_cmp(y).unwrap()) } else { ts.into_iter() .filter(|&t| t < -1e-6) .max_by(|x, y| x.partial_cmp(y).unwrap()) }; let best_t = best?; let new_param = t0 + span * best_t; let mut e = orig.clone(); e.common.handle = Handle::NULL; if extend_end { e.end_parameter = new_param; } else { e.start_parameter = new_param; } Some(EntityType::Ellipse(e)) } // ── Spline trim / extend ────────────────────────────────────────────────── /// Trim a Spline entity. Returns surviving spline pieces (one or two). fn trim_spline(spl: &SplineEnt, ts: &[f64], t_click: f64) -> Vec { let Some(curve) = spline_to_nurbs(spl) else { return vec![]; }; let (t0, t1) = curve.domain(); let curve = Curve::Nurbs(curve); trim_intervals(&curve, 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; } // Cut away everything before the interval, then everything after // it, leaving [t_lo, t_hi]. Either cut is a no-op when the // interval already reaches that end of the curve — dropping the // piece there would delete the half the user meant to keep. let after_low = match spline_cut(spl, t_lo) { Some((_, right)) => right, None => spl.clone(), }; 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() } /// 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 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 = 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 = 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 = 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 { return None; } let (dx, dy) = (dx / len, dy / len); // Shoot a real ray from the endpoint along the tangent. The direction is a // unit vector, so the parameter that comes back is the distance to the // boundary — no stand-in length to pick, and nothing beyond it to miss. let shot = Curve::Ray(KernelRay { origin: ep, direction: [dx, dy], }); let best_t = cut_params(&shot, spl.common.handle, geos) .into_iter() .filter(|&t| t > 1e-6) .reduce(f64::min)?; let hit_x = ep[0] + best_t * dx; let hit_y = ep[1] + best_t * dy; // 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(codec::types::Vector3::new(hit_x, hit_y, z)); } else { new_spl .control_points .insert(0, codec::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(); new_spl.knots = clamped_uniform_knots(degree, n); Some(EntityType::Spline(new_spl)) } // ── Trim helpers ────────────────────────────────────────────────────────── fn trim_intervals(curve: &Curve, ts: &[f64], t_click: f64) -> Vec<(f64, f64)> { kernel_trim_spans(curve, ts, t_click, KernelTolerance::new(CUT_TOLERANCE)) .into_iter() .map(|span| (span[0], span[1])) .collect() } /// Trim a Line entity. Returns the surviving line segments. fn trim_line(orig: &LineEnt, ts: &[f64], t_click: f64) -> Vec { let p1 = [orig.start.x, orig.start.y]; let p2 = [orig.end.x, orig.end.y]; let z = orig.start.z; let curve = Curve::Line(KernelLine { start: p1, end: p2 }); trim_intervals(&curve, ts, t_click) .into_iter() .filter_map(|(ta, tb)| { let a = lerp2(p1, p2, ta); let b = lerp2(p1, p2, tb); if (b[0] - a[0]).hypot(b[1] - a[1]) < 1e-6 { return None; } let mut l = orig.clone(); l.common.handle = Handle::NULL; l.start = Vector3::new(a[0], a[1], z); l.end = Vector3::new(b[0], b[1], z); Some(EntityType::Line(l)) }) .collect() } /// Extend a clicked Arc end along its circle to the nearest boundary /// crossing (#409). `t_click` ∈ [0,1] within the current span picks the end: /// ≥ 0.5 extends the end angle CCW, < 0.5 extends the start angle CW. fn extend_arc(orig: &ArcEnt, t_click: f64, geos: &[Geo]) -> Option { let cx = orig.center.x; let cy = orig.center.y; let r = orig.radius; if r < 1e-9 { return None; } let a0 = orig.start_angle; let a1 = orig.end_angle; let span = { let s = norm(a1) - norm(a0); if s <= 0.0 { s + TAU } else { s } }; if span >= TAU - 1e-9 { return None; // already a full circle } // Boundary crossings around the FULL circle, then re-expressed relative // to the arc's own span: t ∈ [0,1] lies on the arc, t > 1 walks CCW past // the end and wraps around toward the start. let ts: Vec = arc_seg_ts(cx, cy, r, a0, a0 + TAU, orig.common.handle, geos) .into_iter() .map(|t| t * TAU / span) .collect(); let extend_end = t_click >= 0.5; let best = if extend_end { // First crossing CCW past the end. ts.iter() .copied() .filter(|&t| t > 1.0 + 1e-6) .min_by(|x, y| x.partial_cmp(y).unwrap()) } else { // First crossing CW before the start — in wrapped span-relative // coordinates that is the LARGEST t outside the arc. ts.iter() .copied() .filter(|&t| t > 1.0 + 1e-6) .max_by(|x, y| x.partial_cmp(y).unwrap()) }?; let ang = norm(a0) + span * best; let mut a = orig.clone(); a.common.handle = Handle::NULL; if extend_end { a.end_angle = ang; } else { a.start_angle = ang; } Some(EntityType::Arc(a)) } /// Trim an Arc entity. Returns the surviving arc segments. fn trim_arc(orig: &ArcEnt, ts: &[f64], t_click: f64) -> Vec { let a0 = orig.start_angle; let a1 = orig.end_angle; let span = { let s = norm(a1) - norm(a0); if s <= 0.0 { s + TAU } else { s } }; let angle_at = |t: f64| norm(a0) + span * t; let curve = Curve::Arc(KernelArc { centre: [orig.center.x, orig.center.y], radius: orig.radius, start_angle: a0, end_angle: a1, }); trim_intervals(&curve, ts, t_click) .into_iter() .filter_map(|(ta, tb)| { if (tb - ta).abs() < 1e-6 { return None; } let mut a = orig.clone(); a.common.handle = Handle::NULL; a.start_angle = angle_at(ta); a.end_angle = angle_at(tb); Some(EntityType::Arc(a)) }) .collect() } /// Trim a clicked Circle. A full circle has no endpoints, so it needs ≥2 /// boundary crossings: the arc segment containing the click is removed and the /// circle becomes a single Arc spanning the rest. With fewer than two crossings /// there is nothing to cut, so an empty result leaves the circle unchanged. /// /// `ts` are the cut parameters in [0,1) around the circle (angle / TAU), sorted. fn trim_circle(orig: &CircleEnt, ts: &[f64], t_click: f64) -> Vec { if ts.len() < 2 { return vec![]; } let tc = t_click.rem_euclid(1.0); // Find the cyclic gap (ta, tb) between adjacent cuts that holds the click; // the last gap wraps past 1.0 back to the first cut. let n = ts.len(); let mut removed: Option<(f64, f64)> = None; for i in 0..n { let ta = ts[i]; let tb = if i + 1 < n { ts[i + 1] } else { ts[0] + 1.0 }; if (tc >= ta && tc < tb) || (tc + 1.0 >= ta && tc + 1.0 < tb) { removed = Some((ta, tb)); break; } } let (ta, tb) = match removed { Some(g) => g, None => return vec![], }; // Surviving arc runs CCW from the far edge of the removed gap all the way // around to its near edge. let mut arc = ArcEnt::new(); arc.common = orig.common.clone(); arc.common.handle = Handle::NULL; arc.center = orig.center; arc.radius = orig.radius; arc.thickness = orig.thickness; arc.normal = orig.normal; arc.start_angle = (tb % 1.0) * TAU; arc.end_angle = (ta % 1.0) * TAU; vec![EntityType::Arc(arc)] } /// Extract a sub-polyline between parameter `s0` and `s1` (`s0 < s1`). /// Preserves and proportionally scales arc bulges on partial segments. fn extract_sub_polyline(poly: &LwPolyline, s0: f64, s1: f64) -> Option { if s1 <= s0 + 1e-9 { return None; } let n = poly.vertices.len(); if n < 2 { return None; } let closed = poly.is_closed; let seg_count = if closed { n } else { n - 1 }; if seg_count == 0 { return None; } let vx = |i: usize| -> [f64; 2] { let v = &poly.vertices[i % n]; [v.location.x, v.location.y] }; let seg_bulge = |i: usize| -> f64 { poly.vertices[i % n].bulge }; let mut raw_verts: Vec<(f64, f64, f64)> = Vec::new(); let mut curr = s0; while curr < s1 - 1e-9 { let seg_idx_unwrapped = curr.floor() as usize; let next_int = (seg_idx_unwrapped + 1) as f64; let end_curr = s1.min(next_int); let seg = seg_idx_unwrapped % seg_count; let u_a = (curr - seg_idx_unwrapped as f64).clamp(0.0, 1.0); let u_b = (end_curr - seg_idx_unwrapped as f64).clamp(0.0, 1.0); if u_b > u_a + 1e-9 { let p0 = vx(seg); let p1 = vx(seg + 1); let b = seg_bulge(seg); let (start_pt, sub_bulge) = if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) { let sp = ba.sample(u_a); let sub_b = if (u_a - 0.0).abs() < 1e-9 && (u_b - 1.0).abs() < 1e-9 { b } else { let sweep = ba.sweep * (u_b - u_a); (sweep * 0.25).tan() }; (sp, sub_b) } else { let sp = [p0[0] + u_a * (p1[0] - p0[0]), p0[1] + u_a * (p1[1] - p0[1])]; (sp, 0.0) }; raw_verts.push((start_pt[0], start_pt[1], sub_bulge)); } curr = end_curr; } // End point of the last sub-segment let end_pt = { let seg_idx_unwrapped = (s1 - 1e-9).floor() as usize; let seg = seg_idx_unwrapped % seg_count; let u = (s1 - seg_idx_unwrapped as f64).clamp(0.0, 1.0); let p0 = vx(seg); let p1 = vx(seg + 1); let b = seg_bulge(seg); if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) { ba.sample(u) } else { [p0[0] + u * (p1[0] - p0[0]), p0[1] + u * (p1[1] - p0[1])] } }; raw_verts.push((end_pt[0], end_pt[1], 0.0)); // Filter out coincident consecutive points to prevent degenerate zero-length segments let mut clean_verts: Vec<(f64, f64, f64)> = Vec::new(); for (x, y, b) in raw_verts { if let Some(last) = clean_verts.last_mut() { if (last.0 - x).hypot(last.1 - y) < 1e-6 { if last.2 == 0.0 { last.2 = b; } continue; } } clean_verts.push((x, y, b)); } if let Some(last) = clean_verts.last_mut() { last.2 = 0.0; } if clean_verts.len() < 2 { return None; } let mut new_poly = poly.clone(); new_poly.common.handle = Handle::NULL; new_poly.is_closed = false; new_poly.vertices = clean_verts .into_iter() .map(|(x, y, b)| { let mut v = LwVertex::from_coords(x, y); v.bulge = b; v }) .collect(); Some(new_poly) } /// Kernel curve for one LwPolyline segment. /// /// The boolean tells whether the kernel curve runs opposite to the polyline's /// own segment direction. Negative-bulge arcs are represented by the kernel as /// a positive-sweep arc, so their intersection parameter has to be reversed /// back to the polyline's local 0..1 parameter. fn lwpoly_segment_curve(poly: &LwPolyline, segment: usize) -> Option<(Curve, bool)> { let n = poly.vertices.len(); if n < 2 { return None; } let a = &poly.vertices[segment % n]; let b = &poly.vertices[(segment + 1) % n]; let p0 = [a.location.x, a.location.y]; let p1 = [b.location.x, b.location.y]; if (p1[0] - p0[0]).hypot(p1[1] - p0[1]) < 1e-12 { return None; } if let Some(arc) = BulgeArc::from_bulge(p0, p1, a.bulge) { if arc.sweep >= 0.0 { Some(( Curve::Arc(KernelArc { centre: arc.center, radius: arc.radius, start_angle: arc.start_angle, end_angle: arc.start_angle + arc.sweep, }), false, )) } else { Some(( Curve::Arc(KernelArc { centre: arc.center, radius: arc.radius, start_angle: arc.end_angle, end_angle: arc.end_angle - arc.sweep, }), true, )) } } else { Some((Curve::Line(KernelLine { start: p0, end: p1 }), false)) } } /// Global polyline parameters at which non-adjacent segments of the same /// polyline intersect. /// /// Segment neighbours are deliberately excluded: their common vertex is a /// normal polyline vertex, not a self-intersection. A polyline cuts itself /// only when it is one of the cutting edges. fn lwpoly_self_cut_params(poly: &LwPolyline, geos: &[Geo]) -> Vec { let n = poly.vertices.len(); if n < 3 || !geos.iter().any(|geo| geo.handle() == poly.common.handle) { return Vec::new(); } let closed = poly.is_closed; let segment_count = if closed { n } else { n - 1 }; let total = segment_count as f64; let mut cuts = Vec::new(); for i in 0..segment_count { let Some((curve_a, reverse_a)) = lwpoly_segment_curve(poly, i) else { continue; }; for j in (i + 1)..segment_count { // Ordinary neighbouring segments meet at their common vertex. let adjacent = j == i + 1 || (closed && i == 0 && j + 1 == segment_count); if adjacent { continue; } let Some((curve_b, reverse_b)) = lwpoly_segment_curve(poly, j) else { continue; }; let hits = kernel_intersect(&curve_a, &curve_b, KernelTolerance::new(CUT_TOLERANCE)); for hit in hits { let mut ua = hit.t_a; let mut ub = hit.t_b; if reverse_a { ua = 1.0 - ua; } if reverse_b { ub = 1.0 - ub; } if !ua.is_finite() || !ub.is_finite() || ua < -1e-7 || ua > 1.0 + 1e-7 || ub < -1e-7 || ub > 1.0 + 1e-7 { continue; } ua = ua.clamp(0.0, 1.0); ub = ub.clamp(0.0, 1.0); // If both sides only touch at existing vertices, those // vertices already act as trim limits below. let a_endpoint = ua <= 1e-7 || ua >= 1.0 - 1e-7; let b_endpoint = ub <= 1e-7 || ub >= 1.0 - 1e-7; if a_endpoint && b_endpoint { continue; } let pa = i as f64 + ua; let pb = j as f64 + ub; cuts.push(if closed { pa.rem_euclid(total) } else { pa }); cuts.push(if closed { pb.rem_euclid(total) } else { pb }); } } } cuts.sort_by(f64::total_cmp); cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); cuts } /// Trim a clicked LwPolyline: remove the portion containing the click, bounded /// by the nearest boundary intersections on each side. A closed polyline needs /// ≥2 cuts and becomes an open polyline (the surviving arc); an open one yields /// the surviving piece(s). Bulges on surviving arc segments are preserved /// with proportionally scaled curvature. fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option> { let handle = poly.common.handle; let n = poly.vertices.len(); if n < 2 { return None; } let closed = poly.is_closed; let seg_count = if closed { n } else { n - 1 }; let total = seg_count as f64; let vx = |i: usize| -> [f64; 2] { let v = &poly.vertices[i % n]; [v.location.x, v.location.y] }; let seg_bulge = |i: usize| -> f64 { poly.vertices[i % n].bulge }; // External boundary cuts as global params (segment index + local u). let mut cuts: Vec = Vec::new(); for i in 0..seg_count { let p0 = vx(i); let p1 = vx(i + 1); let b = seg_bulge(i); for u in polyline_seg_ts(p0, p1, b, handle, geos) { let param = i as f64 + u.clamp(0.0, 1.0); cuts.push(if closed { param.rem_euclid(total) } else { param }); } } // A polyline can also cut itself. These are intersections between // non-adjacent segments belonging to this same LwPolyline. Its vertices // are not cuts: the trimmed piece runs on past corners to the next // cutting edge, and a polyline no edge crosses is left alone. cuts.extend(lwpoly_self_cut_params(poly, geos)); cuts.sort_by(f64::total_cmp); cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); if cuts.is_empty() { return None; } // Click param: nearest point on the polyline. let mut best = (f64::INFINITY, 0.0_f64); for i in 0..seg_count { let p0 = vx(i); let p1 = vx(i + 1); let b = seg_bulge(i); let (d, u) = if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) { let angle = (cy - ba.center[1]).atan2(cx - ba.center[0]); let (travelled, sweep) = if ba.sweep >= 0.0 { ((angle - ba.start_angle).rem_euclid(TAU), ba.sweep) } else { ((ba.start_angle - angle).rem_euclid(TAU), -ba.sweep) }; let u = if sweep < 1e-12 { 0.0 } else if travelled <= sweep { (travelled / sweep).clamp(0.0, 1.0) } else { let gap = TAU - sweep; let past_end = travelled - sweep; if past_end <= gap * 0.5 { 1.0 } else { 0.0 } }; let pt = ba.sample(u); let dist2 = (pt[0] - cx).powi(2) + (pt[1] - cy).powi(2); (dist2, u) } else { let (dx, dy) = (p1[0] - p0[0], p1[1] - p0[1]); let len2 = dx * dx + dy * dy; let u = if len2 > 1e-12 { (((cx - p0[0]) * dx + (cy - p0[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; let px = p0[0] + u * dx; let py = p0[1] + u * dy; let dist2 = (px - cx).powi(2) + (py - cy).powi(2); (dist2, u) }; if d < best.0 { best = (d, i as f64 + u); } } let t_click = best.1; let mut out: Vec = Vec::new(); if closed { if cuts.len() < 2 { return None; } let n = cuts.len(); let mut removed = None; for i in 0..n { let a = cuts[i]; let b = if i + 1 < n { cuts[i + 1] } else { cuts[0] + total }; if (t_click >= a && t_click < b) || (t_click + total >= a && t_click + total < b) { removed = Some((a, b)); break; } } let Some((lo, hi)) = removed else { return None; }; let mut s1 = lo; while s1 <= hi { s1 += total; } if let Some(piece) = extract_sub_polyline(poly, hi, s1) { out.push(EntityType::LwPolyline(piece)); } } else { let mut bounds = vec![0.0]; bounds.extend(cuts.iter().copied()); bounds.push(total); bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); let n = bounds.len(); let mut removed = None; for i in 0..n - 1 { let a = bounds[i]; let b = bounds[i + 1]; if (t_click >= a && t_click < b) || (i + 2 == n && t_click <= b) { removed = Some((a, b)); break; } } let Some((lo, hi)) = removed else { return None; }; if lo > 1e-6 { if let Some(piece) = extract_sub_polyline(poly, 0.0, lo) { out.push(EntityType::LwPolyline(piece)); } } if hi < total - 1e-6 { if let Some(piece) = extract_sub_polyline(poly, hi, total) { out.push(EntityType::LwPolyline(piece)); } } } if out.is_empty() { None } else { Some(out) } } // ── Extend helpers ──────────────────────────────────────────────────────── /// Extend the first or last segment of an LwPolyline to the nearest boundary. /// Click point (DXF XY) determines which end to extend. /// The nearest boundary crossing past one end of a segment. /// /// `t` runs on the infinite line through `from` → `to`: zero at `from`, one /// at `to`. Extending the far end wants the smallest `t` above one; extending /// the near end wants the largest `t` below zero. `None` when nothing lies /// that way. /// /// The crossings come from `cut_params`, which is the same kernel call every /// other cut in this file uses. What was here instead was a match over every /// `Geo` variant with its own line-line, line-circle and line-ellipse test — /// twice, once for a line and once for a polyline's terminal segment — and a /// boundary spline was walked as sixty-four chords rather than intersected. fn extension_hit( from: [f64; 2], to: [f64; 2], handle: Handle, geos: &[Geo], beyond_end: bool, ) -> Option { let direction = [to[0] - from[0], to[1] - from[1]]; if direction[0].hypot(direction[1]) < 1e-9 { return None; } // An infinite line rather than the segment: the crossing being looked for // is by definition off the end of the drawn part. let ray = Curve::XLine(KernelXLine { base: from, direction, }); let hits = cut_params(&ray, handle, geos); if beyond_end { hits.into_iter() .filter(|t| *t > 1.0 + 1e-6) .min_by(f64::total_cmp) } else { hits.into_iter() .filter(|t| *t < -1e-6) .max_by(f64::total_cmp) } } fn extend_lwpoly( poly: &LwPolyline, click_x: f64, click_y: f64, geos: &[Geo], ) -> Option { let n = poly.vertices.len(); if n < 2 { return None; } let first = &poly.vertices[0]; let second = &poly.vertices[1]; let last = &poly.vertices[n - 1]; let prev = &poly.vertices[n - 2]; let d_first = (first.location.x - click_x).hypot(first.location.y - click_y); let d_last = (last.location.x - click_x).hypot(last.location.y - click_y); let extend_end = d_last <= d_first; // Extract the terminal segment as a virtual line. let (ax, ay, bx, by) = if extend_end { ( prev.location.x, prev.location.y, last.location.x, last.location.y, ) } else { ( second.location.x, second.location.y, first.location.x, first.location.y, ) }; let (dx, dy) = (bx - ax, by - ay); let len2 = dx * dx + dy * dy; if len2 < 1e-12 { return None; } let best_t = extension_hit([ax, ay], [bx, by], poly.common.handle, geos, true)?; let new_x = ax + best_t * dx; let new_y = ay + best_t * dy; let mut new_poly = poly.clone(); new_poly.common.handle = Handle::NULL; if extend_end { let last_v = new_poly.vertices.last_mut()?; last_v.location.x = new_x; last_v.location.y = new_y; } else { let first_v = new_poly.vertices.first_mut()?; first_v.location.x = new_x; first_v.location.y = new_y; } Some(EntityType::LwPolyline(new_poly)) } /// Extend a Line to the nearest boundary on the extended side. /// t_click < 0.5 → extend start (look for t < 0); t_click ≥ 0.5 → extend end (t > 1). fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option { let ax = orig.start.x; let ay = orig.start.y; let bx = orig.end.x; let by = orig.end.y; let (dx, dy) = (bx - ax, by - ay); let extend_end = t_click >= 0.5; let best_t = extension_hit([ax, ay], [bx, by], orig.common.handle, geos, extend_end)?; let mut line = orig.clone(); line.common.handle = Handle::NULL; let new_x = ax + best_t * dx; let new_y = ay + best_t * dy; if extend_end { line.end = Vector3::new(new_x, new_y, orig.end.z); } else { line.start = Vector3::new(new_x, new_y, orig.start.z); } Some(EntityType::Line(line)) } /// Trim a Ray entity. /// /// The parameter is the ray's own: `t = 0` at the base and advancing by one /// `direction` per unit, unbounded above. A surviving piece that reaches /// infinity stays a Ray; one with two ends becomes a Line. fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { let bx = orig.base_point.x; let by = orig.base_point.y; let bz = orig.base_point.z; let dx = orig.direction.x; let dy = orig.direction.y; let dz = orig.direction.z; let pt = |t: f64| [bx + t * dx, by + t * dy, bz + t * dz]; let curve = ray_curve(orig); trim_intervals(&curve, ts, t_click) .into_iter() .filter_map(|(ta, tb)| { let pa = pt(ta); let pb = pt(tb); if tb.is_finite() && (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 1e-6 { return None; } if tb.is_infinite() { // Still extends to infinity → remains a Ray with new base let r = RayEnt::new(Vector3::new(pa[0], pa[1], pa[2]), Vector3::new(dx, dy, dz)); let mut r = r; r.common = orig.common.clone(); r.common.handle = Handle::NULL; Some(EntityType::Ray(r)) } else { // Finite segment → Line let mut l = LineEnt { common: orig.common.clone(), ..LineEnt::new() }; l.common.handle = Handle::NULL; l.start = Vector3::new(pa[0], pa[1], pa[2]); l.end = Vector3::new(pb[0], pb[1], pb[2]); Some(EntityType::Line(l)) } }) .collect() } /// Trim an XLine entity. /// /// The parameter is the line's own: `t = 0` at the base point, advancing by /// one `direction` per unit and running both ways without limit. A surviving /// piece keeps whichever ends reach infinity — both, and it is still an XLine; /// one, and it is a Ray; neither, and it is a Line. fn trim_xline(orig: &XLineEnt, ts: &[f64], t_click: f64) -> Vec { let bx = orig.base_point.x; let by = orig.base_point.y; let bz = orig.base_point.z; let dx = orig.direction.x; let dy = orig.direction.y; let dz = orig.direction.z; let pt = |t: f64| [bx + t * dx, by + t * dy, bz + t * dz]; let curve = xline_curve(orig); trim_intervals(&curve, ts, t_click) .into_iter() .filter_map(|(ta, tb)| { let pa = pt(ta); let pb = pt(tb); let ext_neg = ta.is_infinite(); let ext_pos = tb.is_infinite(); match (ext_neg, ext_pos) { (true, true) => { // Whole XLine survived (shouldn't happen after a real trim) let mut x = orig.clone(); x.common.handle = Handle::NULL; Some(EntityType::XLine(x)) } (true, false) => { // Extends toward -infinity: Ray at pb pointing in -dir let r = RayEnt::new( Vector3::new(pb[0], pb[1], pb[2]), Vector3::new(-dx, -dy, -dz), ); let mut r = r; r.common = orig.common.clone(); r.common.handle = Handle::NULL; Some(EntityType::Ray(r)) } (false, true) => { // Extends toward +infinity: Ray at pa pointing in +dir let r = RayEnt::new(Vector3::new(pa[0], pa[1], pa[2]), Vector3::new(dx, dy, dz)); let mut r = r; r.common = orig.common.clone(); r.common.handle = Handle::NULL; Some(EntityType::Ray(r)) } (false, false) => { // Finite segment let mut l = LineEnt { common: orig.common.clone(), ..LineEnt::new() }; l.common.handle = Handle::NULL; l.start = Vector3::new(pa[0], pa[1], pa[2]); l.end = Vector3::new(pb[0], pb[1], pb[2]); Some(EntityType::Line(l)) } } }) .collect() } // ── Point-generation helpers ────────────────────────────────────────────── const DIM_RED: [f32; 4] = [1.0, 0.3, 0.3, 0.6]; fn line_pts(l: &LineEnt) -> Vec<[f32; 3]> { geom::line_points( [l.start.x, l.start.y, l.start.z], [l.end.x, l.end.y, l.end.z], ) } fn entity_pts(e: &EntityType) -> Vec<[f32; 3]> { match e { EntityType::Line(l) => line_pts(l), EntityType::Arc(a) => arc_pts( a.center.x, a.center.y, a.radius, a.start_angle, a.end_angle, a.center.z, ), EntityType::Ellipse(e) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { return vec![]; } let b = a * e.minor_axis_ratio; let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; 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), EntityType::LwPolyline(p) => { let elev = p.elevation as f32; let xy_pts = preview_sample_xy(e); xy_pts .into_iter() .map(|pt| [pt[0] as f32, pt[1] as f32, elev]) .collect() } // For preview, show a 20-unit section of semi-infinite results EntityType::Ray(r) => { let bx = r.base_point.x; let by = r.base_point.y; let bz = r.base_point.z; let far_x = bx + r.direction.x * 20.0; let far_y = by + r.direction.y * 20.0; let far_z = bz + r.direction.z * 20.0; vec![ [bx as f32, bz as f32, by as f32], [far_x as f32, far_z as f32, far_y as f32], ] } _ => vec![], } } // ══════════════════════════════════════════════════════════════════════════ // TrimCommand // ══════════════════════════════════════════════════════════════════════════ // ── TRIM / EXTEND option machinery (#336) ───────────────────────────────── /// Sub-mode of the TRIM / EXTEND commands (#336). `Pick` is the quick mode; /// the others are entered through the option keywords. enum TrimMode { Pick, /// Collecting cutting/boundary edges; Enter returns to Pick. SelectEdges, /// Collecting fence points; Enter runs the fence pass. Fence(FencePick), /// Crossing: waiting for the first rectangle corner. CrossFirst, /// Crossing: first corner picked, waiting for the second. CrossSecond([f64; 2]), /// Erase mode: each pick deletes the object; Enter returns to Pick. Erase, } #[derive(Clone, Copy)] struct CrossingWindow { min: [f64; 2], max: [f64; 2], } fn segment_window_range(p1: [f64; 2], p2: [f64; 2], window: CrossingWindow) -> Option<(f64, f64)> { let mut lo: f64 = 0.0; let mut hi: f64 = 1.0; for axis in 0..2 { let d = p2[axis] - p1[axis]; if d.abs() < 1e-12 { if p1[axis] < window.min[axis] - 1e-9 || p1[axis] > window.max[axis] + 1e-9 { return None; } continue; } let mut a = (window.min[axis] - p1[axis]) / d; let mut b = (window.max[axis] - p1[axis]) / d; if a > b { std::mem::swap(&mut a, &mut b); } lo = lo.max(a); hi = hi.min(b); if lo > hi + 1e-9 { return None; } } Some((lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0))) } /// Crossing selection trims LwPolyline segments independently. A segment that /// lies inside the selection rectangle but never meets a cutting edge must /// survive; otherwise a rectangle loses its far vertical edge (#588). fn crossing_trim_lwpolyline( poly: &LwPolyline, geos: &[Geo], window: CrossingWindow, ) -> Option> { let handle = poly.common.handle; let n = poly.vertices.len(); if n < 2 { return None; } let closed = poly.is_closed; let seg_count = if closed { n } else { n - 1 }; let total = seg_count as f64; let vertex_xy = |i: usize| { let v = &poly.vertices[i % n]; [v.location.x, v.location.y] }; let seg_bulge = |i: usize| -> f64 { poly.vertices[i % n].bulge }; let mut removed = Vec::<(f64, f64)>::new(); // Compute the polyline's own non-adjacent segment intersections once. // These global parameters will later be mapped back into each source segment. let self_cuts = lwpoly_self_cut_params(poly, geos); for i in 0..seg_count { let a = vertex_xy(i); let b = vertex_xy(i + 1); let bulge = seg_bulge(i); let window_range = if let Some(ba) = BulgeArc::from_bulge(a, b, bulge) { let steps = 32usize; let mut min_t: Option = None; let mut max_t: Option = None; for s in 0..=steps { let t = s as f64 / steps as f64; let pt = ba.sample(t); if pt[0] >= window.min[0] - 1e-9 && pt[0] <= window.max[0] + 1e-9 && pt[1] >= window.min[1] - 1e-9 && pt[1] <= window.max[1] + 1e-9 { min_t = Some(min_t.map_or(t, |m: f64| m.min(t))); max_t = Some(max_t.map_or(t, |m: f64| m.max(t))); } } match (min_t, max_t) { (Some(lo), Some(hi)) => Some((lo, hi)), _ => None, } } else { segment_window_range(a, b, window) }; let Some((inside_lo, inside_hi)) = window_range else { continue; }; // External intersections on this segment. let mut cuts = polyline_seg_ts(a, b, bulge, handle, geos); // Also include self-intersections belonging to this source segment. // `lwpoly_self_cut_params()` stores them as global polyline parameters: // segment_index + local_parameter // // Convert the ones belonging to segment `i` back to local 0..1. let seg_start = i as f64; let seg_end = seg_start + 1.0; for global_t in self_cuts.iter().copied() { if global_t > seg_start + 1e-6 && global_t < seg_end - 1e-6 { cuts.push(global_t - seg_start); } } // The source segment endpoints are also valid trim limits. cuts.push(0.0); cuts.push(1.0); cuts.sort_by(f64::total_cmp); cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); // Remove every trim span that is actually touched by the selection box. // // A single source segment may contain several sub-spans when external // boundaries cross it. Only the sub-spans overlapping the window are removed. for span in cuts.windows(2) { let lo = span[0].clamp(0.0, 1.0); let hi = span[1].clamp(0.0, 1.0); if hi - lo <= 1e-6 { continue; } // This trim span participates when its parameter interval overlaps the // portion of the source segment lying inside the crossing rectangle. let overlaps_window = hi >= inside_lo - 1e-6 && lo <= inside_hi + 1e-6; if overlaps_window { removed.push((i as f64 + lo, i as f64 + hi)); } } } if removed.is_empty() { return None; } removed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let mut merged = Vec::<(f64, f64)>::new(); for span in removed { if let Some(last) = merged.last_mut() { if span.0 <= last.1 + 1e-6 { last.1 = last.1.max(span.1); continue; } } merged.push(span); } let mut kept = Vec::<(f64, f64)>::new(); if closed { for i in 0..merged.len() { let start = merged[i].1; let mut end = merged[(i + 1) % merged.len()].0; if i + 1 == merged.len() { end += total; } if end - start > 1e-6 { kept.push((start, end)); } } } else { let mut cursor = 0.0; for &(start, end) in &merged { if start - cursor > 1e-6 { kept.push((cursor, start)); } cursor = cursor.max(end); } if total - cursor > 1e-6 { kept.push((cursor, total)); } } Some( kept.into_iter() .filter_map(|(start, end)| { extract_sub_polyline(poly, start, end).map(EntityType::LwPolyline) }) .collect(), ) } /// Quick-mode trim at a click/crossing point: the surviving pieces, or `None` /// when the pick doesn't intersect any boundary (or the type is unsupported). fn pick_trim_at( all: &[EntityType], geos: &[Geo], handle: Handle, px: f64, py: f64, ) -> Option> { let entity = all.iter().find(|e| e.common().handle == handle); let result: Option> = match entity { Some(EntityType::Line(l)) => { let ax = l.start.x; let ay = l.start.y; let bx = l.end.x; let by = l.end.y; let ts = line_seg_ts(ax, ay, bx, by, handle, geos); if ts.is_empty() { return None; } let dx = bx - ax; let dy = by - ay; let len2 = dx * dx + dy * dy; let t_click = if len2 > 1e-12 { ((px - ax) * dx + (py - ay) * dy) / len2 } else { 0.5 }; Some(trim_line(l, &ts, t_click)) } Some(EntityType::Arc(a)) => { let cx = a.center.x; let cy = a.center.y; let a0 = a.start_angle; let a1 = a.end_angle; let ts = arc_seg_ts(cx, cy, a.radius, a0, a1, handle, geos); if ts.is_empty() { return None; } let click_angle = (py - cy).atan2(px - cx); let t_click = arc_t(click_angle, a0, a1); Some(trim_arc(a, &ts, t_click)) } Some(EntityType::Circle(c)) => { let cx = c.center.x; let cy = c.center.y; let ts = arc_seg_ts(cx, cy, c.radius, 0.0, TAU, handle, geos); if ts.len() < 2 { return None; } let click_angle = (py - cy).atan2(px - cx); let t_click = arc_t(click_angle, 0.0, TAU); let survivors = trim_circle(c, &ts, t_click); if survivors.is_empty() { return None; } Some(survivors) } Some(EntityType::Ray(r)) => { let curve = ray_curve(r); let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return None; } let t_click = curve.parameter_at([px, py]); Some(trim_ray(r, &ts, t_click)) } Some(EntityType::XLine(x)) => { let curve = xline_curve(x); let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return None; } let t_click = curve.parameter_at([px, py]); Some(trim_xline(x, &ts, t_click)) } Some(EntityType::Ellipse(e)) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { return None; } let b = a * e.minor_axis_ratio; if b < 1e-9 { return None; } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; if t1 <= t0 { t1 += TAU; } let ts = ellipse_seg_ts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, handle, geos); if ts.is_empty() { return None; } // t_click: project mouse onto ellipse local param let rx = px - e.center.x; let ry = py - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; let t_ell = ellipse_closest_parameter(a, b, xl, yl); 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, geos); if ts.is_empty() { return None; } let t_click = spline_nearest_t(s, px, py) .and_then(|t_actual| { let (t0, t1) = spline_range(s)?; Some(t_to_rel(t_actual, t0, t1)) }) .unwrap_or(0.5); Some(trim_spline(s, &ts, t_click)) } Some(EntityType::LwPolyline(p)) => trim_lwpolyline(p, px, py, geos), _ => None, }; result } /// Quick-mode extend at a click/crossing point: the lengthened entity, or /// `None` when no boundary lies beyond that end (or the type is unsupported). fn pick_extend_at( all: &[EntityType], geos: &[Geo], handle: Handle, px: f64, py: f64, ) -> Option { let entity = all.iter().find(|e| e.common().handle == handle); let result: Option = match entity { Some(EntityType::Line(l)) => { let ax = l.start.x; let ay = l.start.y; let bx = l.end.x; let by = l.end.y; let dx = bx - ax; let dy = by - ay; let len2 = dx * dx + dy * dy; let t_click = if len2 > 1e-12 { ((px - ax) * dx + (py - ay) * dy) / len2 } else { 0.5 }; extend_line(l, t_click, geos) } Some(EntityType::Arc(a)) => { let ang = (py - a.center.y).atan2(px - a.center.x); let t_click = arc_t(ang, a.start_angle, a.end_angle); extend_arc(a, t_click, geos) } Some(EntityType::Ellipse(e)) => { let t0 = e.start_parameter; let mut t1 = e.end_parameter; if t1 <= t0 { t1 += TAU; } let span = t1 - t0; let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { return None; } let b = a * e.minor_axis_ratio; if b < 1e-9 { return None; } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let rx = px - e.center.x; let ry = py - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; let t_ell = ellipse_closest_parameter(a, b, xl, yl); let t_click = arc_t(t_ell, t0, t1); let _ = span; extend_ellipse(e, t_click, geos) } Some(EntityType::LwPolyline(p)) => extend_lwpoly(p, px, py, geos), Some(EntityType::Spline(s)) => { let t_click = spline_nearest_t(s, px, py) .and_then(|t_actual| { let (t0, t1) = spline_range(s)?; Some(t_to_rel(t_actual, t0, t1)) }) .unwrap_or(0.5); extend_spline(s, t_click, geos) } _ => None, }; result } /// Edge option (#336): treat boundary edges as implied — straight edges run /// on past their endpoints and arcs close to full circles, so objects trim / /// extend to the boundary's extrapolation, not only its drawn extent. fn imply_edge_geos(geos: &mut [Geo]) { for g in geos.iter_mut() { match g { Geo::Line { handle, p1, p2 } => { let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]); if dx.hypot(dy) > 1e-9 { // An implied edge cuts wherever the boundary's line would // reach, which is what an infinite line is. Stretching the // segment to a stand-in length instead both invents a // limit and, at survey coordinates, is no larger than the // coordinates themselves. *g = Geo::InfLine { handle: *handle, bx: p1[0], by: p1[1], dx, dy, }; } } Geo::Arc { a0, a1, .. } => { *a0 = 0.0; *a1 = TAU; } _ => {} } } } /// Dense XY sampling for the fence pass — covers the types the quick pick /// supports (adds Circle / Ray / XLine over `sample_entity_xy`). fn fence_sample_xy(e: &EntityType) -> Vec<[f64; 2]> { match e { // The two unbounded kinds, which `sample_entity_xy` declines to // guess a length for. Here there is one: as far as a trim reaches. EntityType::Ray(r) => vec![ [r.base_point.x, r.base_point.y], [ r.base_point.x + r.direction.x * TRIM_EXTENT, r.base_point.y + r.direction.y * TRIM_EXTENT, ], ], EntityType::XLine(x) => vec![ [ x.base_point.x - x.direction.x * TRIM_EXTENT, x.base_point.y - x.direction.y * TRIM_EXTENT, ], [ x.base_point.x + x.direction.x * TRIM_EXTENT, x.base_point.y + x.direction.y * TRIM_EXTENT, ], ], _ => sample_entity_xy(e), } } /// Every crossing point of `e` with the fence polyline, in traversal order. /// Each drives the same per-type pick as a direct click, so the actual cuts /// happen at the exact boundary intersections. fn fence_cross_points(e: &EntityType, fence_geos: &[Geo]) -> Vec<[f64; 2]> { let pts = fence_sample_xy(e); let mut out = Vec::new(); for w in pts.windows(2) { // NOTE: the exclusion handle must differ from the fence geos' own — // passing NULL against NULL-handle geos excluded the whole fence. let mut ts = line_seg_ts( w[0][0], w[0][1], w[1][0], w[1][1], Handle::new(FENCE_PROBE), fence_geos, ); ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); for t in ts { out.push(lerp2(w[0], w[1], t)); } } out } /// Trim/extend one entity at EVERY fence crossing: the pick is re-applied to /// the surviving pieces until no crossing bites (a fence across both /// overhanging ends of one line clears both). Returns `None` when nothing /// changed. Pieces carry NULL handles for the ReplaceMany placeholder flow. fn fence_pieces( e: &EntityType, geos: &[Geo], fence_geos: &[Geo], window: Option, extend: bool, ) -> Option> { if !extend { if let (EntityType::LwPolyline(poly), Some(window)) = (e, window) { return crossing_trim_lwpolyline(poly, geos, window); } } let target = e.common().handle; let mut pieces = vec![e.clone()]; let mut changed = false; for _round in 0..8 { let mut next: Vec = Vec::new(); let mut any = false; for piece in &pieces { let mut tmp = piece.clone(); tmp.as_entity_mut().set_handle(target); let tmp_all = [tmp]; let mut consumed = false; let mut cps = fence_cross_points(&tmp_all[0], fence_geos); // Crossing works like a crossing SELECTION: an object wholly // inside the window is picked too — synthesize the click at a // sampled point inside it. if cps.is_empty() { if let Some(window) = window { if let Some(p) = fence_sample_xy(&tmp_all[0]).into_iter().find(|p| { p[0] >= window.min[0] && p[0] <= window.max[0] && p[1] >= window.min[1] && p[1] <= window.max[1] }) { cps.push(p); } } } for cp in cps { let res = if extend { pick_extend_at(&tmp_all, geos, target, cp[0], cp[1]).map(|x| vec![x]) } else { pick_trim_at(&tmp_all, geos, target, cp[0], cp[1]) }; if let Some(mut sub) = res { for sp in &mut sub { sp.as_entity_mut().set_handle(Handle::NULL); } next.extend(sub); any = true; changed = true; consumed = true; break; } } if !consumed { next.push(piece.clone()); } } pieces = next; if !any || pieces.len() > 64 { break; } } if changed { Some(pieces) } else { None } } /// Fence / Crossing pass (#336): every object crossing the fence polyline is /// trimmed (or extended) at each of its crossing points; the cuts land on the /// boundary-edge intersections, the fence only selects. /// Boundary/edge-selection highlight colour (the EXTRIM boundary yellow). const OPT_YELLOW: [f32; 4] = [1.0, 0.90, 0.15, 1.0]; /// Sentinel handles for the fence pass: the fence's own geos and the probe /// handle used against them must never collide with a real entity handle (or /// with each other — `line_seg_ts` drops geos matching the probe handle). const FENCE_GEO: u64 = u64::MAX - 8; const FENCE_PROBE: u64 = u64::MAX - 9; fn build_fence_geos(fence: &[[f64; 2]]) -> Vec { fence .windows(2) .map(|w| Geo::Line { handle: Handle::new(FENCE_GEO), p1: w[0], p2: w[1], }) .collect() } /// Live result preview of a fence / crossing pass: each affected original in /// red, its surviving pieces in cyan — the same convention as the pick hover. fn fence_result_preview( all: &[EntityType], geos: &[Geo], fence: &[[f64; 2]], window: Option, extend: bool, implied_edges: bool, ) -> Vec { let fence_geos = build_fence_geos(fence); let mut out = Vec::new(); for e in all { if e.common().handle.is_null() { continue; } if let Some(pieces) = fence_pieces(e, geos, &fence_geos, window, extend) { out.push(WireModel::solid( "trim_rm".into(), entity_pts(e), DIM_RED, false, )); for (i, pe) in pieces.iter().enumerate() { out.push(WireModel::solid( format!("trim_keep_{i}"), entity_pts(pe), WireModel::CYAN, false, )); if extend { if let Some(t) = extend_tail_preview(e, pe, "extend_tail") { out.push(t); } } } if implied_edges { let cuts = piece_cut_points(e, &pieces); out.extend(implied_cut_guides(all, e.common().handle, &cuts)); } } } out } fn fence_pass( all: &[EntityType], geos: &[Geo], fence: &[[f64; 2]], window: Option, extend: bool, ) -> Vec<(Handle, Vec)> { let fence_geos: Vec = build_fence_geos(fence); let mut out = Vec::new(); for e in all { let h = e.common().handle; if h.is_null() { continue; } if let Some(pieces) = fence_pieces(e, geos, &fence_geos, window, extend) { out.push((h, pieces)); } } out } /// The ADDED tail of an extend result, dashed — the slice of the extended /// curve beyond the original endpoint, so the preview shows the extension /// itself rather than only a recolored whole (#336). Generic over entity /// type via the sampled points. fn extend_tail_preview(orig: &EntityType, ext: &EntityType, name: &str) -> Option { let op = entity_pts(orig); let ep = entity_pts(ext); if op.len() < 2 || ep.len() < 2 { return None; } let d2 = |a: &[f32; 3], b: &[f32; 3]| { let dx = a[0] - b[0]; let dy = a[1] - b[1]; dx * dx + dy * dy }; let nearest_idx = |pts: &[[f32; 3]], q: &[f32; 3]| { let mut best = 0usize; let mut bd = f32::MAX; for (i, p) in pts.iter().enumerate() { let d = d2(p, q); if d < bd { bd = d; best = i; } } best }; let of = op.first().unwrap(); let ol = op.last().unwrap(); let ef = ep.first().unwrap(); let el = ep.last().unwrap(); let tail: Vec<[f32; 3]> = if d2(ol, el) > 1e-10 { let i = nearest_idx(&ep, ol); ep[i..].to_vec() } else if d2(of, ef) > 1e-10 { let i = nearest_idx(&ep, of); ep[..=i].to_vec() } else { return None; }; if tail.len() < 2 { return None; } let mut w = WireModel::solid(name.into(), tail, [0.3, 1.0, 1.0, 1.0], false); w.pattern_length = 0.8; w.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; w.line_weight_px = 1.6; Some(w) } /// Distance from `q` to the drawn (sampled) body of `e`. fn dist_to_drawn(e: &EntityType, q: [f64; 2]) -> f64 { let pts = fence_sample_xy(e); let mut best = f64::MAX; for w in pts.windows(2) { let (a, b) = (w[0], w[1]); let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let len2 = dx * dx + dy * dy; let t = if len2 > 1e-12 { (((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; let px = a[0] + dx * t; let py = a[1] + dy * t; best = best.min((q[0] - px).hypot(q[1] - py)); } best } /// Endpoints of the surviving pieces that are NOT endpoints of the original — /// i.e. the actual cut points of a trim result. fn piece_cut_points(orig: &EntityType, pieces: &[EntityType]) -> Vec<[f64; 2]> { let op = entity_pts(orig); if op.len() < 2 { return vec![]; } let f = op.first().unwrap(); let l = op.last().unwrap(); let closed = (f[0] - l[0]).abs() < 1e-6 && (f[1] - l[1]).abs() < 1e-6; let ends: Vec<[f32; 3]> = if closed { vec![] } else { vec![*f, *l] }; let mut out = Vec::new(); for pe in pieces { let pp = entity_pts(pe); if pp.len() < 2 { continue; } for q in [pp.first().unwrap(), pp.last().unwrap()] { let is_orig_end = ends.iter().any(|e| { let dx = e[0] - q[0]; let dy = e[1] - q[1]; dx * dx + dy * dy < 1e-6 }); if !is_orig_end { out.push([q[0] as f64, q[1] as f64]); } } } out } /// Edge: Extend guides (#336): for every cut point that does NOT lie on a /// boundary's drawn body, draw a dashed guide along the boundary's implied /// extension — from its drawn end to the cut — so the user sees WHICH edge /// causes the cut there. Lines get a straight guide, arcs follow the circle. fn implied_cut_guides(all: &[EntityType], target: Handle, cuts: &[[f64; 2]]) -> Vec { let mut out = Vec::new(); 'cuts: for (ci, cp) in cuts.iter().enumerate() { let tol = 1e-6 * (1.0 + cp[0].abs() + cp[1].abs()); // Attributed to a drawn body → nothing implied to explain. for e in all { let h = e.common().handle; if h.is_null() || h == target { continue; } if dist_to_drawn(e, *cp) < tol { continue 'cuts; } } // Find the boundary whose extrapolation passes through the cut. for e in all { let h = e.common().handle; if h.is_null() || h == target { continue; } match e { EntityType::Line(l) => { let (ax, ay) = (l.start.x, l.start.y); let (dx, dy) = (l.end.x - ax, l.end.y - ay); let len = dx.hypot(dy); if len < 1e-9 { continue; } // Perpendicular distance to the infinite line. let d = ((cp[0] - ax) * dy - (cp[1] - ay) * dx).abs() / len; if d < tol { let d_start = (cp[0] - ax).hypot(cp[1] - ay); let d_end = (cp[0] - l.end.x).hypot(cp[1] - l.end.y); let from = if d_start < d_end { [ax as f32, ay as f32, 0.0] } else { [l.end.x as f32, l.end.y as f32, 0.0] }; let mut w = WireModel::solid( format!("edge_guide_{ci}"), vec![from, [cp[0] as f32, cp[1] as f32, 0.0]], OPT_YELLOW, false, ); w.pattern_length = 0.8; w.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; out.push(w); continue 'cuts; } } EntityType::Arc(a) => { let r = (cp[0] - a.center.x).hypot(cp[1] - a.center.y); if (r - a.radius).abs() < tol { // Follow the circle from the nearer drawn end to the // cut, going the short way outside the drawn span. let theta = (cp[1] - a.center.y).atan2(cp[0] - a.center.x); let a0 = norm(a.start_angle); let a1 = norm(a.end_angle); let ccw_from_end = (theta - a1).rem_euclid(TAU); let cw_from_start = (a0 - theta).rem_euclid(TAU); let (base, sweep) = if ccw_from_end <= cw_from_start { (a1, ccw_from_end) } else { (a0, -cw_from_start) }; let steps = ((sweep.abs() * 16.0).ceil() as usize).max(2); let pts: Vec<[f32; 3]> = (0..=steps) .map(|i| { let ang = base + sweep * (i as f64 / steps as f64); [ (a.center.x + a.radius * ang.cos()) as f32, (a.center.y + a.radius * ang.sin()) as f32, 0.0, ] }) .collect(); let mut w = WireModel::solid(format!("edge_guide_{ci}"), pts, OPT_YELLOW, false); w.pattern_length = 0.8; w.pattern = [0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; out.push(w); continue 'cuts; } } _ => {} } } } out } /// Extend hover preview: the whole result in cyan, the added tail dashed and /// — with Edge: Extend on — a guide along the implied boundary that the new /// endpoint lands on. fn extend_hover_wires( orig: &EntityType, ext: &EntityType, all: &[EntityType], target: Handle, implied_edges: bool, ) -> Vec { let mut out = vec![WireModel::solid( "extend_prev".into(), entity_pts(ext), WireModel::CYAN, false, )]; if let Some(t) = extend_tail_preview(orig, ext, "extend_tail") { out.push(t); } if implied_edges { // The moved endpoint is the cut against the (implied) boundary. let cuts = piece_cut_points(orig, std::slice::from_ref(ext)); out.extend(implied_cut_guides(all, target, &cuts)); } out } pub struct TrimCommand { all_entities: Vec, entity_index: ModifyEntityIndex, geos: Vec, mode: TrimMode, /// Cutting-edge selection; empty = every object cuts (quick mode). edge_set: Vec, /// Edge option: boundaries extrapolate past their drawn extent. implied_edges: bool, /// Live Shift state — Shift+click extends instead of trims (#336). shift: bool, } impl TrimCommand { pub fn new(all_entities: Vec) -> Self { Self::with_cutting_edges(all_entities, Vec::new()) } pub fn with_cutting_edges(all_entities: Vec, initial_edges: Vec) -> Self { let all_entities: Vec = all_entities .iter() .map(entity_with_lwpolyline_world_xy) .collect(); let entity_index = ModifyEntityIndex::build(&all_entities); let mut cmd = Self { all_entities, entity_index, geos: Vec::new(), mode: TrimMode::Pick, edge_set: initial_edges, implied_edges: false, shift: false, }; cmd.rebuild_geos(); cmd } /// Boundary geometry from the edge selection (or everything when none), /// with the Edge option's implied extrapolation applied on top. fn rebuild_geos(&mut self) { self.entity_index = ModifyEntityIndex::build(&self.all_entities); self.geos = if self.edge_set.is_empty() { build_geos(&self.all_entities) } else { let picked: Vec = self .all_entities .iter() .filter(|e| self.edge_set.contains(&e.common().handle)) .cloned() .collect(); build_geos(&picked) }; if self.implied_edges { imply_edge_geos(&mut self.geos); } } /// Exact analytic boundaries whose boxes overlap the picked entity. The /// host already narrowed the cursor pick; this second broad phase keeps /// TRIM preview/intersection work local on dense drawings. fn nearby_geos(&self, handle: Handle) -> Vec { if self.implied_edges { return self.geos.clone(); } let Some(handles) = self.entity_index.nearby_handles(&self.all_entities, handle) else { return self.geos.clone(); }; self.geos .iter() .filter(|geo| handles.contains(&geo.handle())) .cloned() .collect() } fn fence_run(&mut self, fence: &[[f64; 2]], window: Option) -> CmdResult { let repl = fence_pass(&self.all_entities, &self.geos, fence, window, self.shift); if repl.is_empty() { return CmdResult::NeedPoint; } CmdResult::ReplaceMany(repl, Vec::new()) } fn stage_replacements(&mut self, replacements: &[(Handle, Vec)]) { for (handle, _) in replacements { if let Some(index) = self .all_entities .iter() .position(|entity| entity.common().handle == *handle) { self.all_entities.remove(index); } self.edge_set.retain(|edge| edge != handle); } for (_, entities) in replacements { self.all_entities.extend(entities.iter().cloned()); } self.rebuild_geos(); } } impl CadCommand for TrimCommand { fn name(&self) -> &'static str { "TRIM" } fn prompt(&self) -> String { let edge = if self.implied_edges { t!(" [Edge: Extend]") } else { std::borrow::Cow::Borrowed("") }; match &self.mode { TrimMode::Pick => { crate::tf!("TRIM{edge} Click segment to remove (Shift+click extends):") .into_owned() } TrimMode::SelectEdges => crate::tf!( "TRIM Select cutting edges [{} picked, Enter = done]:", self.edge_set.len() ) .into_owned(), TrimMode::Fence(pick) => crate::tf!( "TRIM{edge} Fence: pick points [{} placed, Enter = trim crossed]:", pick.len() ) .into_owned(), TrimMode::CrossFirst => crate::tf!("TRIM{edge} Crossing: first corner:").into_owned(), TrimMode::CrossSecond(_) => { crate::tf!("TRIM{edge} Crossing: opposite corner:").into_owned() } TrimMode::Erase => { t!("TRIM Erase: click objects to delete [Enter = done]:").into_owned() } } } fn options(&self) -> Vec { use crate::command::CmdOption; match self.mode { TrimMode::Pick => vec![ CmdOption::new("Cutting edges", "T"), CmdOption::new("Fence", "F"), CmdOption::new("Crossing", "C"), CmdOption::new( if self.implied_edges { "Edge: Extend" } else { "Edge: No extend" }, "E", ), CmdOption::new("Erase", "R"), CmdOption::enter("Done"), ], _ => vec![CmdOption::enter("Done")], } } fn wants_text_input(&self) -> bool { true } fn on_text_input(&mut self, text: &str) -> Option { // Consumed inputs return Some(NeedPoint) — None would be offered to // the command a second time by the driver. match text.trim().to_uppercase().as_str() { "T" | "CUTTING" | "B" | "BOUNDARY" => { self.edge_set.clear(); self.mode = TrimMode::SelectEdges; Some(CmdResult::NeedPoint) } "F" | "FENCE" => { self.mode = TrimMode::Fence(FencePick::fence()); Some(CmdResult::NeedPoint) } "C" | "CROSSING" => { self.mode = TrimMode::CrossFirst; Some(CmdResult::NeedPoint) } "E" | "EDGE" => { self.implied_edges = !self.implied_edges; self.rebuild_geos(); Some(CmdResult::NeedPoint) } "R" | "ERASE" => { self.mode = TrimMode::Erase; Some(CmdResult::NeedPoint) } _ => None, } } fn set_shift(&mut self, shift: bool) { self.shift = shift; } fn needs_entity_pick(&self) -> bool { matches!( self.mode, TrimMode::Pick | TrimMode::SelectEdges | TrimMode::Erase ) } fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } match self.mode { TrimMode::SelectEdges => { // Toggle membership; the boundary rebuilds on Enter. if let Some(pos) = self.edge_set.iter().position(|h| *h == handle) { self.edge_set.remove(pos); } else { self.edge_set.push(handle); } CmdResult::NeedPoint } TrimMode::Erase => { // Erase option: delete without needing an intersection. if let Some(pos) = self .all_entities .iter() .position(|e| e.common().handle == handle) { self.all_entities.remove(pos); } self.edge_set.retain(|h| *h != handle); self.rebuild_geos(); CmdResult::ReplaceEntity(handle, vec![]) } _ => { let (px, py) = (pt.x, pt.y); let new_entities = if self.shift { // Shift+click swaps to Extend for this pick (#336). pick_extend_at(&self.all_entities, &self.geos, handle, px, py).map(|e| vec![e]) } else { let geos = self.nearby_geos(handle); pick_trim_at(&self.all_entities, &geos, handle, px, py) }; if let Some(new_entities) = new_entities { // Snapshot is updated in on_entity_replaced once we know // the real handles. Pre-stage: remove the old entry now so // geos exclude it immediately. if let Some(pos) = self .all_entities .iter() .position(|e| e.common().handle == handle) { self.all_entities.remove(pos); // Pieces join with NULL handles as geometry-only placeholders. self.all_entities.extend(new_entities.clone()); self.edge_set.retain(|h| *h != handle); self.rebuild_geos(); } CmdResult::ReplaceEntity(handle, new_entities) } else { CmdResult::NeedPoint } } } } fn on_entity_replaced(&mut self, _old: Handle, new_handles: &[codec::Handle]) { // Batch gestures stage several NULL-handle replacement groups before // the document assigns real handles. The host applies them in the same // order, so fill the first remaining placeholders on each callback. let mut handles = new_handles.iter().copied(); for entity in self .all_entities .iter_mut() .filter(|entity| entity.common().handle.is_null()) { let Some(handle) = handles.next() else { break; }; entity.as_entity_mut().set_handle(handle); } self.rebuild_geos(); } fn on_drag_selection( &mut self, fence: &[[f64; 2]], window: Option<([f64; 2], [f64; 2])>, ) -> Option { if !matches!(self.mode, TrimMode::Pick) || fence.len() < 2 { return None; } let window = window.map(|(min, max)| CrossingWindow { min, max }); let replacements = fence_pass(&self.all_entities, &self.geos, fence, window, self.shift); if replacements.is_empty() { return Some(CmdResult::NeedPoint); } self.stage_replacements(&replacements); Some(CmdResult::ReplaceManyContinue(replacements)) } fn accepts_drag_selection(&self) -> bool { matches!(self.mode, TrimMode::Pick) } fn on_hover_entity(&mut self, handle: Handle, pt: DVec3) -> Vec { match self.mode { TrimMode::SelectEdges => { // Picked cutting edges stay highlighted; the hovered candidate // joins them at half strength. let mut out: Vec = self .edge_set .iter() .filter_map(|h| self.all_entities.iter().find(|e| e.common().handle == *h)) .map(|e| WireModel::solid("edge_sel".into(), entity_pts(e), OPT_YELLOW, false)) .collect(); if !handle.is_null() && !self.edge_set.contains(&handle) { if let Some(e) = self .all_entities .iter() .find(|e| e.common().handle == handle) { let mut c = OPT_YELLOW; c[3] = 0.45; out.push(WireModel::solid( "edge_cand".into(), entity_pts(e), c, false, )); } } return out; } TrimMode::Erase => { // Erase mode: the hovered object previews fully red. if let Some(e) = self .all_entities .iter() .find(|e| e.common().handle == handle) { return vec![WireModel::solid( "erase_prev".into(), entity_pts(e), DIM_RED, false, )]; } return vec![]; } TrimMode::Pick => {} _ => return vec![], } if self.shift { // Shift held: preview the extend result instead. if let Some(ext) = pick_extend_at(&self.all_entities, &self.geos, handle, pt.x, pt.y) { if let Some(orig) = self .all_entities .iter() .find(|e| e.common().handle == handle) { return extend_hover_wires( orig, &ext, &self.all_entities, handle, self.implied_edges, ); } return vec![WireModel::solid( "extend_prev".into(), entity_pts(&ext), WireModel::CYAN, false, )]; } return vec![]; } if handle.is_null() { return vec![]; } let nearby_geos = self.nearby_geos(handle); let geos = nearby_geos.as_slice(); let entity = self.entity_index.get(&self.all_entities, handle); let mut hover_wires = match entity { Some(EntityType::Line(l)) => { let ax = l.start.x; let ay = l.start.y; let bx = l.end.x; let by = l.end.y; let ts = line_seg_ts(ax, ay, bx, by, handle, geos); if ts.is_empty() { return vec![]; } let dx = bx - ax; let dy = by - ay; let len2 = dx * dx + dy * dy; let t_click = if len2 > 1e-12 { ((pt.x as f64 - ax) * dx + (pt.y as f64 - ay) * dy) / len2 } else { 0.5 }; let survivors = trim_line(l, &ts, t_click); let p1 = [l.start.x as f32, l.start.y as f32, l.start.y as f32]; let p2 = [l.end.x as f32, l.end.y as f32, l.end.y as f32]; let removed = WireModel::solid("trim_rm".into(), vec![p1, p2], DIM_RED, false); let mut out = vec![removed]; for (i, e) in survivors.iter().enumerate() { let pts = entity_pts(e); out.push(WireModel::solid( format!("trim_keep_{i}"), pts, WireModel::CYAN, false, )); } out } Some(EntityType::Arc(a)) => { let cx = a.center.x; let cy = a.center.y; let a0 = a.start_angle; let a1 = a.end_angle; let ts = arc_seg_ts(cx, cy, a.radius, a0, a1, handle, geos); if ts.is_empty() { return vec![]; } let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx); let t_click = arc_t(click_angle, a0, a1); let survivors = trim_arc(a, &ts, t_click); let orig_pts = arc_pts(cx, cy, a.radius, a0, a1, a.center.z); let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false); let mut out = vec![removed]; for (i, e) in survivors.iter().enumerate() { let pts = entity_pts(e); out.push(WireModel::solid( format!("trim_keep_{i}"), pts, WireModel::CYAN, false, )); } out } Some(EntityType::Circle(c)) => { let cx = c.center.x; let cy = c.center.y; let ts = arc_seg_ts(cx, cy, c.radius, 0.0, TAU, handle, geos); if ts.len() < 2 { return vec![]; } let click_angle = (pt.y as f64 - cy).atan2(pt.x as f64 - cx); let t_click = arc_t(click_angle, 0.0, TAU); let survivors = trim_circle(c, &ts, t_click); if survivors.is_empty() { return vec![]; } let orig_pts = arc_pts(cx, cy, c.radius, 0.0, TAU, c.center.z); let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false); let mut out = vec![removed]; for (i, e) in survivors.iter().enumerate() { let pts = entity_pts(e); out.push(WireModel::solid( format!("trim_keep_{i}"), pts, WireModel::CYAN, false, )); } out } Some(EntityType::Ray(r)) => { let bx = r.base_point.x; let by = r.base_point.y; let curve = ray_curve(r); let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return vec![]; } let t_click = curve.parameter_at([pt.x as f64, pt.y as f64]); let survivors = trim_ray(r, &ts, t_click); // Show a finite preview section (20 units) for the original ray let far = [ (bx + r.direction.x * 20.0) as f32, (by + r.direction.y * 20.0) as f32, r.base_point.z as f32, ]; let base = [bx as f32, by as f32, r.base_point.z as f32]; let removed = WireModel::solid("trim_rm".into(), vec![base, far], DIM_RED, false); let mut out = vec![removed]; for (i, e) in survivors.iter().enumerate() { let pts = entity_pts(e); out.push(WireModel::solid( format!("trim_keep_{i}"), pts, WireModel::CYAN, false, )); } out } Some(EntityType::XLine(x)) => { let bx = x.base_point.x; let by = x.base_point.y; let curve = xline_curve(x); let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return vec![]; } let t_click = curve.parameter_at([pt.x as f64, pt.y as f64]); let survivors = trim_xline(x, &ts, t_click); let neg = [ (bx - x.direction.x * 20.0) as f32, (by - x.direction.y * 20.0) as f32, x.base_point.z as f32, ]; let pos_pt = [ (bx + x.direction.x * 20.0) as f32, (by + x.direction.y * 20.0) as f32, x.base_point.z as f32, ]; let removed = WireModel::solid("trim_rm".into(), vec![neg, pos_pt], DIM_RED, false); let mut out = vec![removed]; for (i, e) in survivors.iter().enumerate() { let pts = entity_pts(e); out.push(WireModel::solid( format!("trim_keep_{i}"), pts, WireModel::CYAN, false, )); } out } Some(EntityType::Ellipse(e)) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a < 1e-9 { return vec![]; } let b = a * e.minor_axis_ratio; if b < 1e-9 { return vec![]; } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; if t1 <= t0 { t1 += TAU; } let ts = ellipse_seg_ts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, handle, geos); if ts.is_empty() { return vec![]; } let rx = pt.x as f64 - e.center.x; let ry = pt.y as f64 - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; let t_ell = ellipse_closest_parameter(a, b, xl, yl); let t_click = arc_t(t_ell, t0, t1); let survivors = trim_ellipse(e, &ts, t_click); let orig_pts = ellipse_pts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, e.center.z); let removed = WireModel::solid("trim_rm".into(), orig_pts, DIM_RED, false); 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 } Some(EntityType::Spline(s)) => { let ts = spline_seg_ts(s, handle, 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 (t0, t1) = spline_range(s)?; 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 } Some(EntityType::LwPolyline(p)) => { let Some(survivors) = trim_lwpolyline(p, pt.x as f64, pt.y as f64, geos) else { return vec![]; }; let orig = WireModel::solid( "trim_rm".into(), entity_pts(entity.unwrap()), DIM_RED, false, ); let mut out = vec![orig]; for (i, ent) in survivors.iter().enumerate() { out.push(WireModel::solid( format!("trim_keep_{i}"), entity_pts(ent), WireModel::CYAN, false, )); } out } _ => vec![], }; // Edge: Extend — explain cuts landing on an IMPLIED boundary with a // dashed guide from that boundary's drawn end to the cut point (#336). if self.implied_edges && !hover_wires.is_empty() { if let (Some(orig), Some(pieces)) = ( entity, pick_trim_at(&self.all_entities, geos, handle, pt.x, pt.y), ) { let cuts = piece_cut_points(orig, &pieces); hover_wires.extend(implied_cut_guides(&self.all_entities, handle, &cuts)); } } hover_wires } fn on_point(&mut self, pt: DVec3) -> CmdResult { match &mut self.mode { TrimMode::Fence(pick) => { pick.push([pt.x, pt.y]); CmdResult::NeedPoint } TrimMode::CrossFirst => { self.mode = TrimMode::CrossSecond([pt.x, pt.y]); CmdResult::NeedPoint } TrimMode::CrossSecond(p1) => { // The rectangle's edges act as a 4-segment fence, and objects // wholly inside the window are picked too (crossing-selection // semantics). let p1 = *p1; let p2 = [pt.x, pt.y]; self.mode = TrimMode::Pick; let rect = [p1, [p1[0], p2[1]], p2, [p2[0], p1[1]], p1]; let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], }; self.fence_run(&rect, Some(window)) } _ => CmdResult::NeedPoint, } } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { match &self.mode { TrimMode::Fence(pick) if !pick.is_empty() => { let mut out = vec![pick.preview([pt.x, pt.y], "trim_fence")]; let mut fpts = pick.path(); fpts.push([pt.x, pt.y]); out.extend(fence_result_preview( &self.all_entities, &self.geos, &fpts, None, self.shift, self.implied_edges, )); out } TrimMode::CrossSecond(p1) => { let p1 = *p1; let p2 = [pt.x, pt.y]; let mut out = vec![crossing_box_preview(p1, p2, "trim_cross")]; let rect = [p1, [p1[0], p2[1]], p2, [p2[0], p1[1]], p1]; let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], }; out.extend(fence_result_preview( &self.all_entities, &self.geos, &rect, Some(window), self.shift, self.implied_edges, )); out } _ => Vec::new(), } } fn on_enter(&mut self) -> CmdResult { match std::mem::replace(&mut self.mode, TrimMode::Pick) { TrimMode::SelectEdges => { self.rebuild_geos(); CmdResult::NeedPoint } TrimMode::Fence(pick) if pick.is_usable() => self.fence_run(&pick.path(), None), TrimMode::Fence(_) | TrimMode::CrossFirst | TrimMode::CrossSecond(_) | TrimMode::Erase => CmdResult::NeedPoint, TrimMode::Pick => CmdResult::Cancel, } } fn on_escape(&mut self) -> CmdResult { // Esc leaves a sub-mode first; a second Esc ends the command. if !matches!(self.mode, TrimMode::Pick) { self.mode = TrimMode::Pick; return CmdResult::NeedPoint; } CmdResult::Cancel } } // ══════════════════════════════════════════════════════════════════════════ // ExtendCommand // ══════════════════════════════════════════════════════════════════════════ pub struct ExtendCommand { all_entities: Vec, entity_index: ModifyEntityIndex, geos: Vec, mode: TrimMode, /// Boundary-edge selection; empty = every object is a boundary. edge_set: Vec, /// Edge option: boundaries extrapolate past their drawn extent. implied_edges: bool, /// Live Shift state — Shift+click trims instead of extends (#336). shift: bool, } impl ExtendCommand { pub fn new(all_entities: Vec) -> Self { let all_entities: Vec = all_entities .iter() .map(entity_with_lwpolyline_world_xy) .collect(); let entity_index = ModifyEntityIndex::build(&all_entities); let geos = build_geos(&all_entities); Self { all_entities, entity_index, geos, mode: TrimMode::Pick, edge_set: Vec::new(), implied_edges: false, shift: false, } } fn rebuild_geos(&mut self) { self.entity_index = ModifyEntityIndex::build(&self.all_entities); self.geos = if self.edge_set.is_empty() { build_geos(&self.all_entities) } else { let picked: Vec = self .all_entities .iter() .filter(|e| self.edge_set.contains(&e.common().handle)) .cloned() .collect(); build_geos(&picked) }; if self.implied_edges { imply_edge_geos(&mut self.geos); } } fn fence_run(&mut self, fence: &[[f64; 2]], window: Option) -> CmdResult { // EXTEND's fence extends; Shift held at Enter swaps it to trim. let repl = fence_pass(&self.all_entities, &self.geos, fence, window, !self.shift); if repl.is_empty() { return CmdResult::NeedPoint; } CmdResult::ReplaceMany(repl, Vec::new()) } } impl CadCommand for ExtendCommand { fn name(&self) -> &'static str { "EXTEND" } fn prompt(&self) -> String { let edge = if self.implied_edges { t!(" [Edge: Extend]") } else { std::borrow::Cow::Borrowed("") }; match &self.mode { TrimMode::Pick => { crate::tf!("EXTEND{edge} Click near end of object to extend (Shift+click trims):") .into_owned() } TrimMode::SelectEdges => crate::tf!( "EXTEND Select boundary edges [{} picked, Enter = done]:", self.edge_set.len() ) .into_owned(), TrimMode::Fence(pick) => crate::tf!( "EXTEND{edge} Fence: pick points [{} placed, Enter = extend crossed]:", pick.len() ) .into_owned(), TrimMode::CrossFirst => { crate::tf!("EXTEND{edge} Crossing: first corner:").into_owned() } TrimMode::CrossSecond(_) => { crate::tf!("EXTEND{edge} Crossing: opposite corner:").into_owned() } TrimMode::Erase => t!("EXTEND [Enter = done]:").into_owned(), } } fn options(&self) -> Vec { use crate::command::CmdOption; match self.mode { TrimMode::Pick => vec![ CmdOption::new("Boundary edges", "B"), CmdOption::new("Fence", "F"), CmdOption::new("Crossing", "C"), CmdOption::new( if self.implied_edges { "Edge: Extend" } else { "Edge: No extend" }, "E", ), CmdOption::enter("Done"), ], _ => vec![CmdOption::enter("Done")], } } fn wants_text_input(&self) -> bool { true } fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { "B" | "BOUNDARY" | "T" | "CUTTING" => { self.edge_set.clear(); self.mode = TrimMode::SelectEdges; Some(CmdResult::NeedPoint) } "F" | "FENCE" => { self.mode = TrimMode::Fence(FencePick::fence()); Some(CmdResult::NeedPoint) } "C" | "CROSSING" => { self.mode = TrimMode::CrossFirst; Some(CmdResult::NeedPoint) } "E" | "EDGE" => { self.implied_edges = !self.implied_edges; self.rebuild_geos(); Some(CmdResult::NeedPoint) } _ => None, } } fn set_shift(&mut self, shift: bool) { self.shift = shift; } fn needs_entity_pick(&self) -> bool { matches!(self.mode, TrimMode::Pick | TrimMode::SelectEdges) } fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } match self.mode { TrimMode::SelectEdges => { if let Some(pos) = self.edge_set.iter().position(|h| *h == handle) { self.edge_set.remove(pos); } else { self.edge_set.push(handle); } CmdResult::NeedPoint } _ => { let (px, py) = (pt.x, pt.y); let new_entities = if self.shift { // Shift+click swaps to Trim for this pick (#336). pick_trim_at(&self.all_entities, &self.geos, handle, px, py) } else { pick_extend_at(&self.all_entities, &self.geos, handle, px, py).map(|e| vec![e]) }; if let Some(new_entities) = new_entities { // Same snapshot bookkeeping as TRIM: drop the old entry, // append the pieces as NULL-handle placeholders, and let // on_entity_replaced assign the real handles. if let Some(pos) = self .all_entities .iter() .position(|e| e.common().handle == handle) { self.all_entities.remove(pos); self.all_entities.extend(new_entities.clone()); self.edge_set.retain(|h| *h != handle); self.rebuild_geos(); } CmdResult::ReplaceEntity(handle, new_entities) } else { CmdResult::NeedPoint } } } } fn on_entity_replaced(&mut self, _old: Handle, new_handles: &[codec::Handle]) { // The last new_handles.len() entries are the pieces appended with NULL // handles in on_entity_pick — assign their real document handles. let start = self.all_entities.len().saturating_sub(new_handles.len()); for (e, &h) in self.all_entities[start..] .iter_mut() .zip(new_handles.iter()) { match e { EntityType::Line(l) => l.common.handle = h, EntityType::Arc(a) => a.common.handle = h, 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, EntityType::LwPolyline(p) => p.common.handle = h, _ => {} } } self.rebuild_geos(); } fn on_hover_entity(&mut self, handle: Handle, pt: DVec3) -> Vec { match self.mode { TrimMode::SelectEdges => { let mut out: Vec = self .edge_set .iter() .filter_map(|h| self.entity_index.get(&self.all_entities, *h)) .map(|e| WireModel::solid("edge_sel".into(), entity_pts(e), OPT_YELLOW, false)) .collect(); if !handle.is_null() && !self.edge_set.contains(&handle) { if let Some(e) = self.entity_index.get(&self.all_entities, handle) { let mut c = OPT_YELLOW; c[3] = 0.45; out.push(WireModel::solid( "edge_cand".into(), entity_pts(e), c, false, )); } } return out; } TrimMode::Pick => {} _ => return vec![], } if self.shift { // Shift held: preview the trim result instead. if let Some(pieces) = pick_trim_at(&self.all_entities, &self.geos, handle, pt.x, pt.y) { let mut out = Vec::new(); for (i, e) in pieces.iter().enumerate() { out.push(WireModel::solid( format!("trim_keep_{i}"), entity_pts(e), WireModel::CYAN, false, )); } return out; } return vec![]; } if handle.is_null() { return vec![]; } let entity = self.entity_index.get(&self.all_entities, handle); match entity { Some(EntityType::Line(l)) => { let ax = l.start.x; let ay = l.start.y; let bx = l.end.x; let by = l.end.y; let dx = bx - ax; let dy = by - ay; let len2 = dx * dx + dy * dy; let t_click = if len2 > 1e-12 { ((pt.x as f64 - ax) * dx + (pt.y as f64 - ay) * dy) / len2 } else { 0.5 }; if let Some(ext) = extend_line(l, t_click, &self.geos) { return extend_hover_wires( &EntityType::Line(l.clone()), &ext, &self.all_entities, handle, self.implied_edges, ); } } Some(EntityType::Arc(a)) => { let ang = (pt.y as f64 - a.center.y).atan2(pt.x as f64 - a.center.x); let t_click = arc_t(ang, a.start_angle, a.end_angle); if let Some(ext) = extend_arc(a, t_click, &self.geos) { return extend_hover_wires( &EntityType::Arc(a.clone()), &ext, &self.all_entities, handle, self.implied_edges, ); } } Some(EntityType::Ellipse(e)) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a >= 1e-9 { let b = a * e.minor_axis_ratio; if b >= 1e-9 { let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; if t1 <= t0 { t1 += TAU; } let rx = pt.x as f64 - e.center.x; let ry = pt.y as f64 - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; let t_ell = ellipse_closest_parameter(a, b, xl, yl); let t_click = arc_t(t_ell, t0, t1); if let Some(ext) = extend_ellipse(e, t_click, &self.geos) { return extend_hover_wires( &EntityType::Ellipse(e.clone()), &ext, &self.all_entities, handle, self.implied_edges, ); } } } } Some(EntityType::LwPolyline(p)) => { if let Some(ext) = extend_lwpoly(p, pt.x as f64, pt.y as f64, &self.geos) { return extend_hover_wires( &EntityType::LwPolyline(p.clone()), &ext, &self.all_entities, handle, self.implied_edges, ); } } Some(EntityType::Spline(s)) => { let t_click = spline_nearest_t(s, pt.x as f64, pt.y as f64) .and_then(|t_actual| { let (t0, t1) = spline_range(s)?; Some(t_to_rel(t_actual, t0, t1)) }) .unwrap_or(0.5); if let Some(ext) = extend_spline(s, t_click, &self.geos) { return extend_hover_wires( &EntityType::Spline(s.clone()), &ext, &self.all_entities, handle, self.implied_edges, ); } } _ => {} } vec![] } fn on_point(&mut self, pt: DVec3) -> CmdResult { match &mut self.mode { TrimMode::Fence(pick) => { pick.push([pt.x, pt.y]); CmdResult::NeedPoint } TrimMode::CrossFirst => { self.mode = TrimMode::CrossSecond([pt.x, pt.y]); CmdResult::NeedPoint } TrimMode::CrossSecond(p1) => { let p1 = *p1; let p2 = [pt.x, pt.y]; self.mode = TrimMode::Pick; let rect = [p1, [p1[0], p2[1]], p2, [p2[0], p1[1]], p1]; let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], }; self.fence_run(&rect, Some(window)) } _ => CmdResult::NeedPoint, } } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { match &self.mode { TrimMode::Fence(pick) if !pick.is_empty() => { let mut out = vec![pick.preview([pt.x, pt.y], "extend_fence")]; let mut fpts = pick.path(); fpts.push([pt.x, pt.y]); out.extend(fence_result_preview( &self.all_entities, &self.geos, &fpts, None, !self.shift, self.implied_edges, )); out } TrimMode::CrossSecond(p1) => { let p1 = *p1; let p2 = [pt.x, pt.y]; let mut out = vec![crossing_box_preview(p1, p2, "extend_cross")]; let rect = [p1, [p1[0], p2[1]], p2, [p2[0], p1[1]], p1]; let window = CrossingWindow { min: [p1[0].min(p2[0]), p1[1].min(p2[1])], max: [p1[0].max(p2[0]), p1[1].max(p2[1])], }; out.extend(fence_result_preview( &self.all_entities, &self.geos, &rect, Some(window), !self.shift, self.implied_edges, )); out } _ => Vec::new(), } } fn on_enter(&mut self) -> CmdResult { match std::mem::replace(&mut self.mode, TrimMode::Pick) { TrimMode::SelectEdges => { self.rebuild_geos(); CmdResult::NeedPoint } TrimMode::Fence(pick) if pick.is_usable() => self.fence_run(&pick.path(), None), TrimMode::Fence(_) | TrimMode::CrossFirst | TrimMode::CrossSecond(_) | TrimMode::Erase => CmdResult::NeedPoint, TrimMode::Pick => CmdResult::Cancel, } } fn on_escape(&mut self) -> CmdResult { if !matches!(self.mode, TrimMode::Pick) { self.mode = TrimMode::Pick; return CmdResult::NeedPoint; } CmdResult::Cancel } } // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["EXTEND"] }); // ExtendCommand inventory::submit!(crate::command::CommandRegistration { names: &["TRIM"] }); // TrimCommand #[cfg(test)] mod tests { use super::*; use std::f64::consts::PI; fn xline_at(x1: f64, y1: f64, x2: f64, y2: f64, h: u64) -> EntityType { let mut l = LineEnt::new(); l.start = Vector3::new(x1, y1, 0.0); l.end = Vector3::new(x2, y2, 0.0); l.common.handle = Handle::new(h); EntityType::Line(l) } /// Edge: Extend — the target must lengthen to the BOUNDARY'S extrapolation /// when the boundary doesn't physically cross the target's path (#336). #[test] fn edge_extend_reaches_implied_boundary() { let target = xline_at(0.0, 0.0, 2.0, 0.0, 1); let boundary = xline_at(5.0, 2.0, 5.0, 8.0, 2); let all = vec![target.clone(), boundary]; // Edge off: the boundary's drawn extent never meets y=0 — no extend. let geos = build_geos(&all); assert!(pick_extend_at(&all, &geos, Handle::new(1), 1.9, 0.0).is_none()); // Edge on: the implied boundary crosses y=0 at x=5 — extend to (5,0). let mut implied = build_geos(&all); imply_edge_geos(&mut implied); match pick_extend_at(&all, &implied, Handle::new(1), 1.9, 0.0) { Some(EntityType::Line(l)) => { assert!( (l.end.x - 5.0).abs() < 1e-6 && l.end.y.abs() < 1e-6, "extends to the implied boundary, got ({}, {})", l.end.x, l.end.y ); } other => panic!("expected an extended Line, got {other:?}"), } } /// #336 repro: two lines forming an X; a fence across one overhanging arm /// must trim that arm back to the intersection. #[test] fn fence_trims_x_arm() { let all = vec![ xline_at(0.0, 0.0, 10.0, 10.0, 1), xline_at(0.0, 10.0, 10.0, 0.0, 2), ]; let geos = build_geos(&all); let fence = [[8.0, 10.0], [10.0, 8.0]]; let repl = fence_pass(&all, &geos, &fence, None, false); assert_eq!( repl.len(), 1, "exactly the crossed line is trimmed: {repl:?}" ); assert_eq!(repl[0].0, Handle::new(1)); assert_eq!(repl[0].1.len(), 1); match &repl[0].1[0] { EntityType::Line(l) => { assert!( (l.end.x - 5.0).abs() < 1e-6 && (l.end.y - 5.0).abs() < 1e-6, "arm cut back to the intersection, got end ({}, {})", l.end.x, l.end.y ); } other => panic!("expected a Line, got {other:?}"), } } fn circle(r: f64) -> CircleEnt { let mut c = CircleEnt::new(); c.center = Vector3::new(0.0, 0.0, 0.0); c.radius = r; c } /// A circle crossed by a horizontal cutter (cuts at angle 0 and π, i.e. /// t = 0.0 and 0.5) becomes a single Arc; clicking the top half removes it /// and leaves the bottom half (π → 0 CCW), clicking the bottom does the /// reverse. #[test] fn trims_circle_into_arc_on_clicked_half() { let c = circle(10.0); let ts = [0.0, 0.5]; // Click top (t = 0.25) → removes top, keeps bottom half (start π, end 0). let top = trim_circle(&c, &ts, 0.25); assert_eq!(top.len(), 1, "circle should trim to exactly one arc"); match &top[0] { EntityType::Arc(a) => { assert!((norm(a.start_angle) - PI).abs() < 1e-9); assert!(norm(a.end_angle).abs() < 1e-9); assert_eq!(a.radius, 10.0); assert!(a.common.handle.is_null()); } _ => panic!("expected an Arc"), } // Click bottom (t = 0.75) → keeps top half (start 0, end π). let bottom = trim_circle(&c, &ts, 0.75); match &bottom[0] { EntityType::Arc(a) => { assert!(norm(a.start_angle).abs() < 1e-9); assert!((norm(a.end_angle) - PI).abs() < 1e-9); } _ => panic!("expected an Arc"), } } /// Fewer than two crossings can't cut a closed circle, so it is left as-is. #[test] fn circle_with_one_or_zero_cuts_is_left_unchanged() { let c = circle(5.0); assert!(trim_circle(&c, &[], 0.3).is_empty()); assert!(trim_circle(&c, &[0.4], 0.3).is_empty()); } #[test] fn trim_polyline_arc_segment_preserves_bulge() { use std::f64::consts::FRAC_PI_8; // Arc from (0, 0) to (2, 0) with bulge 1.0 (semicircle sweeping CCW under the chord) let mut poly = LwPolyline::new(); poly.common.handle = Handle::new(10); let mut v0 = LwVertex::from_coords(0.0, 0.0); v0.bulge = 1.0; let v1 = LwVertex::from_coords(2.0, 0.0); poly.vertices = vec![v0, v1]; // Vertical cutter line at x = 1.0 crossing the arc at (1.0, -1.0) let mut cutter = LineEnt::new(); cutter.start = Vector3::new(1.0, -2.0, 0.0); cutter.end = Vector3::new(1.0, 2.0, 0.0); cutter.common.handle = Handle::new(20); let all = vec![EntityType::LwPolyline(poly), EntityType::Line(cutter)]; let geos = build_geos(&all); // Click first half near (0.5, -0.8) -> first half removed, second half kept from (1.0, -1.0) to (2.0, 0.0) let res = pick_trim_at(&all, &geos, Handle::new(10), 0.5, -0.8) .expect("should trim polyline arc"); assert_eq!(res.len(), 1); match &res[0] { EntityType::LwPolyline(p) => { assert_eq!(p.vertices.len(), 2); let p0 = &p.vertices[0]; let p1 = &p.vertices[1]; assert!( (p0.location.x - 1.0).abs() < 1e-5, "expected x=1.0, got {}", p0.location.x ); assert!( (p0.location.y + 1.0).abs() < 1e-5, "expected y=-1.0, got {}", p0.location.y ); assert!( (p1.location.x - 2.0).abs() < 1e-5, "expected x=2.0, got {}", p1.location.x ); assert!( p1.location.y.abs() < 1e-5, "expected y=0.0, got {}", p1.location.y ); // The surviving quarter circle should have bulge = tan(pi / 8) let expected_bulge = FRAC_PI_8.tan(); assert!( (p0.bulge - expected_bulge).abs() < 1e-4, "surviving arc should retain quarter-circle bulge {}, got {}", expected_bulge, p0.bulge ); assert_eq!(p1.bulge, 0.0); } other => panic!("expected LwPolyline, got {other:?}"), } // Click second half near (1.5, -0.8) -> second half removed, first half kept from (0.0, 0.0) to (1.0, -1.0) let res2 = pick_trim_at(&all, &geos, Handle::new(10), 1.5, -0.8) .expect("should trim polyline arc"); assert_eq!(res2.len(), 1); match &res2[0] { EntityType::LwPolyline(p) => { assert_eq!(p.vertices.len(), 2); let p0 = &p.vertices[0]; let p1 = &p.vertices[1]; assert!( p0.location.x.abs() < 1e-5, "expected x=0.0, got {}", p0.location.x ); assert!( p0.location.y.abs() < 1e-5, "expected y=0.0, got {}", p0.location.y ); assert!( (p1.location.x - 1.0).abs() < 1e-5, "expected x=1.0, got {}", p1.location.x ); assert!( (p1.location.y + 1.0).abs() < 1e-5, "expected y=-1.0, got {}", p1.location.y ); let expected_bulge = FRAC_PI_8.tan(); assert!( (p0.bulge - expected_bulge).abs() < 1e-4, "surviving arc should retain quarter-circle bulge {}, got {}", expected_bulge, p0.bulge ); assert_eq!(p1.bulge, 0.0); } other => panic!("expected LwPolyline, got {other:?}"), } } #[test] fn trim_multi_segment_polyline_with_arc() { use std::f64::consts::FRAC_PI_8; // Segment 0: straight line from (-2, 0) to (0, 0) // Segment 1: arc from (0, 0) to (2, 0) with bulge 1.0 let mut poly = LwPolyline::new(); poly.common.handle = Handle::new(30); let v0 = LwVertex::from_coords(-2.0, 0.0); let mut v1 = LwVertex::from_coords(0.0, 0.0); v1.bulge = 1.0; let v2 = LwVertex::from_coords(2.0, 0.0); poly.vertices = vec![v0, v1, v2]; // Vertical cutter at x = 1.0 let mut cutter = LineEnt::new(); cutter.start = Vector3::new(1.0, -2.0, 0.0); cutter.end = Vector3::new(1.0, 2.0, 0.0); cutter.common.handle = Handle::new(40); let all = vec![EntityType::LwPolyline(poly), EntityType::Line(cutter)]; let geos = build_geos(&all); // Click the far end of the arc near (1.5, -0.8) -> removes end, keeps straight seg + first half of arc let res = pick_trim_at(&all, &geos, Handle::new(30), 1.5, -0.8).expect("should trim"); assert_eq!(res.len(), 1); match &res[0] { EntityType::LwPolyline(p) => { assert_eq!(p.vertices.len(), 3); // Vertex 0: (-2, 0), straight assert!((p.vertices[0].location.x + 2.0).abs() < 1e-5); assert!(p.vertices[0].bulge.abs() < 1e-5); // Vertex 1: (0, 0), arc with quarter-turn bulge assert!(p.vertices[1].location.x.abs() < 1e-5); let expected_bulge = FRAC_PI_8.tan(); assert!((p.vertices[1].bulge - expected_bulge).abs() < 1e-4); // Vertex 2: (1, -1), end assert!((p.vertices[2].location.x - 1.0).abs() < 1e-5); assert!((p.vertices[2].location.y + 1.0).abs() < 1e-5); } other => panic!("expected LwPolyline, got {other:?}"), } } #[test] fn trim_negative_bulge_polyline_arc() { use std::f64::consts::FRAC_PI_8; // Negative bulge means CW arc: from (0, 0) to (2, 0) arches above the chord (y > 0) let mut poly = LwPolyline::new(); poly.common.handle = Handle::new(50); let mut v0 = LwVertex::from_coords(0.0, 0.0); v0.bulge = -1.0; let v1 = LwVertex::from_coords(2.0, 0.0); poly.vertices = vec![v0, v1]; // Vertical cutter at x = 1.0 crossing at (1.0, 1.0) let mut cutter = LineEnt::new(); cutter.start = Vector3::new(1.0, -2.0, 0.0); cutter.end = Vector3::new(1.0, 2.0, 0.0); cutter.common.handle = Handle::new(60); let all = vec![EntityType::LwPolyline(poly), EntityType::Line(cutter)]; let geos = build_geos(&all); // Click first half near (0.5, 0.8) -> removes first half, keeps second half from (1.0, 1.0) to (2.0, 0.0) let res = pick_trim_at(&all, &geos, Handle::new(50), 0.5, 0.8).expect("should trim CW arc"); assert_eq!(res.len(), 1); match &res[0] { EntityType::LwPolyline(p) => { assert_eq!(p.vertices.len(), 2); let p0 = &p.vertices[0]; let p1 = &p.vertices[1]; assert!((p0.location.x - 1.0).abs() < 1e-5); assert!((p0.location.y - 1.0).abs() < 1e-5); assert!((p1.location.x - 2.0).abs() < 1e-5); assert!(p1.location.y.abs() < 1e-5); let expected_bulge = -FRAC_PI_8.tan(); assert!( (p0.bulge - expected_bulge).abs() < 1e-4, "expected negative bulge {}, got {}", expected_bulge, p0.bulge ); } other => panic!("expected LwPolyline, got {other:?}"), } } #[test] fn trim_closed_polyline_with_arc() { use std::f64::consts::FRAC_PI_8; // Closed polyline: // Vertex 0: (0, 0), bulge 1.0 (arc to (2, 0) below chord, peak at y=-1) // Vertex 1: (2, 0), bulge 0.0 (straight to (2, 2)) // Vertex 2: (2, 2), bulge 0.0 (straight to (0, 2)) // Vertex 3: (0, 2), bulge 0.0 (straight back to (0, 0)) let mut poly = LwPolyline::new(); poly.common.handle = Handle::new(70); poly.is_closed = true; let mut v0 = LwVertex::from_coords(0.0, 0.0); v0.bulge = 1.0; let v1 = LwVertex::from_coords(2.0, 0.0); let v2 = LwVertex::from_coords(2.0, 2.0); let v3 = LwVertex::from_coords(0.0, 2.0); poly.vertices = vec![v0, v1, v2, v3]; // Cutter 1: x = 1.0 cuts the arc at (1.0, -1.0) let mut c1 = LineEnt::new(); c1.start = Vector3::new(1.0, -3.0, 0.0); c1.end = Vector3::new(1.0, -0.5, 0.0); c1.common.handle = Handle::new(80); // Cutter 2: y = 1.0 cuts vertical edges at x=0 and x=2 let mut c2 = LineEnt::new(); c2.start = Vector3::new(-1.0, 1.0, 0.0); c2.end = Vector3::new(3.0, 1.0, 0.0); c2.common.handle = Handle::new(81); let all = vec![ EntityType::LwPolyline(poly), EntityType::Line(c1), EntityType::Line(c2), ]; let geos = build_geos(&all); // Click first half of arc near (0.5, -0.8): // Nearest cuts are at (1.0, -1.0) on seg 0 and at (0.0, 1.0) on seg 3. // Trim removes that corner/arc portion and leaves an open polyline let res = pick_trim_at(&all, &geos, Handle::new(70), 0.5, -0.8) .expect("should trim closed polyline"); assert_eq!(res.len(), 1); match &res[0] { EntityType::LwPolyline(p) => { assert!(!p.is_closed); // Starts at (1.0, -1.0) with remaining quarter-circle arc let start_v = &p.vertices[0]; assert!((start_v.location.x - 1.0).abs() < 1e-5); assert!((start_v.location.y + 1.0).abs() < 1e-5); let expected_bulge = FRAC_PI_8.tan(); assert!((start_v.bulge - expected_bulge).abs() < 1e-4); } other => panic!("expected LwPolyline, got {other:?}"), } } /// #1318 repro: a 3D polyline whose first segment is not horizontal has no /// plan-view shape, so it samples to nothing. The seam test then compared /// two empty ends as equal and sliced the empty sample from index 1. #[test] fn sampling_skips_segments_with_no_plan_shape() { use codec::entities::{Polyline3D, Vertex3DPolyline}; let mut pl = Polyline3D::new(); pl.vertices = vec![ // Vertical: an upright curve plane, dropped by `entity_curve_xy`. Vertex3DPolyline::from_xyz(0.0, 0.0, 0.0), Vertex3DPolyline::from_xyz(0.0, 0.0, 10.0), // Horizontal, so this one does sample. Vertex3DPolyline::from_xyz(10.0, 0.0, 10.0), ]; let pts = sample_entity_xy(&EntityType::Polyline3D(pl)); assert!( !pts.is_empty(), "the horizontal segment still has to be sampled" ); assert!( pts.iter().all(|p| p.iter().all(|c| c.is_finite())), "sampled points stay finite: {pts:?}" ); } /// The same skip must not swallow a leading segment that does sample, and /// must keep dropping the duplicated seam vertex between two of them. #[test] fn sampling_still_joins_segments_at_their_seam() { use codec::entities::{Polyline3D, Vertex3DPolyline}; let mut pl = Polyline3D::new(); pl.vertices = vec![ Vertex3DPolyline::from_xyz(0.0, 0.0, 0.0), Vertex3DPolyline::from_xyz(10.0, 0.0, 0.0), Vertex3DPolyline::from_xyz(10.0, 10.0, 0.0), ]; let pts = sample_entity_xy(&EntityType::Polyline3D(pl)); let seam = [10.0, 0.0]; assert_eq!( pts.iter().filter(|p| **p == seam).count(), 1, "the shared vertex appears once, not twice: {pts:?}" ); } #[test] fn preview_sampling_skips_edge_on_polyline2d_arc_segments() { use codec::entities::{Polyline2D, Vertex2D}; let mut pl = Polyline2D::new(); let mut start = Vertex2D::new(Vector3::new(0.0, 0.0, 0.0)); start.bulge = 1.0; pl.vertices = vec![start, Vertex2D::new(Vector3::new(2.0, 0.0, 0.0))]; pl.normal = Vector3::new(1.0, 0.0, 0.0); let pts = preview_sample_xy(&EntityType::Polyline2D(pl)); assert!( pts.is_empty(), "edge-on arc segments are skipped instead of panicking: {pts:?}" ); } #[test] fn trim_circle_with_tangent_arcs_touching_at_endpoints() { use codec::entities::{Arc as ArcEnt, Circle as CircleEnt}; let mut c = CircleEnt::new(); c.common.handle = Handle::new(1); c.center = Vector3::new(0.0, 0.0, 0.0); c.radius = 10.0; // Arc 1: center (20, 0), r = 10, from PI/2 to PI (endpoint at (10, 0)) let mut a1 = ArcEnt::new(); a1.common.handle = Handle::new(2); a1.center = Vector3::new(20.0, 0.0, 0.0); a1.radius = 10.0; a1.start_angle = std::f64::consts::FRAC_PI_2; a1.end_angle = std::f64::consts::PI; // Arc 2: center (-20, 0), r = 10, from 0 to PI/2 (endpoint at (-10, 0)) let mut a2 = ArcEnt::new(); a2.common.handle = Handle::new(3); a2.center = Vector3::new(-20.0, 0.0, 0.0); a2.radius = 10.0; a2.start_angle = 0.0; a2.end_angle = std::f64::consts::FRAC_PI_2; let all = vec![ EntityType::Circle(c), EntityType::Arc(a1), EntityType::Arc(a2), ]; let geos = build_geos(&all); // Click on top of the circle at (0, 10) let survivors = pick_trim_at(&all, &geos, Handle::new(1), 0.0, 10.0); assert!( survivors.is_some(), "trimming circle between two tangent arc endpoints must succeed" ); let pieces = survivors.unwrap(); assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); if let EntityType::Arc(arc) = &pieces[0] { assert!((arc.radius - 10.0).abs() < 1e-6); let span = { let s = (arc.end_angle - arc.start_angle).rem_euclid(TAU); if s == 0.0 { TAU } else { s } }; let mid_ang = arc.start_angle + span * 0.5; let mid_y = mid_ang.sin(); assert!( mid_y < 0.0, "bottom half should survive when clicking top half" ); } else { panic!("expected Arc survivor"); } } #[test] fn test_trim_circle_between_two_tangent_lines() { use codec::entities::{Circle as CircleEnt, Line as LineEnt}; let mut c = CircleEnt::new(); c.common.handle = Handle::new(1); c.center = Vector3::new(0.0, 0.0, 0.0); c.radius = 10.0; // Line 1: tangent at top (0, 10), from (-20, 10) to (20, 10) let mut l1 = LineEnt::new(); l1.common.handle = Handle::new(2); l1.start = Vector3::new(-20.0, 10.0, 0.0); l1.end = Vector3::new(20.0, 10.0, 0.0); // Line 2: tangent at bottom (0, -10), from (-20, -10) to (20, -10) let mut l2 = LineEnt::new(); l2.common.handle = Handle::new(3); l2.start = Vector3::new(-20.0, -10.0, 0.0); l2.end = Vector3::new(20.0, -10.0, 0.0); let all = vec![ EntityType::Circle(c), EntityType::Line(l1), EntityType::Line(l2), ]; let geos = build_geos(&all); // Trim circle by clicking right side at (10, 0) let survivors = pick_trim_at(&all, &geos, Handle::new(1), 10.0, 0.0); assert!( survivors.is_some(), "trimming circle between two tangent lines must succeed" ); let pieces = survivors.unwrap(); assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); let EntityType::Arc(arc) = &pieces[0] else { panic!("expected Arc") }; let arc_start = glam::DVec3::new( arc.radius * arc.start_angle.cos(), arc.radius * arc.start_angle.sin(), 0.0, ); let arc_end = glam::DVec3::new( arc.radius * arc.end_angle.cos(), arc.radius * arc.end_angle.sin(), 0.0, ); println!("arc start: {:?}, end: {:?}", arc_start, arc_end); // Now trim line 1 by clicking at (10, 10) let l1_survivors = pick_trim_at(&all, &geos, Handle::new(2), 10.0, 10.0); assert!(l1_survivors.is_some(), "trimming line 1 must succeed"); let l1_pieces = l1_survivors.unwrap(); assert_eq!(l1_pieces.len(), 1); let EntityType::Line(trimmed_l1) = &l1_pieces[0] else { panic!("expected Line") }; println!( "trimmed line 1 start: {:?}, end: {:?}", trimmed_l1.start, trimmed_l1.end ); // Check distance between trimmed line 1 end and arc start let line_end = glam::DVec3::new(trimmed_l1.end.x, trimmed_l1.end.y, trimmed_l1.end.z); let diff = (line_end - arc_start).length(); println!( "Distance between line endpoint and arc endpoint: {:e}", diff ); // Now test an angled / non-axis-aligned case from external point let c_center = glam::DVec3::new(123.45, 67.89, 0.0); let c_radius = 25.0; let p_ext1 = glam::DVec3::new(200.0, 150.0, 0.0); let p_ext2 = glam::DVec3::new(50.0, -20.0, 0.0); let get_tangents = |p: glam::DVec3| { let k_curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle { centre: [c_center.x, c_center.y], radius: c_radius, }); let pts = kernel::geom2d::tangent_from(&k_curve, [p.x, p.y]); ( glam::DVec3::new(pts[0].point[0], pts[0].point[1], 0.0), glam::DVec3::new(pts[1].point[0], pts[1].point[1], 0.0), ) }; let t1 = get_tangents(p_ext1).0; let t2 = get_tangents(p_ext2).1; let mut c_rot = CircleEnt::new(); c_rot.common.handle = Handle::new(10); c_rot.center = Vector3::new(c_center.x, c_center.y, c_center.z); c_rot.radius = c_radius; // Line 1 from p_ext1 extending PAST t1 to make it a boundary line crossing tangent let dir1 = (t1 - p_ext1).normalize(); let mut l_rot1 = LineEnt::new(); l_rot1.common.handle = Handle::new(11); l_rot1.start = Vector3::new(p_ext1.x, p_ext1.y, 0.0); l_rot1.end = Vector3::new((t1 + dir1 * 20.0).x, (t1 + dir1 * 20.0).y, 0.0); let dir2 = (t2 - p_ext2).normalize(); let mut l_rot2 = LineEnt::new(); l_rot2.common.handle = Handle::new(12); l_rot2.start = Vector3::new(p_ext2.x, p_ext2.y, 0.0); l_rot2.end = Vector3::new((t2 + dir2 * 20.0).x, (t2 + dir2 * 20.0).y, 0.0); let all_rot = vec![ EntityType::Circle(c_rot.clone()), EntityType::Line(l_rot1.clone()), EntityType::Line(l_rot2.clone()), ]; let geos_rot = build_geos(&all_rot); // Click circle between them let mid_click = c_center + (t1 + t2 - 2.0 * c_center).normalize() * c_radius; let rot_survivors = pick_trim_at( &all_rot, &geos_rot, Handle::new(10), mid_click.x, mid_click.y, ); assert!( rot_survivors.is_some(), "trimming circle between angled tangent lines must succeed" ); let pieces = rot_survivors.unwrap(); assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); let EntityType::Arc(a) = &pieces[0] else { panic!("expected Arc") }; let a_start = c_center + glam::DVec3::new( a.radius * a.start_angle.cos(), a.radius * a.start_angle.sin(), 0.0, ); let a_end = c_center + glam::DVec3::new( a.radius * a.end_angle.cos(), a.radius * a.end_angle.sin(), 0.0, ); let err_t1 = (a_start - t1).length().min((a_end - t1).length()); let err_t2 = (a_start - t2).length().min((a_end - t2).length()); assert!( err_t1 < 1e-9, "cut point must land within grip tolerance of t1: got {err_t1:e}" ); assert!( err_t2 < 1e-9, "cut point must land within grip tolerance of t2: got {err_t2:e}" ); } #[test] fn trim_ellipse_with_tangent_arcs_touching_at_endpoints() { use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt}; let mut e = EllipseEnt::new(); e.common.handle = Handle::new(10); e.center = Vector3::new(0.0, 0.0, 0.0); e.major_axis = Vector3::new(20.0, 0.0, 0.0); e.minor_axis_ratio = 0.5; // b = 10 e.start_parameter = 0.0; e.end_parameter = std::f64::consts::TAU; // Arc 1 touches right vertex at (20, 0): center (30, 0), r = 10, from PI/2 to PI let mut a1 = ArcEnt::new(); a1.common.handle = Handle::new(11); a1.center = Vector3::new(30.0, 0.0, 0.0); a1.radius = 10.0; a1.start_angle = std::f64::consts::FRAC_PI_2; a1.end_angle = std::f64::consts::PI; // Arc 2 touches left vertex at (-20, 0): center (-30, 0), r = 10, from 0 to PI/2 let mut a2 = ArcEnt::new(); a2.common.handle = Handle::new(12); a2.center = Vector3::new(-30.0, 0.0, 0.0); a2.radius = 10.0; a2.start_angle = 0.0; a2.end_angle = std::f64::consts::FRAC_PI_2; let all = vec![ EntityType::Ellipse(e), EntityType::Arc(a1), EntityType::Arc(a2), ]; let geos = build_geos(&all); // Click top of ellipse at (0, 10) let survivors = pick_trim_at(&all, &geos, Handle::new(10), 0.0, 10.0); assert!( survivors.is_some(), "trimming ellipse between two tangent arc endpoints must succeed" ); let pieces = survivors.unwrap(); assert_eq!(pieces.len(), 1, "expected 1 surviving ellipse piece"); if let EntityType::Ellipse(ell) = &pieces[0] { assert!((ell.major_axis.x - 20.0).abs() < 1e-6); } else { panic!("expected Ellipse survivor"); } } #[test] fn trim_ellipse_selection_tracking_near_breakpoint() { use codec::entities::{Ellipse as EllipseEnt, Line as LineEnt}; use std::f64::consts::TAU; let mut e = EllipseEnt::new(); e.common.handle = Handle::new(10); e.center = Vector3::new(0.0, 0.0, 0.0); e.major_axis = Vector3::new(20.0, 0.0, 0.0); e.minor_axis_ratio = 0.5; // a = 20, b = 10 e.start_parameter = 0.0; e.end_parameter = TAU; // Cutting line 1 crossing at t = PI/4 (45 degrees parametric): // At t = PI/4: x = 20 * cos(PI/4) = 14.142, y = 10 * sin(PI/4) = 7.071 // (Polar angle is atan2(7.071, 14.142) = 26.56 degrees!) let mut l1 = LineEnt::new(); l1.common.handle = Handle::new(11); l1.start = Vector3::new(0.0, 0.0, 0.0); l1.end = Vector3::new(30.0, 15.0, 0.0); // passes through (14.142, 7.071) // Cutting line 2 crossing at t = 3*PI/4: let mut l2 = LineEnt::new(); l2.common.handle = Handle::new(12); l2.start = Vector3::new(0.0, 0.0, 0.0); l2.end = Vector3::new(-30.0, 15.0, 0.0); let all = vec![ EntityType::Ellipse(e), EntityType::Line(l1), EntityType::Line(l2), ]; let geos = build_geos(&all); // Click 1: Click at t = 40 deg parametric (x = 20*cos(40°), y = 10*sin(40°)) // Polar angle is atan(0.5 * tan(40°)) ≈ 22.7°. // This is before PI/4. Surviving piece should run between the cuts. let t_before = 40.0_f64.to_radians(); let px_before = 20.0 * t_before.cos(); let py_before = 10.0 * t_before.sin(); let res_before = pick_trim_at(&all, &geos, Handle::new(10), px_before, py_before); assert!(res_before.is_some(), "trimming at 40 deg must succeed"); let pieces_before = res_before.unwrap(); assert_eq!(pieces_before.len(), 1); // Click 2: Click at t = 50 deg parametric (x = 20*cos(50°), y = 10*sin(50°)) // Polar angle is atan(0.5 * tan(50°)) ≈ 30.77°. // Visually and parametrically, 50° is in the top segment (between PI/4 and 3*PI/4). // Without the fix, 30.77° polar angle would be < 45° parametric, selecting the WRONG segment! let t_after = 50.0_f64.to_radians(); let px_after = 20.0 * t_after.cos(); let py_after = 10.0 * t_after.sin(); let res_after = pick_trim_at(&all, &geos, Handle::new(10), px_after, py_after); assert!(res_after.is_some(), "trimming at 50 deg must succeed"); let pieces_after = res_after.unwrap(); assert_eq!(pieces_after.len(), 1); // Click 3 & 4: Click slightly INSIDE and OUTSIDE the ellipse near 50 deg. // Even when displaced by 3 units (pick-box offset when zoomed out), // orthogonal projection must still correctly identify the top segment! let nx = 10.0 * t_after.cos(); let ny = 20.0 * t_after.sin(); let nlen = nx.hypot(ny); let (unx, uny) = (nx / nlen, ny / nlen); // Displaced outside: let res_out = pick_trim_at( &all, &geos, Handle::new(10), px_after + 3.0 * unx, py_after + 3.0 * uny, ); assert!( res_out.is_some(), "trimming at displaced outside point must succeed" ); // Displaced inside: let res_in = pick_trim_at( &all, &geos, Handle::new(10), px_after - 3.0 * unx, py_after - 3.0 * uny, ); assert!( res_in.is_some(), "trimming at displaced inside point must succeed" ); if let ( EntityType::Ellipse(surv_after), EntityType::Ellipse(surv_out), EntityType::Ellipse(surv_in), ) = (&pieces_after[0], &res_out.unwrap()[0], &res_in.unwrap()[0]) { assert!( (surv_out.start_parameter - surv_after.start_parameter).abs() < 1e-4, "displaced outside point should select the same segment as point on curve" ); assert!( (surv_in.start_parameter - surv_after.start_parameter).abs() < 1e-4, "displaced inside point should select the same segment as point on curve" ); } } #[test] fn trim_arc_selection_tracking_near_start_endpoint() { use codec::entities::{Arc as ArcEnt, Line as LineEnt}; // Arc from 0.5 to 2.0 rad (~28.6° to 114.6°), radius 10.0 let mut a = ArcEnt::new(); a.common.handle = Handle::new(20); a.center = Vector3::new(0.0, 0.0, 0.0); a.radius = 10.0; a.start_angle = 0.5; a.end_angle = 2.0; // Boundary line crossing arc at midpoint ~ 1.25 rad let mid = 1.25_f64; let mut l = LineEnt::new(); l.common.handle = Handle::new(21); l.start = Vector3::new(0.0, 0.0, 0.0); l.end = Vector3::new(20.0 * mid.cos(), 20.0 * mid.sin(), 0.0); let all = vec![EntityType::Arc(a), EntityType::Line(l)]; let geos = build_geos(&all); // Hover slightly CW of start endpoint (e.g. angle = 0.49 rad, outside the arc by ~0.5 degree) let click_ang = 0.49_f64; let px = 10.0 * click_ang.cos(); let py = 10.0 * click_ang.sin(); let res = pick_trim_at(&all, &geos, Handle::new(20), px, py); assert!(res.is_some(), "trim near start endpoint must succeed"); let pieces = res.unwrap(); assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); if let EntityType::Arc(surv) = &pieces[0] { // It must remove the start-half [0.5, 1.25] and keep the end-half [1.25, 2.0]. // If the bug were present, it would snap to 1.0 (end-half removed, start-half kept). assert!( (surv.end_angle - 2.0).abs() < 0.05, "survivor should be the end-half [1.25, 2.0], got start={} end={}", surv.start_angle, surv.end_angle ); } else { panic!("expected Arc survivor"); } } #[test] fn test_tangent_circles_or_arcs_trim() { use codec::entities::{Arc as ArcEnt, Circle as CircleEnt}; let mut c1 = CircleEnt::new(); c1.common.handle = Handle::new(1); c1.center = Vector3::new(0.0, 0.0, 0.0); c1.radius = 10.0; let mut c2 = CircleEnt::new(); c2.common.handle = Handle::new(2); c2.center = Vector3::new(20.0, 0.0, 0.0); c2.radius = 10.0; let all = vec![ EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone()), ]; let geos = build_geos(&all); let curve1 = entity_curve_xy(&EntityType::Circle(c1.clone())).unwrap(); let ts = cut_params(&curve1, Handle::new(1), &geos); assert_eq!( ts, vec![0.0], "circle1 tangent to circle2 must have cut at t=0" ); // Arc1 touching circle2 tangently let mut a1 = ArcEnt::new(); a1.common.handle = Handle::new(3); a1.center = Vector3::new(0.0, 0.0, 0.0); a1.radius = 10.0; a1.start_angle = -1.0; a1.end_angle = 1.0; let all_arc = vec![EntityType::Arc(a1.clone()), EntityType::Circle(c2.clone())]; let geos_arc = build_geos(&all_arc); let curve_arc = entity_curve_xy(&EntityType::Arc(a1.clone())).unwrap(); let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos_arc); assert_eq!( ts_arc, vec![0.5], "arc1 tangent to circle2 at midpoint must have cut at t=0.5" ); let trim_res = pick_trim_at(&all_arc, &geos_arc, Handle::new(3), 10.0, 5.0); assert!( trim_res.is_some(), "trimming arc1 at tangent circle2 must succeed" ); let pieces = trim_res.unwrap(); assert_eq!( pieces.len(), 1, "trimming half of arc should leave 1 survivor piece" ); // 1. External tangency: Test across multiple angles and slight epsilons for angle in [0.0f64, 0.5, 1.0, 1.57, 2.5, std::f64::consts::PI, 4.0, 5.0] { for eps in [-1e-8, 0.0, 1e-8] { let dist = 20.0 + eps; let dx = dist * angle.cos(); let dy = dist * angle.sin(); let mut c_off = CircleEnt::new(); c_off.common.handle = Handle::new(4); c_off.center = Vector3::new(dx, dy, 0.0); c_off.radius = 10.0; let all_off = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_off)]; let geos_off = build_geos(&all_off); let ts_off = cut_params(&curve1, Handle::new(1), &geos_off); assert_eq!( ts_off.len(), 1, "must find exactly 1 tangency cut at angle {angle}, eps {eps}" ); let expected_t = (angle / TAU).rem_euclid(1.0); let actual_t = ts_off[0]; let diff = (actual_t - expected_t).abs(); let cyclic_diff = diff.min(1.0 - diff); assert!( cyclic_diff < 1e-5, "tangency t must match angle: got {actual_t}, expected {expected_t}" ); } } // 2. Internal tangency: c1 (r=10) and c_inner (r=4) at (6, 0) let mut c_inner = CircleEnt::new(); c_inner.common.handle = Handle::new(5); c_inner.center = Vector3::new(6.0, 0.0, 0.0); c_inner.radius = 4.0; let all_int = vec![ EntityType::Circle(c1.clone()), EntityType::Circle(c_inner.clone()), ]; let geos_int = build_geos(&all_int); let ts_int = cut_params(&curve1, Handle::new(1), &geos_int); assert_eq!( ts_int, vec![0.0], "internal tangency on c1 must have cut at t=0" ); // And from c_inner's perspective (r=4 inside r=10): let curve_inner = entity_curve_xy(&EntityType::Circle(c_inner.clone())).unwrap(); let ts_inner = cut_params(&curve_inner, Handle::new(5), &geos_int); assert_eq!( ts_inner, vec![0.0], "internal tangency on c_inner must have cut at t=0" ); // 3. Two arcs touching tangently at (10, 0) // a_left: center (0, 0), radius 10, start=0, end=PI (passes through (10,0) at start t=0) let mut a_left = ArcEnt::new(); a_left.common.handle = Handle::new(6); a_left.center = Vector3::new(0.0, 0.0, 0.0); a_left.radius = 10.0; a_left.start_angle = 0.0; a_left.end_angle = std::f64::consts::PI; // a_right: center (20, 0), radius 10, start=PI/2, end=3*PI/2 (passes through (10,0) at angle PI, midpoint t=0.5) let mut a_right = ArcEnt::new(); a_right.common.handle = Handle::new(7); a_right.center = Vector3::new(20.0, 0.0, 0.0); a_right.radius = 10.0; a_right.start_angle = std::f64::consts::FRAC_PI_2; a_right.end_angle = 3.0 * std::f64::consts::FRAC_PI_2; let all_arcs = vec![ EntityType::Arc(a_left.clone()), EntityType::Arc(a_right.clone()), ]; let geos_arcs = build_geos(&all_arcs); let curve_right = entity_curve_xy(&EntityType::Arc(a_right.clone())).unwrap(); let ts_right = cut_params(&curve_right, Handle::new(7), &geos_arcs); assert_eq!( ts_right, vec![0.5], "a_right must be cut at midpoint t=0.5 by tangent a_left" ); let trim_right = pick_trim_at(&all_arcs, &geos_arcs, Handle::new(7), 15.0, 8.0); assert!( trim_right.is_some(), "trimming a_right at tangent a_left must succeed" ); // 4. Circle trimmed by 2 tangent circles // c1 at (0, 0) r=10. c_right at (20, 0) r=10. c_left at (-20, 0) r=10. let mut c_left = CircleEnt::new(); c_left.common.handle = Handle::new(8); c_left.center = Vector3::new(-20.0, 0.0, 0.0); c_left.radius = 10.0; let all_3c = vec![ EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone()), EntityType::Circle(c_left.clone()), ]; let geos_3c = build_geos(&all_3c); let ts_3c = cut_params(&curve1, Handle::new(1), &geos_3c); assert_eq!( ts_3c.len(), 2, "c1 touched by 2 tangent circles must have 2 cut points" ); assert_eq!(ts_3c, vec![0.0, 0.5]); let trim_3c = pick_trim_at(&all_3c, &geos_3c, Handle::new(1), 0.0, 10.0); assert!( trim_3c.is_some(), "c1 with 2 tangent circles must be trimmable" ); if let Some(surv) = trim_3c { assert_eq!(surv.len(), 1, "trimming c1 should leave 1 arc survivor"); } // 5. Extending an arc to meet a tangent circle // a_short: center (0, 0), radius 10, start=0.5, end=1.5 (span ~ 1.0 rad) // Tangent circle c2 is at (20, 0), tangent point is at (10, 0) which is angle 0. // Clicking near start extends start CW towards 0.0. let mut a_short = ArcEnt::new(); a_short.common.handle = Handle::new(9); a_short.center = Vector3::new(0.0, 0.0, 0.0); a_short.radius = 10.0; a_short.start_angle = 0.5; a_short.end_angle = 1.5; let all_ext = vec![ EntityType::Arc(a_short.clone()), EntityType::Circle(c2.clone()), ]; let geos_ext = build_geos(&all_ext); let ext_res = pick_extend_at( &all_ext, &geos_ext, Handle::new(9), 10.0 * 0.6_f64.cos(), 10.0 * 0.6_f64.sin(), ); assert!( ext_res.is_some(), "extending arc towards tangent circle must succeed" ); if let Some(EntityType::Arc(ext_arc)) = ext_res { let start_norm = norm(ext_arc.start_angle); assert!( start_norm < 1e-5 || (TAU - start_norm) < 1e-5, "extended arc start must reach 0.0 (tangent point)" ); } } #[test] fn test_skinny_ellipse_crossing_arc() { use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt}; let mut e = EllipseEnt::new(); e.common.handle = Handle::new(100); e.center = Vector3::new(0.0, 0.0, 0.0); e.major_axis = Vector3::new(100.0, 0.0, 0.0); e.minor_axis_ratio = 0.05; // b = 5, very skinny! e.start_parameter = 0.0; e.end_parameter = std::f64::consts::TAU; // Test arcs that physically cross the ellipse near its ends (x = 80, 90, 95, 98, 99) for &r in &[80.0, 90.0, 95.0, 98.0, 99.0] { let mut a = ArcEnt::new(); a.common.handle = Handle::new(101); a.center = Vector3::new(0.0, 0.0, 0.0); a.radius = r; a.start_angle = -0.3; a.end_angle = 0.3; let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)]; let geos = build_geos(&all); // Trim the ellipse tip beyond the arc (near x = 99.5, y = 0) let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.9, 0.0); // Trim the arc near y = 0 (x = r, y = 0) let arc_res = pick_trim_at(&all, &geos, Handle::new(101), r, 0.0); assert!( ell_res.is_some(), "ellipse trim should succeed for crossing arc at r={r}" ); assert!( arc_res.is_some(), "arc trim should succeed for crossing arc at r={r}" ); } // Test off-axis arc crossing near the tip (center at (96, 20), radius 20, passing near (96, 0)) { let mut a = ArcEnt::new(); a.common.handle = Handle::new(102); a.center = Vector3::new(96.0, 20.0, 0.0); a.radius = 20.0; // Angle around -PI/2 (bottom point is (96, 0)) a.start_angle = -std::f64::consts::FRAC_PI_2 - 0.4; a.end_angle = -std::f64::consts::FRAC_PI_2 + 0.4; let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)]; let geos = build_geos(&all); let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.0, 0.0); let arc_res = pick_trim_at(&all, &geos, Handle::new(102), 96.0, 0.0); assert!( ell_res.is_some(), "ellipse trim should succeed with off-axis crossing arc" ); assert!( arc_res.is_some(), "arc trim should succeed with off-axis crossing arc" ); } } #[test] fn test_trim_line_tangent_to_arc() { use codec::entities::{Arc as ArcEnt, Line as LineEnt}; let mut l1 = LineEnt::new(); l1.common.handle = Handle::new(1); l1.start = Vector3::new(-10.0, 0.0, 0.0); l1.end = Vector3::new(10.0, 0.0, 0.0); let mut a = ArcEnt::new(); a.common.handle = Handle::new(2); a.center = Vector3::new(0.0, 5.0, 0.0); a.radius = 5.0; a.start_angle = -std::f64::consts::FRAC_PI_2; a.end_angle = std::f64::consts::PI; let all = vec![EntityType::Line(l1.clone()), EntityType::Arc(a.clone())]; let geos = build_geos(&all); let curve_line = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap(); let ts_line = cut_params(&curve_line, Handle::new(1), &geos); assert!(!ts_line.is_empty(), "line must have cut point where tangent arc touches it: ts={:?}", ts_line); let curve_arc = entity_curve_xy(&EntityType::Arc(a.clone())).unwrap(); let ts_arc = cut_params(&curve_arc, Handle::new(2), &geos); println!("ts_arc: {:?}", ts_arc); } #[test] fn test_trim_arc_tangent_to_two_lines() { use codec::entities::{Arc as CadArc, Line as LineEnt}; use crate::command::{TangentObject, WorkingPlane}; use crate::modules::draw::draw::arc::{solve_arc_3_inputs, ArcPick}; let mut l1 = LineEnt::new(); l1.common.handle = Handle::new(1); l1.start = Vector3::new(-10.0, 0.0, 0.0); l1.end = Vector3::new(10.0, 0.0, 0.0); let mut l2 = LineEnt::new(); l2.common.handle = Handle::new(2); l2.start = Vector3::new(0.0, -10.0, 0.0); l2.end = Vector3::new(0.0, 10.0, 0.0); let t1 = TangentObject::Line { p1: DVec3::new(-10.0, 0.0, 0.0), p2: DVec3::new(10.0, 0.0, 0.0), }; let t2 = TangentObject::Line { p1: DVec3::new(0.0, -10.0, 0.0), p2: DVec3::new(0.0, 10.0, 0.0), }; // Middle point: (1 - sqrt(0.5), 1 - sqrt(0.5)) -> radius ~ 1 let mid = DVec3::new(1.0 - (0.5_f64).sqrt(), 1.0 - (0.5_f64).sqrt(), 0.0); let sol = solve_arc_3_inputs( ArcPick::Tangent { target: t1, hit: DVec3::new(2.0, 0.0, 0.0) }, ArcPick::Point(mid), ArcPick::Tangent { target: t2, hit: DVec3::new(0.0, 2.0, 0.0) }, WorkingPlane::default(), ); let (center, radius, sa, ea) = sol.expect("must solve arc tangent to two lines"); let mut arc = CadArc::new(); arc.common.handle = Handle::new(3); arc.center = Vector3::new(center.x, center.y, center.z); arc.radius = radius; arc.start_angle = sa; arc.end_angle = ea; let all = vec![ EntityType::Line(l1.clone()), EntityType::Line(l2.clone()), EntityType::Arc(arc.clone()), ]; let geos = build_geos(&all); let curve_l1 = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap(); let ts_l1 = cut_params(&curve_l1, Handle::new(1), &geos); println!("ts_l1: {:?}", ts_l1); let curve_l2 = entity_curve_xy(&EntityType::Line(l2.clone())).unwrap(); let ts_l2 = cut_params(&curve_l2, Handle::new(2), &geos); println!("ts_l2: {:?}", ts_l2); let curve_arc = entity_curve_xy(&EntityType::Arc(arc.clone())).unwrap(); let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos); println!("ts_arc: {:?}", ts_arc); assert!(!ts_l1.is_empty(), "Line 1 must be cut: ts_l1={:?}", ts_l1); assert!(!ts_l2.is_empty(), "Line 2 must be cut: ts_l2={:?}", ts_l2); // Test trimming line 1 arm between (1, 0) and (10, 0) let trim_l1 = pick_trim_at(&all, &geos, Handle::new(1), 5.0, 0.0); println!("trim_l1: {:?}", trim_l1); assert!(trim_l1.is_some(), "trimming line 1 arm must succeed"); // Test trimming line 2 arm between (0, 1) and (0, 10) let trim_l2 = pick_trim_at(&all, &geos, Handle::new(2), 0.0, 5.0); println!("trim_l2: {:?}", trim_l2); assert!(trim_l2.is_some(), "trimming line 2 arm must succeed"); // Test trimming the arc let trim_arc = pick_trim_at(&all, &geos, Handle::new(3), mid.x, mid.y); println!("trim_arc: {:?}", trim_arc); } #[test] fn test_trim_arc_tangent_angled_lines() { use codec::entities::{Arc as CadArc, Line as LineEnt}; use crate::command::{TangentObject, WorkingPlane}; use crate::modules::draw::draw::arc::{solve_arc_3_inputs, ArcPick}; // Two lines meeting at (0, 0): one along (cos(20 deg), sin(20 deg)), other along (cos(80 deg), sin(80 deg)) let a1 = 20.0_f64.to_radians(); let a2 = 80.0_f64.to_radians(); let dir1 = DVec3::new(a1.cos(), a1.sin(), 0.0); let dir2 = DVec3::new(a2.cos(), a2.sin(), 0.0); let mut l1 = LineEnt::new(); l1.common.handle = Handle::new(1); l1.start = Vector3::new(-10.0 * dir1.x, -10.0 * dir1.y, 0.0); l1.end = Vector3::new(50.0 * dir1.x, 50.0 * dir1.y, 0.0); let mut l2 = LineEnt::new(); l2.common.handle = Handle::new(2); l2.start = Vector3::new(-10.0 * dir2.x, -10.0 * dir2.y, 0.0); l2.end = Vector3::new(50.0 * dir2.x, 50.0 * dir2.y, 0.0); let t1 = TangentObject::Line { p1: DVec3::new(l1.start.x, l1.start.y, 0.0), p2: DVec3::new(l1.end.x, l1.end.y, 0.0), }; let t2 = TangentObject::Line { p1: DVec3::new(l2.start.x, l2.start.y, 0.0), p2: DVec3::new(l2.end.x, l2.end.y, 0.0), }; // Midpoint between points at distance 20 on the bisector let bisector_angle = (a1 + a2) * 0.5; let mid = DVec3::new(15.0 * bisector_angle.cos(), 15.0 * bisector_angle.sin(), 0.0); let sol = solve_arc_3_inputs( ArcPick::Tangent { target: t1, hit: DVec3::new(20.0 * dir1.x, 20.0 * dir1.y, 0.0) }, ArcPick::Point(mid), ArcPick::Tangent { target: t2, hit: DVec3::new(20.0 * dir2.x, 20.0 * dir2.y, 0.0) }, WorkingPlane::default(), ); let (center, radius, sa, ea) = sol.expect("must solve arc tangent to two angled lines"); let mut arc = CadArc::new(); arc.common.handle = Handle::new(3); arc.center = Vector3::new(center.x, center.y, center.z); arc.radius = radius; arc.start_angle = sa; arc.end_angle = ea; let all = vec![ EntityType::Line(l1.clone()), EntityType::Line(l2.clone()), EntityType::Arc(arc.clone()), ]; let geos = build_geos(&all); let curve_l1 = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap(); let ts_l1 = cut_params(&curve_l1, Handle::new(1), &geos); println!("angled ts_l1: {:?}", ts_l1); let curve_l2 = entity_curve_xy(&EntityType::Line(l2.clone())).unwrap(); let ts_l2 = cut_params(&curve_l2, Handle::new(2), &geos); println!("angled ts_l2: {:?}", ts_l2); // We expect l1 to have at least 2 cuts: the intersection with l2 at (0,0) and the tangent point with arc! assert!(ts_l1.len() >= 2, "Line 1 must have intersection and tangent cut: got {:?}", ts_l1); assert!(ts_l2.len() >= 2, "Line 2 must have intersection and tangent cut: got {:?}", ts_l2); // Trimming l1 arm past the tangent point let click_l1 = 40.0 * dir1; let trim_l1 = pick_trim_at(&all, &geos, Handle::new(1), click_l1.x, click_l1.y); println!("angled trim_l1: {:?}", trim_l1); assert!(trim_l1.is_some(), "trimming line 1 must succeed"); } #[test] fn test_kernel_intersect_tangency() { use std::f64::consts::FRAC_PI_2; let line = Curve::Line(KernelLine { start: [-10.0, 0.0], end: [10.0, 0.0] }); // Case 1: Exact tangency at interior of arc let arc_exact = Curve::Arc(KernelArc { centre: [0.0, 5.0], radius: 5.0, start_angle: -FRAC_PI_2 - 0.2, end_angle: -FRAC_PI_2 + 0.2, }); let hits_exact = kernel_intersect(&line, &arc_exact, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_exact: {:?}", hits_exact); // Case 2: Distance slightly greater than R by 1e-12 (within tolerance 1e-7) let arc_eps = Curve::Arc(KernelArc { centre: [0.0, 5.0 + 1e-12], radius: 5.0, start_angle: -FRAC_PI_2 - 0.2, end_angle: -FRAC_PI_2 + 0.2, }); let hits_eps = kernel_intersect(&line, &arc_eps, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_eps: {:?}", hits_eps); // Case 3: Distance slightly greater than R by 1e-9 let arc_eps9 = Curve::Arc(KernelArc { centre: [0.0, 5.0 + 1e-9], radius: 5.0, start_angle: -FRAC_PI_2 - 0.2, end_angle: -FRAC_PI_2 + 0.2, }); let hits_eps9 = kernel_intersect(&line, &arc_eps9, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_eps9: {:?}", hits_eps9); // Case 4: Tangency at arc start endpoint let arc_start = Curve::Arc(KernelArc { centre: [0.0, 5.0], radius: 5.0, start_angle: -FRAC_PI_2, end_angle: 0.0, }); let hits_start = kernel_intersect(&line, &arc_start, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_start (-PI/2): {:?}", hits_start); // Case 5: Tangency at arc start endpoint with normalized angle 3*PI/2 let arc_norm = Curve::Arc(KernelArc { centre: [0.0, 5.0], radius: 5.0, start_angle: 3.0 * FRAC_PI_2, end_angle: 0.0, // wait! In CCW, from 3*PI/2 to 0 is span PI/2, or 2*PI? }); let hits_norm = kernel_intersect(&line, &arc_norm, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_norm (3*PI/2): {:?}", hits_norm); // Case 6: Tangency at arc end endpoint let arc_end = Curve::Arc(KernelArc { centre: [0.0, 5.0], radius: 5.0, start_angle: std::f64::consts::PI, end_angle: -FRAC_PI_2, }); let hits_end = kernel_intersect(&line, &arc_end, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_end (-PI/2): {:?}", hits_end); // Case 7: Large coordinates (e.g. 5000, 5005) let line_large = Curve::Line(KernelLine { start: [4990.0, 5000.0], end: [5010.0, 5000.0] }); let arc_large = Curve::Arc(KernelArc { centre: [5000.0, 5005.0], radius: 5.0, start_angle: -FRAC_PI_2, end_angle: 0.0, }); let hits_large = kernel_intersect(&line_large, &arc_large, KernelTolerance::new(CUT_TOLERANCE)); println!("hits_large: {:?}", hits_large); // Test with cut_params: let geos_large = vec![Geo::Arc { handle: Handle::new(2), cx: 5000.0, cy: 5005.0, r: 5.0, a0: -FRAC_PI_2, a1: 0.0, }]; let cuts_large = cut_params(&line_large, Handle::new(1), &geos_large); let _ = cuts_large; } #[test] fn test_arc_tangent_line_precision_sweep() { use codec::entities::{Arc as CadArc, Line as LineEnt}; use crate::command::{TangentObject, WorkingPlane}; use crate::modules::draw::draw::arc::{solve_arc_3_inputs, ArcPick}; let mut max_dist = 0.0_f64; let mut failed_cuts = 0; for i in 1..36 { let angle_deg = i as f64 * 5.0; // 5 to 175 degrees let a2 = angle_deg.to_radians(); let dir1 = DVec3::new(1.0, 0.0, 0.0); let dir2 = DVec3::new(a2.cos(), a2.sin(), 0.0); let mut l1 = LineEnt::new(); l1.common.handle = Handle::new(1); l1.start = Vector3::new(-50.0, 0.0, 0.0); l1.end = Vector3::new(150.0, 0.0, 0.0); let mut l2 = LineEnt::new(); l2.common.handle = Handle::new(2); l2.start = Vector3::new(-50.0 * dir2.x, -50.0 * dir2.y, 0.0); l2.end = Vector3::new(150.0 * dir2.x, 150.0 * dir2.y, 0.0); let t1 = TangentObject::Line { p1: DVec3::new(l1.start.x, l1.start.y, 0.0), p2: DVec3::new(l1.end.x, l1.end.y, 0.0), }; let t2 = TangentObject::Line { p1: DVec3::new(l2.start.x, l2.start.y, 0.0), p2: DVec3::new(l2.end.x, l2.end.y, 0.0), }; let bisector = (dir1 + dir2).normalize(); let mid = bisector * 30.0; let sol = solve_arc_3_inputs( ArcPick::Tangent { target: t1, hit: DVec3::new(50.0, 0.0, 0.0) }, ArcPick::Point(mid), ArcPick::Tangent { target: t2, hit: dir2 * 50.0 }, WorkingPlane::default(), ); if let Some((center, radius, sa, ea)) = sol { let mut arc = CadArc::new(); arc.common.handle = Handle::new(3); arc.center = Vector3::new(center.x, center.y, center.z); arc.radius = radius; arc.start_angle = sa; arc.end_angle = ea; let all = vec![ EntityType::Line(l1.clone()), EntityType::Line(l2.clone()), EntityType::Arc(arc.clone()), ]; let geos = build_geos(&all); let curve_l1 = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap(); let ts_l1 = cut_params(&curve_l1, Handle::new(1), &geos); let curve_l2 = entity_curve_xy(&EntityType::Line(l2.clone())).unwrap(); let ts_l2 = cut_params(&curve_l2, Handle::new(2), &geos); if ts_l1.len() < 2 || ts_l2.len() < 2 { failed_cuts += 1; println!("FAILED at angle {angle_deg} deg: ts_l1={:?}, ts_l2={:?}", ts_l1, ts_l2); } // Check distance of arc endpoints to line let ep1 = [center.x + radius * sa.cos(), center.y + radius * sa.sin()]; let d1 = (ep1[1] - 0.0).abs(); // distance to l1 (y=0) if d1 > max_dist { max_dist = d1; } } } println!("Sweep results: failed_cuts={failed_cuts}, max_dist={max_dist:e}"); assert_eq!(failed_cuts, 0, "No cuts should fail across angle sweep"); } } // ══════════════════════════════════════════════════════════════════════════ // ExtrimCommand — EXTRIM (Express-Tools cookie-cutter trim). #253 // // Pick one boundary, then a side: every object crossing the boundary is trimmed // on the picked side, and objects lying wholly on that side are erased. The side // test is a parity count — a segment from a candidate point to the pick point // that crosses the boundary an even number of times lands on the pick side. // ══════════════════════════════════════════════════════════════════════════ /// Extend `Geo::Line` boundary edges so a line boundary cuts across the whole /// drawing (EXTRIM treats a line boundary as infinite). fn extend_line_geos(geos: &mut [Geo]) { for g in geos.iter_mut() { if let Geo::Line { handle, p1, p2 } = g { let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]); if dx.hypot(dy) > 1e-9 { // Same as `imply_edge_geos`: the boundary reaches as far as its // line does, so say so rather than picking a length. *g = Geo::InfLine { handle: *handle, bx: p1[0], by: p1[1], dx, dy, }; } } } } /// Kept parametric intervals — those whose midpoint is NOT on the pick side. fn extrim_keep( ts: &[f64], point_at: &dyn Fn(f64) -> [f64; 2], on_pick_side: &dyn Fn([f64; 2]) -> bool, ) -> Vec<(f64, f64)> { let mut bounds = vec![0.0f64]; bounds.extend_from_slice(ts); bounds.push(1.0); bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); bounds .windows(2) .filter_map(|w| { if w[1] - w[0] <= 1e-6 { return None; } let mid = point_at((w[0] + w[1]) * 0.5); if on_pick_side(mid) { None } else { Some((w[0], w[1])) } }) .collect() } fn extrim_line(orig: &LineEnt, ts: &[f64], side: &dyn Fn([f64; 2]) -> bool) -> Vec { let p1 = [orig.start.x, orig.start.y]; let p2 = [orig.end.x, orig.end.y]; let z = orig.start.z; let pa = |t: f64| lerp2(p1, p2, t); extrim_keep(ts, &pa, side) .into_iter() .filter_map(|(ta, tb)| { let a = lerp2(p1, p2, ta); let b = lerp2(p1, p2, tb); if (b[0] - a[0]).hypot(b[1] - a[1]) < 1e-6 { return None; } let mut l = orig.clone(); l.common.handle = Handle::NULL; l.start = Vector3::new(a[0], a[1], z); l.end = Vector3::new(b[0], b[1], z); Some(EntityType::Line(l)) }) .collect() } fn extrim_arc(orig: &ArcEnt, ts: &[f64], side: &dyn Fn([f64; 2]) -> bool) -> Vec { let a0 = orig.start_angle; let a1 = orig.end_angle; let span = { let s = norm(a1) - norm(a0); if s <= 0.0 { s + TAU } else { s } }; let angle_at = |t: f64| norm(a0) + span * t; let (cx, cy, r) = (orig.center.x, orig.center.y, orig.radius); let pt = |t: f64| { let a = angle_at(t); [cx + r * a.cos(), cy + r * a.sin()] }; extrim_keep(ts, &pt, side) .into_iter() .filter_map(|(ta, tb)| { if (tb - ta).abs() < 1e-6 { return None; } let mut a = orig.clone(); a.common.handle = Handle::NULL; a.start_angle = angle_at(ta); a.end_angle = angle_at(tb); Some(EntityType::Arc(a)) }) .collect() } fn extrim_circle(orig: &CircleEnt, ts: &[f64], side: &dyn Fn([f64; 2]) -> bool) -> Vec { if ts.len() < 2 { return vec![]; } let (cx, cy, r) = (orig.center.x, orig.center.y, orig.radius); let mut s = ts.to_vec(); s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let n = s.len(); let mut out = Vec::new(); for i in 0..n { let ta = s[i]; let tb = if i + 1 < n { s[i + 1] } else { s[0] + 1.0 }; let mt = ((ta + tb) * 0.5).rem_euclid(1.0) * TAU; let mid = [cx + r * mt.cos(), cy + r * mt.sin()]; if side(mid) { continue; // removed side } let mut arc = ArcEnt::new(); arc.common = orig.common.clone(); arc.common.handle = Handle::NULL; arc.center = orig.center; arc.radius = orig.radius; arc.thickness = orig.thickness; arc.normal = orig.normal; arc.start_angle = ta.rem_euclid(1.0) * TAU; arc.end_angle = tb.rem_euclid(1.0) * TAU; out.push(EntityType::Arc(arc)); } out } /// Sample any entity to a dense XY polyline for the sampled trim path. /// Plan-view sampling of any entity, at the density the editing previews /// have always used. /// /// One tessellation rather than a case per type. The per-type version this /// replaced had drifted from the drawing's own: it sampled an arc at 16 /// segments per radian against the renderer's 20, sampled a spline at a /// fixed 96 points however long it was, and read an arc's centre as world /// coordinates when the entity stores it in its OCS. /// /// Unbounded curves come back empty — a ray has no finite point list, and /// how far to run it is the caller's decision. [`fence_sample_xy`] makes it. fn sample_entity_xy(e: &EntityType) -> Vec<[f64; 2]> { // A polyline is sampled through its exploded segments so that a caller // walking the result sees the same seams the rest of TRIM does. if matches!( e, EntityType::LwPolyline(_) | EntityType::Polyline(_) | EntityType::Polyline2D(_) | EntityType::Polyline3D(_) ) { let mut pts: Vec<[f64; 2]> = Vec::new(); for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { let sp = sample_entity_xy(&seg); // A segment with no plan-view shape samples to nothing — an // edge-on plane has no XY curve, so `entity_curve_xy` declines it. // Skipping it here also keeps the seam test below from comparing // two `None`s and then slicing an empty sample (#1318). if sp.is_empty() { continue; } if pts.last() == sp.first() { pts.extend_from_slice(&sp[1..]); } else { pts.extend(sp); } } return pts; } let Some(curve) = entity_curve_xy(e) else { return vec![]; }; if curve.extent() != KernelExtent::Bounded { return vec![]; } curve.tessellate(SAMPLE_SEGMENTS_PER_RADIAN) } /// Dense XY sampling of any entity for the removal preview (lines are /// subdivided and circles closed so the preview cut follows the boundary). fn preview_sample_xy(e: &EntityType) -> Vec<[f64; 2]> { match e { EntityType::Line(l) => { let p1 = [l.start.x, l.start.y]; let p2 = [l.end.x, l.end.y]; (0..=24).map(|i| lerp2(p1, p2, i as f64 / 24.0)).collect() } EntityType::Circle(c) => { let steps = 64usize; (0..=steps) .map(|i| { let a = TAU * (i as f64 / steps as f64); [ c.center.x + c.radius * a.cos(), c.center.y + c.radius * a.sin(), ] }) .collect() } // Polylines sample only their vertices — subdivide the straight // segments so the preview cut follows the boundary instead of // jumping at the nearest vertex (#340). EntityType::LwPolyline(_) | EntityType::Polyline(_) | EntityType::Polyline2D(_) | EntityType::Polyline3D(_) => { let mut pts: Vec<[f64; 2]> = Vec::new(); for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { let sp: Vec<[f64; 2]> = match &seg { EntityType::Line(l) => { let p1 = [l.start.x, l.start.y]; let p2 = [l.end.x, l.end.y]; (0..=24).map(|i| lerp2(p1, p2, i as f64 / 24.0)).collect() } _ => sample_entity_xy(&seg), }; if sp.is_empty() { continue; } if pts.last() == sp.first() { pts.extend_from_slice(&sp[1..]); } else { pts.extend(sp); } } pts } _ => sample_entity_xy(e), } } /// Append the sub-runs of `pts` for which `take` holds to `out` as one wire's /// point list, separated from earlier content by a NaN pen-up. fn collect_runs(pts: &[[f64; 2]], take: &dyn Fn([f64; 2]) -> bool, out: &mut Vec<[f32; 3]>) { let mut run: Vec<[f64; 2]> = Vec::new(); let flush = |run: &mut Vec<[f64; 2]>, out: &mut Vec<[f32; 3]>| { if run.len() >= 2 { if !out.is_empty() { out.push([f32::NAN, f32::NAN, f32::NAN]); } out.extend(run.iter().map(|p| [p[0] as f32, p[1] as f32, 0.0])); } run.clear(); }; for &p in pts { if take(p) { run.push(p); } else { flush(&mut run, out); } } flush(&mut run, out); } /// Build a preview wire from a NaN-break point list. fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel { WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.into(), points, points_low: Vec::new(), color, selected: false, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], aci: 0, key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), ..Default::default() } } /// Insert the exact boundary crossings between consecutive samples so a /// sampled cut lands ON the boundary instead of at the nearest sample. A /// polyline samples only its vertices, so without this a straight segment /// crossing the boundary was cut at the wrong place — or not at all when /// both endpoints were on the kept side (#340). fn insert_boundary_crossings(pts: &[[f64; 2]], geos: &[Geo]) -> Vec<[f64; 2]> { let Some(last) = pts.last() else { return Vec::new(); }; let mut out: Vec<[f64; 2]> = Vec::with_capacity(pts.len()); for w in pts.windows(2) { let (a, b) = (w[0], w[1]); out.push(a); let mut ts = line_seg_ts(a[0], a[1], b[0], b[1], Handle::NULL, geos); ts.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)); for t in ts { if t > 1e-6 && t < 1.0 - 1e-6 { out.push(lerp2(a, b, t)); } } } out.push(*last); out } /// Sampled trim: `None` leaves the entity unchanged, `Some(vec![])` erases it, /// `Some(runs)` replaces it with the surviving pieces as LwPolylines. /// Classification is per SEGMENT midpoint (samples + exact crossings), so a /// vertex sitting exactly on the boundary can't flip the parity test. fn extrim_sampled( pts: &[[f64; 2]], geos: &[Geo], side: &dyn Fn([f64; 2]) -> bool, ) -> Option> { if pts.len() < 2 { return None; } let dense = insert_boundary_crossings(pts, geos); let seg_kept: Vec = dense .windows(2) .map(|w| !side([(w[0][0] + w[1][0]) * 0.5, (w[0][1] + w[1][1]) * 0.5])) .collect(); if seg_kept.iter().all(|&k| k) { return None; // wholly on the kept side — untouched } if seg_kept.iter().all(|&k| !k) { return Some(vec![]); // wholly on the pick side — erased } let mut runs: Vec> = Vec::new(); let mut cur: Vec<[f64; 2]> = Vec::new(); for (i, &kept) in seg_kept.iter().enumerate() { if kept { if cur.is_empty() { cur.push(dense[i]); } cur.push(dense[i + 1]); } else if cur.len() >= 2 { runs.push(std::mem::take(&mut cur)); } else { cur.clear(); } } if cur.len() >= 2 { runs.push(cur); } Some( runs.into_iter() .map(|run| { let mut pl = LwPolyline::new(); pl.common.handle = Handle::NULL; pl.is_closed = false; pl.vertices = run .into_iter() .map(|p| LwVertex::from_coords(p[0], p[1])) .collect(); EntityType::LwPolyline(pl) }) .collect(), ) } pub struct ExtrimCommand { all: Vec<(Handle, EntityType)>, boundary: Option, geos: Vec, } impl ExtrimCommand { pub fn new(all: Vec<(Handle, EntityType)>) -> Self { let all = all .into_iter() .map(|(handle, entity)| (handle, entity_with_lwpolyline_world_xy(&entity))) .collect(); Self { all, boundary: None, geos: Vec::new(), } } } impl CadCommand for ExtrimCommand { fn name(&self) -> &'static str { "EXTRIM" } fn prompt(&self) -> String { if self.boundary.is_none() { crate::t!("EXTRIM Select cutting boundary:").into_owned() } else { crate::t!("EXTRIM Click the side to trim away:").into_owned() } } fn needs_entity_pick(&self) -> bool { self.boundary.is_none() } fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } let Some((_, e)) = self.all.iter().find(|(h, _)| *h == handle) else { return CmdResult::NeedPoint; }; let mut geos = build_geos(std::slice::from_ref(e)); // Only a picked LINE boundary is treated as infinite. A polyline // explodes into per-segment Line geos — extending those turns a // closed boundary into a grid of infinite lines and the parity // side-test breaks (#340). if matches!(e, EntityType::Line(_)) { extend_line_geos(&mut geos); } if geos.is_empty() { return CmdResult::NeedPoint; // not a usable boundary; keep asking } self.boundary = Some(handle); self.geos = geos; CmdResult::NeedPoint } fn on_point(&mut self, pt: DVec3) -> CmdResult { let Some(bh) = self.boundary else { return CmdResult::NeedPoint; }; let q = [pt.x, pt.y]; let geos = self.geos.clone(); let side = |m: [f64; 2]| line_seg_ts(m[0], m[1], q[0], q[1], Handle::NULL, &geos).len() % 2 == 0; let mut repl: Vec<(Handle, Vec)> = Vec::new(); for (h, e) in &self.all { if *h == bh { continue; } match e { EntityType::Line(l) => { let ts = line_seg_ts(l.start.x, l.start.y, l.end.x, l.end.y, *h, &geos); if ts.is_empty() { let mid = [(l.start.x + l.end.x) * 0.5, (l.start.y + l.end.y) * 0.5]; if side(mid) { repl.push((*h, vec![])); } } else { repl.push((*h, extrim_line(l, &ts, &side))); } } EntityType::Arc(a) => { let ts = arc_seg_ts( a.center.x, a.center.y, a.radius, a.start_angle, a.end_angle, *h, &geos, ); if ts.is_empty() { let am = norm(a.start_angle); let mid = [ a.center.x + a.radius * am.cos(), a.center.y + a.radius * am.sin(), ]; if side(mid) { repl.push((*h, vec![])); } } else { repl.push((*h, extrim_arc(a, &ts, &side))); } } EntityType::Circle(c) => { let ts = arc_seg_ts(c.center.x, c.center.y, c.radius, 0.0, TAU, *h, &geos); if ts.len() < 2 { if side([c.center.x, c.center.y]) { repl.push((*h, vec![])); } } else { repl.push((*h, extrim_circle(c, &ts, &side))); } } EntityType::LwPolyline(_) | EntityType::Polyline(_) | EntityType::Polyline2D(_) | EntityType::Polyline3D(_) | EntityType::Ellipse(_) | EntityType::Spline(_) => { let pts = sample_entity_xy(e); if let Some(res) = extrim_sampled(&pts, &geos, &side) { repl.push((*h, res)); } } _ => {} } } if repl.is_empty() { return CmdResult::Cancel; } CmdResult::ReplaceMany(repl, Vec::new()) } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let Some(bh) = self.boundary else { return Vec::new(); }; if self.geos.is_empty() { return Vec::new(); } let q = [pt.x, pt.y]; let geos = &self.geos; let side = |m: [f64; 2]| line_seg_ts(m[0], m[1], q[0], q[1], Handle::NULL, geos).len() % 2 == 0; // Removed (pick side) → red, surviving → blue; the boundary → yellow. let mut removed: Vec<[f32; 3]> = Vec::new(); let mut kept: Vec<[f32; 3]> = Vec::new(); for (h, e) in &self.all { if *h == bh { continue; } let pts = preview_sample_xy(e); if pts.len() < 2 { continue; } collect_runs(&pts, &side, &mut removed); collect_runs(&pts, &|p| !side(p), &mut kept); } let mut boundary_pts: Vec<[f32; 3]> = Vec::new(); if let Some((_, be)) = self.all.iter().find(|(h, _)| *h == bh) { boundary_pts = preview_sample_xy(be) .into_iter() .map(|p| [p[0] as f32, p[1] as f32, 0.0]) .collect(); } const YELLOW: [f32; 4] = [1.0, 0.90, 0.15, 1.0]; const REMOVE_RED: [f32; 4] = [0.95, 0.30, 0.30, 1.0]; let mut out = Vec::new(); if boundary_pts.len() >= 2 { out.push(preview_wire(boundary_pts, YELLOW, "extrim_boundary")); } if kept.len() >= 2 { out.push(preview_wire(kept, WireModel::SELECTED, "extrim_keep")); } if removed.len() >= 2 { out.push(preview_wire(removed, REMOVE_RED, "extrim_remove")); } out } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } } inventory::submit!(crate::command::CommandRegistration { names: &["EXTRIM"] }); // ExtrimCommand