diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 94afb8e47..7bf7d68a6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4038,12 +4038,36 @@ impl OpenCADStudio { .and_then(|e| break_entity(e, p1, p2)); match replacement { Some(frags) => { + let unchanged = frags.len() == 1 && self.tabs[i].scene.document.get_entity(handle) + .is_some_and(|original| { + let mut fragment = frags[0].clone(); + fragment.common_mut().handle = handle; + &fragment == original + }); + if unchanged { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_line.push_output("BREAK: no geometry changed."); + return Task::none(); + } let label = self.history_label_from_active_cmd(i, "BREAK"); self.push_undo_snapshot(i, label); - self.tabs[i].scene.erase_entities(&[handle]); let count = frags.len(); - for e in frags { - self.tabs[i].scene.add_entity(e); + let owner = self.tabs[i].scene.document.get_entity(handle) + .map(|entity| entity.common().owner_handle); + let mut fragments = frags.into_iter(); + if let Some(mut first) = fragments.next() { + first.common_mut().handle = handle; + self.tabs[i].scene.update_entity(first); + for mut fragment in fragments { + fragment.common_mut().handle = Handle::NULL; + if let Some(owner) = owner { fragment.common_mut().owner_handle = owner; } + self.tabs[i].scene.add_entity(fragment); + } + } else { + self.tabs[i].scene.erase_entities(&[handle]); } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/modules/draw/modify/break_cmd.rs b/src/modules/draw/modify/break_cmd.rs index 5f00bdccd..68fadd058 100644 --- a/src/modules/draw/modify/break_cmd.rs +++ b/src/modules/draw/modify/break_cmd.rs @@ -41,10 +41,7 @@ pub fn break_entity(entity: &EntityType, p1: DVec3, p2: DVec3) -> Option Some(break_line(line, p1, p2)), EntityType::Arc(arc) => Some(break_arc(arc, p1, p2)), EntityType::Circle(c) => Some(break_circle(c, p1, p2)), - EntityType::LwPolyline(p) => { - let p = crate::entities::curve::lwpolyline_world_xy(p)?; - Some(break_lwpolyline(&p, p1, p2)) - } + EntityType::LwPolyline(p) => Some(break_lwpolyline(p, p1, p2)), EntityType::Ellipse(e) => Some(break_ellipse(e, p1, p2)), EntityType::Spline(s) => Some(break_spline(s, p1, p2)), _ => None, @@ -91,215 +88,99 @@ fn break_line(line: &LineEnt, p1: DVec3, p2: DVec3) -> Vec { result } +fn picked_spans(curve: &cadkernel::space::PlanarCurve, p1: DVec3, p2: DVec3) -> Option> { + let first = curve.plane.project(p1.to_array())?; + let second = curve.plane.project(p2.to_array())?; + let a = cadkernel::geom2d::closest_point(&curve.curve, first).t; + let b = cadkernel::geom2d::closest_point(&curve.curve, second).t; + cadkernel::geom2d::break_spans(&curve.curve, a, b, cadkernel::geom2d::Tolerance::new(1e-9)) +} + fn break_arc(arc: &ArcEnt, p1: DVec3, p2: DVec3) -> Vec { - let cx = arc.center.x; - let cy = arc.center.y; - let r = arc.radius; - - let plane = crate::entities::curve::arc_curve(arc).plane; - let Some(q1) = plane.project(p1.to_array()) else { + let curve = crate::entities::curve::arc_curve(arc); + let Some(spans) = picked_spans(&curve, p1, p2) else { return vec![EntityType::Arc(arc.clone())]; }; - let Some(q2) = plane.project(p2.to_array()) else { - return vec![EntityType::Arc(arc.clone())]; - }; - let a1 = angle_on_arc(cx, cy, DVec3::new(q1[0], q1[1], 0.0)); - let a2 = angle_on_arc(cx, cy, DVec3::new(q2[0], q2[1], 0.0)); - - let start = arc.start_angle; - let end = arc.end_angle; - - // Normalize: clamp a1 to arc range, then remove CCW from a1 to a2 - let a1_on = clamp_to_arc(a1, start, end); - let a2_on = clamp_to_arc(a2, start, end); - - // Keep the span outside the two break points. - if (a1_on - a2_on).abs() < 0.01 { - // Single-point break: return original unchanged (no gap) - return vec![EntityType::Arc(arc.clone())]; - } - - let _ = r; // radius unchanged - let mut result = arc.clone(); - result.common.handle = Handle::NULL; - result.start_angle = a2_on; - result.end_angle = a1_on; - vec![EntityType::Arc(result)] + let cadkernel::geom2d::Curve::Arc(geometry) = &curve.curve else { unreachable!() }; + spans.into_iter().map(|[from, to]| { + let mut result = arc.clone(); + result.common.handle = Handle::NULL; + result.start_angle = (geometry.start_angle + from * geometry.sweep()).rem_euclid(std::f64::consts::TAU); + result.end_angle = (geometry.start_angle + to * geometry.sweep()).rem_euclid(std::f64::consts::TAU); + EntityType::Arc(result) + }).collect() } fn break_circle(circle: &acadrust::entities::Circle, p1: DVec3, p2: DVec3) -> Vec { - let cx = circle.center.x; - let cy = circle.center.y; - - let plane = crate::entities::curve::circle_curve(circle).plane; - let Some(q1) = plane.project(p1.to_array()) else { + let curve = crate::entities::curve::circle_curve(circle); + let Some(spans) = picked_spans(&curve, p1, p2) else { return vec![EntityType::Circle(circle.clone())]; }; - let Some(q2) = plane.project(p2.to_array()) else { - return vec![EntityType::Circle(circle.clone())]; - }; - let a1 = angle_on_arc(cx, cy, DVec3::new(q1[0], q1[1], 0.0)); - let a2 = angle_on_arc(cx, cy, DVec3::new(q2[0], q2[1], 0.0)); - - if (a1 - a2).abs() < 0.01 { - return vec![EntityType::Circle(circle.clone())]; - } - - // Convert circle to arc, removing CCW from a1 to a2 - let mut arc = ArcEnt::new(); - arc.common = circle.common.clone(); - arc.common.handle = Handle::NULL; - arc.center = circle.center.clone(); - arc.radius = circle.radius; - arc.thickness = circle.thickness; - arc.normal = circle.normal.clone(); - arc.start_angle = a2; - arc.end_angle = a1; - vec![EntityType::Arc(arc)] + spans.into_iter().map(|[from, to]| { + let mut arc = ArcEnt::new(); + arc.common = circle.common.clone(); + arc.common.handle = Handle::NULL; + arc.center = circle.center; + arc.radius = circle.radius; + arc.thickness = circle.thickness; + arc.normal = circle.normal; + arc.start_angle = from * std::f64::consts::TAU; + arc.end_angle = (to * std::f64::consts::TAU).rem_euclid(std::f64::consts::TAU); + EntityType::Arc(arc) + }).collect() } - fn break_lwpolyline(p: &LwPolyline, p1: DVec3, p2: DVec3) -> Vec { - // For LwPolyline, find the nearest vertex indices for p1 and p2, - // then split into two polylines at those vertices. - let n = p.vertices.len(); - if n < 2 { - return vec![EntityType::LwPolyline(p.clone())]; - } - - let t1 = nearest_pline_param(p, p1); - let t2 = nearest_pline_param(p, p2); - let (ta, tb) = if t1 <= t2 { (t1, t2) } else { (t2, t1) }; - - // Build two polylines: [0..ta] and [tb..end] - let idx_a = ta.min(n - 1); - let idx_b = tb.min(n - 1); - - let mut result = Vec::new(); - - // First piece - if idx_a > 0 { - let mut first = p.clone(); - first.common.handle = Handle::NULL; - first.vertices = p.vertices[..=idx_a].to_vec(); - first.is_closed = false; - result.push(EntityType::LwPolyline(first)); - } - - // Second piece - if idx_b < n - 1 { - let mut second = p.clone(); - second.common.handle = Handle::NULL; - second.vertices = p.vertices[idx_b..].to_vec(); - second.is_closed = false; - result.push(EntityType::LwPolyline(second)); - } - - if result.is_empty() { - vec![EntityType::LwPolyline(p.clone())] - } else { - result + let unchanged = || vec![EntityType::LwPolyline(p.clone())]; + let Some(curve) = crate::entities::curve::lwpolyline_curve(p) else { return unchanged(); }; + let Some(spans) = picked_spans(&curve, p1, p2) else { return unchanged(); }; + let cadkernel::geom2d::Curve::Polyline(geometry) = &curve.curve else { unreachable!() }; + let mut result = Vec::with_capacity(spans.len()); + for [from, to] in spans { + let Some(range) = geometry.ranged(from, to) else { return unchanged(); }; + let mut fragment = p.clone(); + fragment.common.handle = Handle::NULL; + fragment.is_closed = false; + fragment.vertices.clear(); + for (index, vertex) in range.polyline.vertices.iter().enumerate() { + // The final vertex has no outgoing segment. Keep its source metadata + // when it is an original endpoint, otherwise the preceding segment's. + let segment = &range.segments[index.min(range.segments.len() - 1)]; + let last = index == range.segments.len(); + let source = if last && segment.to == 1.0 { + (segment.source_index + 1) % p.vertices.len() + } else { segment.source_index }; + let mut output = p.vertices[source].clone(); + output.location.x = vertex.position[0]; + output.location.y = vertex.position[1]; + if !last || segment.to != 1.0 { + let original = &p.vertices[segment.source_index]; + let widths = segment.interpolate(original.start_width, original.end_width); + output.start_width = widths[0]; + output.end_width = widths[1]; + output.bulge = if last { range.polyline.vertices[index - 1].bulge } else { vertex.bulge }; + } + fragment.vertices.push(output); + } + result.push(EntityType::LwPolyline(fragment)); } + result } - fn break_ellipse(ell: &EllipseEnt, p1: DVec3, p2: DVec3) -> Vec { - // Compute the eccentric-anomaly parameter of a world point relative to ellipse. - // World = DXF (XY drawing plane). - let cx = ell.center.x; - let cy = ell.center.y; - let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt(); - if a < 1e-9 { + let Some(curve) = crate::entities::curve::ellipse_curve(ell) else { return vec![EntityType::Ellipse(ell.clone())]; - } - let _b = a * ell.minor_axis_ratio; - let nx = ell.major_axis.x / a; - let ny = ell.major_axis.y / a; - - // Project a point onto the ellipse parameter (eccentric anomaly) - let param_of = |pt: DVec3| -> f64 { - let rx = pt.x - cx; - let ry = pt.y - cy; - let xl = rx * nx + ry * ny; - let yl = -rx * ny + ry * nx; - yl.atan2(xl) // atan2(yl/b*b, xl/a*a) simplifies to atan2(yl,xl) for ordering }; - - let t0 = ell.start_parameter; - let t1 = ell.end_parameter; - - let pa1 = param_of(p1); - let pa2 = param_of(p2); - - // Clamp both params to arc range (same logic as clamp_to_arc for arcs) - let span_deg = { - let s = t1 - t0; - if s <= 0.0 { - s + std::f64::consts::TAU - } else { - s - } - }; - let clamp = |a: f64| -> f64 { - let rel = - ((a - t0) % std::f64::consts::TAU + std::f64::consts::TAU) % std::f64::consts::TAU; - if rel <= span_deg { - a - } else if rel < span_deg + (std::f64::consts::TAU - span_deg) / 2.0 { - t1 - } else { - t0 - } - }; - let a1_on = clamp(pa1); - let a2_on = clamp(pa2); - - if (a1_on - a2_on).abs() < 1e-4 { + let Some(spans) = picked_spans(&curve, p1, p2) else { return vec![EntityType::Ellipse(ell.clone())]; - } - - // Remove CCW from a1_on to a2_on → result goes from a2_on to a1_on - let mut result = ell.clone(); - result.common.handle = Handle::NULL; - result.start_parameter = a2_on; - result.end_parameter = a1_on; - vec![EntityType::Ellipse(result)] + }; + let cadkernel::geom2d::Curve::Ellipse(geometry) = &curve.curve else { unreachable!() }; + spans.into_iter().map(|[from, to]| { + let mut result = ell.clone(); + result.common.handle = Handle::NULL; + result.start_parameter = geometry.start_parameter + from * geometry.sweep(); + result.end_parameter = geometry.start_parameter + to * geometry.sweep(); + EntityType::Ellipse(result) + }).collect() } - -// ── Small utilities ──────────────────────────────────────────────────────── - -/// Returns the angle (radians, 0-2π) of `pt` viewed from (cx, cy) in the world XY plane. -fn angle_on_arc(cx: f64, cy: f64, pt: DVec3) -> f64 { - let dx = pt.x - cx; - let dy = pt.y - cy; - dy.atan2(dx).rem_euclid(std::f64::consts::TAU) -} - -/// Clamp angle `a` to within the arc's angular range (CCW from `start` to `end`). -fn clamp_to_arc(a: f64, start: f64, end: f64) -> f64 { - let span = (end - start).rem_euclid(std::f64::consts::TAU); - let rel = (a - start).rem_euclid(std::f64::consts::TAU); - if rel <= span { - a - } else if rel < span + (std::f64::consts::TAU - span) / 2.0 { - end - } else { - start - } -} - -/// Find the index of the polyline vertex closest to `pt`. -fn nearest_pline_param(p: &LwPolyline, pt: DVec3) -> usize { - p.vertices - .iter() - .enumerate() - .min_by_key(|(_, v)| { - let dx = v.location.x - pt.x; - let dy = v.location.y - pt.y; - ((dx * dx + dy * dy) * 1e6) as i64 - }) - .map(|(i, _)| i) - .unwrap_or(0) -} - fn world_to_dxf(v: DVec3) -> DVec3 { // World = DXF (identity). DVec3::new(v.x, v.y, v.z) @@ -506,6 +387,8 @@ inventory::submit!(crate::command::CommandRegistration { names: &["BREAK"] }); #[cfg(test)] mod tests { use super::*; + use acadrust::entities::LwVertex; + use acadrust::types::Vector2; #[test] fn first_option_replaces_the_selection_point_and_at_reuses_it() { @@ -526,4 +409,33 @@ mod tests { if result == handle && p1 == replacement && p2 == replacement )); } + + #[test] + fn curved_polyline_break_preserves_partial_bulges_widths_and_plane() { + let mut polyline = LwPolyline::new(); + polyline.normal = Vector3::new(0.0, 1.0, 0.0); + polyline.elevation = 5.0; + let mut first_vertex = LwVertex::new(Vector2::new(0.0, 0.0)); + first_vertex.bulge = 1.0; + first_vertex.start_width = 2.0; + first_vertex.end_width = 4.0; + polyline.vertices = vec![first_vertex, LwVertex::from_coords(10.0, 0.0)]; + let curve = crate::entities::curve::lwpolyline_curve(&polyline).unwrap(); + let first = DVec3::from_array(curve.point_at(0.25)); + let second = DVec3::from_array(curve.point_at(0.75)); + + let fragments = break_lwpolyline(&polyline, first, second); + assert_eq!(fragments.len(), 2); + let EntityType::LwPolyline(before) = &fragments[0] else { panic!("expected polyline") }; + let EntityType::LwPolyline(after) = &fragments[1] else { panic!("expected polyline") }; + assert_eq!(before.normal, polyline.normal); + assert_eq!(before.elevation, polyline.elevation); + assert!((before.vertices[0].start_width - 2.0).abs() < 1e-12); + assert!((before.vertices[0].end_width - 2.5).abs() < 1e-12); + assert!((after.vertices[0].start_width - 3.5).abs() < 1e-12); + assert!((after.vertices[0].end_width - 4.0).abs() < 1e-12); + let expected_bulge = (std::f64::consts::PI / 16.0).tan(); + assert!((before.vertices[0].bulge - expected_bulge).abs() < 1e-12); + assert!((after.vertices[0].bulge - expected_bulge).abs() < 1e-12); + } }