diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs index 66eab0be8..9f8e856dd 100644 --- a/crates/engine/src/cmd/modify/trim.rs +++ b/crates/engine/src/cmd/modify/trim.rs @@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 { } } +/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep` +/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the +/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle. +fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> { + let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true }; + let a_end = ga.center.angle_to(end); + let ccw = sweep > 0.0; + // How far past `end` each crossing is, turning the way the segment turns; crossings on the + // segment itself (the last |sweep| of a turn) are not ahead of it. + let (x, more) = cut + .iter() + .flat_map(|c| intersect_ext(&full, c, false)) + .map(|x| { + let a = ga.center.angle_to(x); + (x, norm_angle(if ccw { a - a_end } else { a_end - a })) + }) + .filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + let total = sweep.abs() + more; + Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total }))) +} + /// Extend the end of `h` nearest `pick` to the nearest boundary edge. pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { if crate::cmd::curves::is_locked(s, h) { @@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); - let dir = (end - prev).normalized(); - let ray = Segment::Line(Line::new(end, end + dir * 1e8)); - let np = cut - .iter() - .flat_map(|c| intersect_ext(&ray, c, false)) - .filter(|x| (*x - end).dot(dir) > 1e-9) - .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) - .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; - if let Some(v) = vs.get_mut(i_end) { - v.p = np; + // The end segment's bulge, signed for travel from `prev` out to `end`. + let i_seg = if from_end { i_prev } else { i_end }; + let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge }); + let none = || EngineError::Other("Object does not intersect an edge.".into()); + if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) { + // An arc end segment runs on round its circle, as an arc does. + let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } + if let Some(v) = vs.get_mut(i_seg) { + v.bulge = if from_end { b } else { -b }; + } + } else { + let dir = (end - prev).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let np = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) + .ok_or_else(none)?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } } - lwpoly(vs, false) + // Width, elevation and linetype generation stay as they were. + EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() }) } k => crate::cmd::modify2::extend_other(k, &cut, pick)?, }; diff --git a/crates/engine/tests/it/extend_polyline_arc_end.rs b/crates/engine/tests/it/extend_polyline_arc_end.rs new file mode 100644 index 000000000..496187767 --- /dev/null +++ b/crates/engine/tests/it/extend_polyline_arc_end.rs @@ -0,0 +1,137 @@ +//! EXTEND on an open polyline whose end segment is an arc: the arc runs on round its own circle +//! to the boundary, as EXTEND does with an ARC, instead of the end vertex sliding along the chord +//! with the old bulge (which changed the arc's radius and centre, or missed the boundary). The +//! polyline keeps its width, elevation and linetype generation. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, Entity, EntityKind, Handle, LwPolyline}; +use cadcraft_engine::geom::{PolyVertex, Vec2}; +use serde_json::json; + +const TOL: f64 = 1e-9; + +fn add(s: &mut Session, kind: EntityKind) -> String { + let space = s.space(); + let d = s.doc_mut().unwrap(); + let handle = d.new_handle(); + d.space_mut(&space).unwrap().push(Entity { handle, common: Common::default(), kind }); + handle.hex() +} + +fn v(x: f64, y: f64, bulge: f64) -> PolyVertex { + PolyVertex { p: Vec2::new(x, y), bulge, ..Default::default() } +} + +fn pline(vertices: Vec) -> LwPolyline { + LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false } +} + +fn edge(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + r["handles"][0].as_str().unwrap().to_string() +} + +fn polyline(s: &Session, h: &str) -> LwPolyline { + match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind { + EntityKind::LwPolyline(p) => p.clone(), + k => panic!("not a polyline: {k:?}"), + } +} + +fn near(a: Vec2, x: f64, y: f64) -> bool { + (a.x - x).abs() < 1e-6 && (a.y - y).abs() < 1e-6 +} + +/// tan(22.5°): the bulge of a quarter circle. +fn quarter() -> f64 { + (std::f64::consts::PI / 8.0).tan() +} + +/// The bulge of an arc turning through `deg` degrees (positive counterclockwise). +fn bulge(deg: f64) -> f64 { + (deg.to_radians() / 4.0).tan() +} + +#[test] +fn a_counterclockwise_arc_at_the_end_runs_on_round_its_circle() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]); + // A line in from (20,0), then a quarter circle about the origin from (10,0) to (0,10). + let mut pl = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]); + pl.const_width = 0.5; + pl.elevation = 3.0; + pl.plinegen = true; + let h = add(&mut s, EntityKind::LwPolyline(pl)); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert_eq!(p.vertices.len(), 3); + // Round the circle (radius 10) through (-10,0) to y = -5: a 210° turn in all. + let end = p.vertices[2].p; + assert!(near(end, -(75.0f64.sqrt()), -5.0), "end {end:?}"); + assert!((p.vertices[1].bulge - bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge); + assert!(near(p.vertices[0].p, 20.0, 0.0) && near(p.vertices[1].p, 10.0, 0.0)); + assert_eq!((p.const_width, p.elevation, p.plinegen), (0.5, 3.0, true)); +} + +#[test] +fn an_arc_at_the_start_runs_on_backwards_round_its_circle() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]); + // The same path drawn the other way: clockwise from (0,10) to (10,0), then out to (20,0). + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(0.0, 10.0, -quarter()), v(10.0, 0.0, 0.0), v(20.0, 0.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[0].p, -(75.0f64.sqrt()), -5.0), "start {:?}", p.vertices[0].p); + assert!((p.vertices[0].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[0].bulge); + assert!(near(p.vertices[1].p, 10.0, 0.0) && near(p.vertices[2].p, 20.0, 0.0)); +} + +#[test] +fn a_clockwise_arc_at_the_end_runs_on_clockwise() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, 5.0], [50.0, 5.0]); + // Clockwise about the origin from (10,0) to (0,-10); on round through (-10,0) to y = 5. + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, -quarter()), v(0.0, -10.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, -9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[2].p, -(75.0f64.sqrt()), 5.0), "end {:?}", p.vertices[2].p); + assert!((p.vertices[1].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge); +} + +#[test] +fn the_nearest_crossing_ahead_on_the_circle_wins() { + let mut s = Session::new(); + // A boundary crossing the circle twice ahead of the end: x = -6 at about 127° and 233°. + let e = edge(&mut s, [-6.0, -50.0], [-6.0, 50.0]); + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[2].p, -6.0, 8.0), "end {:?}", p.vertices[2].p); + let turn = 8.0f64.atan2(-6.0).to_degrees(); + assert!((p.vertices[1].bulge - bulge(turn)).abs() < TOL, "bulge {}", p.vertices[1].bulge); +} + +#[test] +fn an_arc_end_whose_circle_misses_every_edge_is_left_alone() { + let mut s = Session::new(); + // The chord (10,0)→(0,10) runs on to this edge, but the circle never reaches it. + let e = edge(&mut s, [-50.0, 30.0], [50.0, 30.0]); + let before = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]); + let h = add(&mut s, EntityKind::LwPolyline(before.clone())); + assert!(s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).is_err()); + assert_eq!(polyline(&s, &h), before); +} + +#[test] +fn a_straight_end_keeps_the_polyline_width_and_elevation() { + let mut s = Session::new(); + let e = edge(&mut s, [50.0, -50.0], [50.0, 50.0]); + let mut pl = pline(vec![v(0.0, 0.0, 0.0), v(10.0, 0.0, 0.0)]); + pl.const_width = 0.5; + pl.elevation = 3.0; + let h = add(&mut s, EntityKind::LwPolyline(pl)); + s.execute("extend", &json!({ "handle": h, "pick": [9.0, 0.0], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[1].p, 50.0, 0.0), "end {:?}", p.vertices[1].p); + assert_eq!((p.const_width, p.elevation), (0.5, 3.0)); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 38fb4d005..b6ec02d60 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -46,6 +46,7 @@ mod draworder; mod dyn_input; mod dynamic_block_data; mod dynamic_grips; +mod extend_polyline_arc_end; mod fillet_chamfer; mod fix_holds_grips; mod followups_626;