diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index d0869edc7..52606f06e 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -153,6 +153,11 @@ fn explode_polyline2d(p: &Polyline2D) -> Vec { let closed = p.is_closed(); let n_segs = if closed { n } else { n - 1 }; let elevation = p.elevation; + let normal = DVec3::new(p.normal.x, p.normal.y, p.normal.z) + .try_normalize() + .unwrap_or(DVec3::Z); + let normal = Vector3::new(normal.x, normal.y, normal.z); + let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation); let mut result = Vec::new(); for i in 0..n_segs { @@ -164,13 +169,25 @@ fn explode_polyline2d(p: &Polyline2D) -> Vec { if v0.bulge.abs() < 1e-10 { let mut common = p.common.clone(); common.handle = Handle::NULL; + let start = plane.point_at(p0); + let end = plane.point_at(p1); result.push(EntityType::Line(LineEnt { common, - start: Vector3::new(p0[0], p0[1], elevation), - end: Vector3::new(p1[0], p1[1], elevation), + start: Vector3::new(start[0], start[1], start[2]), + end: Vector3::new(end[0], end[1], end[2]), + thickness: p.thickness, + normal: normal.clone(), ..LineEnt::new() })); - } else if let Some(arc) = bulge_to_arc(p0, p1, v0.bulge, elevation, &p.common) { + } else if let Some(arc) = bulge_to_arc( + p0, + p1, + v0.bulge, + elevation, + &p.common, + p.thickness, + normal.clone(), + ) { result.push(arc); } } @@ -237,12 +254,22 @@ fn explode_lwpolyline(p: &LwPolyline) -> Vec { common, start: Vector3::new(p0[0], p0[1], elevation), end: Vector3::new(p1[0], p1[1], elevation), + thickness: p.thickness, + normal: p.normal.clone(), ..LineEnt::new() }; result.push(EntityType::Line(line)); } else { // Arc segment from bulge - if let Some(arc) = bulge_to_arc(p0, p1, v0.bulge, elevation, &p.common) { + if let Some(arc) = bulge_to_arc( + p0, + p1, + v0.bulge, + elevation, + &p.common, + p.thickness, + p.normal.clone(), + ) { result.push(arc); } } @@ -258,6 +285,8 @@ fn bulge_to_arc( bulge: f64, elevation: f64, common_src: &EntityCommon, + thickness: f64, + normal: Vector3, ) -> Option { let ba = crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge)?; @@ -280,6 +309,8 @@ fn bulge_to_arc( radius: ba.radius, start_angle, end_angle, + thickness, + normal, ..ArcEnt::new() }; Some(EntityType::Arc(arc)) @@ -1720,4 +1751,66 @@ mod tests { dim_line ); } + + // EXPLODE replaces the polyline with fresh Line/Arc entities; each piece + // must carry the source thickness instead of resetting to 0 (#916). + #[test] + fn explode_keeps_lwpolyline_thickness() { + use acadrust::entities::LwVertex; + use acadrust::types::Vector2; + + let mut pl = LwPolyline::new(); + // One straight span + one bulged span, so the result holds a Line and + // an Arc and both paths through the rebuild are covered. + let mut v1 = LwVertex::new(Vector2::new(0.0, 0.0)); + v1.bulge = 1.0; // semicircle to the next vertex + pl.vertices = vec![ + v1, + LwVertex::new(Vector2::new(10.0, 0.0)), + LwVertex::new(Vector2::new(20.0, 0.0)), + ]; + pl.thickness = 4.0; + + let pieces = explode_polyline_segments(&EntityType::LwPolyline(pl)); + assert!(pieces.len() >= 2, "expected a line and an arc piece"); + for piece in &pieces { + match piece { + EntityType::Line(l) => assert!( + (l.thickness - 4.0).abs() < 1e-12, + "exploded line must keep thickness, got {}", + l.thickness + ), + EntityType::Arc(a) => assert!( + (a.thickness - 4.0).abs() < 1e-12, + "exploded arc must keep thickness, got {}", + a.thickness + ), + other => panic!("unexpected piece type: {other:?}"), + } + } + } + + #[test] + fn explode_keeps_polyline2d_extrusion() { + use acadrust::entities::Vertex2D; + + let mut pl = Polyline2D::new(); + pl.vertices = vec![ + Vertex2D::new(Vector3::new(0.0, 0.0, 0.0)), + Vertex2D::new(Vector3::new(10.0, 0.0, 0.0)), + ]; + pl.elevation = 2.0; + pl.thickness = -4.0; + pl.normal = Vector3::new(1.0, 0.0, 0.0); + + let pieces = explode_polyline_segments(&EntityType::Polyline2D(pl)); + let Some(EntityType::Line(line)) = pieces.first() else { + panic!("expected one line"); + }; + assert_eq!(pieces.len(), 1); + assert_eq!(line.normal, Vector3::new(1.0, 0.0, 0.0)); + assert!((line.thickness + 4.0).abs() < 1e-12); + assert!((line.start.x - 2.0).abs() < 1e-12); + assert!((line.end.x - 2.0).abs() < 1e-12); + } } diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index b2bf2eae7..8d1eca9d7 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -94,6 +94,25 @@ fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } +fn extrusion(entity: &EntityType) -> Option<(f64, DVec3)> { + let mut thickness = crate::scene::view::dispatch::entity_thickness(entity)?; + let normal = match entity { + EntityType::Arc(entity) => &entity.normal, + EntityType::Line(entity) => &entity.normal, + EntityType::LwPolyline(entity) => &entity.normal, + EntityType::Polyline2D(entity) => &entity.normal, + _ => return None, + }; + let normal = DVec3::new(normal.x, normal.y, normal.z); + if thickness == 0.0 { + return Some((0.0, normal.try_normalize().unwrap_or(DVec3::Z))); + } + let length = normal.length(); + let normal = normal.try_normalize()?; + thickness *= length; + (thickness.is_finite() && normal.is_finite()).then_some((thickness, normal)) +} + /// Build the chain segments for one entity, or `None` for an entity type /// JOIN can't carry (which aborts the whole join). Lines and arcs contribute /// one segment; an OPEN polyline contributes one per span (bulges kept), so @@ -143,12 +162,23 @@ fn segs_of(e: &EntityType) -> Option> { if p.is_closed() || p.vertices.len() < 2 { return None; } + let normal = DVec3::new(p.normal.x, p.normal.y, p.normal.z).try_normalize()?; + if normal.x.abs() > 1e-12 || normal.y.abs() > 1e-12 { + return None; + } + let cadkernel::geom2d::Curve::Polyline(curve) = + crate::entities::curve::entity_curve_xy(&EntityType::Polyline2D(p.clone()))? + else { + return None; + }; + let z = crate::entities::curve::ocs_plane(p.normal.clone(), p.elevation).origin[2]; Some( - p.vertices + curve + .vertices .windows(2) .map(|w| Seg { - a: DVec3::new(w[0].location.x, w[0].location.y, w[0].location.z), - b: DVec3::new(w[1].location.x, w[1].location.y, w[1].location.z), + a: DVec3::new(w[0].position[0], w[0].position[1], z), + b: DVec3::new(w[1].position[0], w[1].position[1], z), bulge: w[0].bulge, }) .collect(), @@ -176,6 +206,7 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, } let handles: Vec = entities.iter().map(|(h, _)| *h).collect(); let common = entities[0].1.common().clone(); + let (thickness, normal) = extrusion(entities[0].1)?; let (chain, closed) = stitch(segs)?; @@ -198,15 +229,24 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, line.common.handle = Handle::NULL; line.start = v3(verts.first().unwrap().0); line.end = v3(verts.last().unwrap().0); + line.normal = v3(normal); + line.thickness = thickness; return Some((handles, vec![EntityType::Line(line)])); } if planar { + if thickness != 0.0 && normal.x.hypot(normal.y) > 1e-9 { + return None; + } + let flipped = thickness != 0.0 && normal.z < 0.0; let lw_verts: Vec = verts .iter() .map(|(p, bulge)| { - let mut v = acadrust::entities::LwVertex::new(Vector2::new(p.x, p.y)); - v.bulge = *bulge; + let mut v = acadrust::entities::LwVertex::new(Vector2::new( + if flipped { -p.x } else { p.x }, + p.y, + )); + v.bulge = if flipped { -*bulge } else { *bulge }; v }) .collect(); @@ -215,7 +255,11 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, pl.common.handle = Handle::NULL; pl.vertices = lw_verts; pl.is_closed = closed; - pl.elevation = z0; + pl.elevation = if flipped { -z0 } else { z0 }; + pl.thickness = thickness; + if flipped { + pl.normal = Vector3::new(0.0, 0.0, -1.0); + } return Some((handles, vec![EntityType::LwPolyline(pl)])); } @@ -224,6 +268,9 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec, if has_arc { return None; } + if thickness != 0.0 { + return None; + } let mut pl = acadrust::entities::Polyline3D::new(); pl.common = common; pl.common.handle = Handle::NULL; @@ -284,6 +331,76 @@ fn stitch(mut segs: Vec) -> Option<(Vec, bool)> { Some((chain, closed)) } +#[cfg(test)] +mod join_tests { + use super::*; + use acadrust::entities::{Line as LineEnt, LwVertex}; + use acadrust::Handle; + + fn line(x0: f64, y0: f64, x1: f64, y1: f64, thickness: f64) -> EntityType { + let mut l = LineEnt::new(); + l.start = Vector3::new(x0, y0, 0.0); + l.end = Vector3::new(x1, y1, 0.0); + l.thickness = thickness; + EntityType::Line(l) + } + + fn lw_2pts(p0: (f64, f64), p1: (f64, f64), thickness: f64) -> EntityType { + let mut pl = acadrust::entities::LwPolyline::new(); + pl.vertices = vec![ + LwVertex::new(Vector2::new(p0.0, p0.1)), + LwVertex::new(Vector2::new(p1.0, p1.1)), + ]; + pl.thickness = thickness; + EntityType::LwPolyline(pl) + } + + // JOIN rebuilds the result entity; thickness must follow the chain's first + // entity (the same source `common` comes from), not reset to 0 (#916). + #[test] + fn join_keeps_source_thickness() { + let h1 = Handle::new(1); + let h2 = Handle::new(2); + let mut e1 = lw_2pts((0.0, 0.0), (10.0, 0.0), 2.5); + let EntityType::LwPolyline(source) = &mut e1 else { + unreachable!(); + }; + source.normal = Vector3::new(0.0, 0.0, -1.0); + // A corner keeps the chain from collapsing back to a single Line. + let e2 = line(10.0, 0.0, 10.0, 10.0, 0.0); + let (removed, out) = join_entities(&[(h1, &e1), (h2, &e2)]).expect("chain joins"); + assert_eq!(removed.len(), 2); + let Some(EntityType::LwPolyline(pl)) = out.first() else { + panic!("expected joined lwpolyline"); + }; + assert!( + (pl.thickness - 2.5).abs() < 1e-12, + "joined polyline must keep source thickness, got {}", + pl.thickness + ); + assert_eq!(pl.normal, Vector3::new(0.0, 0.0, -1.0)); + } + + // A collinear straight run collapses to a single Line; its thickness must + // come from the first source entity too, not default to 0. + #[test] + fn collinear_join_keeps_thickness() { + let h1 = Handle::new(1); + let h2 = Handle::new(2); + let e1 = line(0.0, 0.0, 5.0, 0.0, 1.25); + let e2 = line(5.0, 0.0, 10.0, 0.0, 0.0); + let (_, out) = join_entities(&[(h1, &e1), (h2, &e2)]).expect("chain joins"); + let Some(EntityType::Line(l)) = out.first() else { + panic!("expected collapsed line"); + }; + assert!( + (l.thickness - 1.25).abs() < 1e-12, + "collapsed line must keep source thickness, got {}", + l.thickness + ); + } +} + /// True when every vertex lies on one straight line (within tolerance). fn is_collinear(verts: &[(DVec3, f64)]) -> bool { if verts.len() < 3 { diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 63051e5d0..54e53e178 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -1,7 +1,7 @@ // PEDIT edits polylines and polygon meshes. use acadrust::entities::LwVertex; -use acadrust::types::Vector2; +use acadrust::types::{Vector2, Vector3}; use acadrust::{EntityType, Handle}; use cadkernel::geom2d::nurbs::clamped_uniform_knots; use cadkernel::geom2d::NurbsCurve; @@ -685,14 +685,35 @@ pub fn convert_to_polyline(entity: &EntityType) -> Option { let mut pl = acadrust::LwPolyline::new(); match entity { EntityType::Line(l) => { + let normal = DVec3::new(l.normal.x, l.normal.y, l.normal.z).try_normalize()?; + let normal = Vector3::new(normal.x, normal.y, normal.z); + let start = [l.start.x, l.start.y, l.start.z]; + let end = [l.end.x, l.end.y, l.end.z]; + let elevation = DVec3::from_array(start).dot(DVec3::new( + normal.x, normal.y, normal.z, + )); + let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation); + let tolerance = cadkernel::space::coplanarity_tolerance(&[start, end]); + if !plane.contains(end, tolerance) { + return None; + } + let start = plane.project(start)?; + let end = plane.project(end)?; pl.common = l.common.clone(); + pl.thickness = l.thickness; + pl.elevation = elevation; + pl.normal = normal; pl.vertices = vec![ - LwVertex::new(Vector2::new(l.start.x, l.start.y)), - LwVertex::new(Vector2::new(l.end.x, l.end.y)), + LwVertex::new(Vector2::new(start[0], start[1])), + LwVertex::new(Vector2::new(end[0], end[1])), ]; } EntityType::Arc(a) => { + let normal = DVec3::new(a.normal.x, a.normal.y, a.normal.z).try_normalize()?; pl.common = a.common.clone(); + pl.thickness = a.thickness; + pl.elevation = a.center.z; + pl.normal = Vector3::new(normal.x, normal.y, normal.z); let (sa, ea) = (a.start_angle, a.end_angle); let sweep = { let s = (ea - sa).rem_euclid(TAU); @@ -720,6 +741,53 @@ pub fn convert_to_polyline(entity: &EntityType) -> Option { Some(EntityType::LwPolyline(pl)) } +#[cfg(test)] +mod convert_tests { + use super::*; + + // PEDIT convert rebuilds the entity as a polyline; thickness must move + // onto the new entity instead of resetting to 0 (#916). + #[test] + fn convert_keeps_line_thickness() { + let mut l = acadrust::entities::Line::new(); + l.start = acadrust::types::Vector3::new(2.0, 3.0, 4.0); + l.end = acadrust::types::Vector3::new(2.0, 5.0, 6.0); + l.normal = acadrust::types::Vector3::new(1.0, 0.0, 0.0); + l.thickness = 3.5; + let Some(EntityType::LwPolyline(pl)) = convert_to_polyline(&EntityType::Line(l)) else { + panic!("line must convert"); + }; + assert!( + (pl.thickness - 3.5).abs() < 1e-12, + "converted polyline must keep source thickness, got {}", + pl.thickness + ); + assert_eq!(pl.normal, acadrust::types::Vector3::new(1.0, 0.0, 0.0)); + assert!((pl.elevation - 2.0).abs() < 1e-12); + } + + #[test] + fn convert_keeps_arc_thickness() { + let mut a = acadrust::entities::Arc::new(); + a.center = acadrust::types::Vector3::new(0.0, 0.0, 4.0); + a.normal = acadrust::types::Vector3::new(0.0, 1.0, 0.0); + a.radius = 5.0; + a.start_angle = 0.0; + a.end_angle = std::f64::consts::FRAC_PI_2; + a.thickness = -2.0; + let Some(EntityType::LwPolyline(pl)) = convert_to_polyline(&EntityType::Arc(a)) else { + panic!("arc must convert"); + }; + assert!( + (pl.thickness - (-2.0)).abs() < 1e-12, + "converted polyline must keep negative thickness, got {}", + pl.thickness + ); + assert_eq!(pl.normal, acadrust::types::Vector3::new(0.0, 1.0, 0.0)); + assert!((pl.elevation - 4.0).abs() < 1e-12); + } +} + // ── Curve fitting ───────────────────────────────────────────────────────── fn vert_xy(v: &LwVertex) -> DVec2 {