From 758ab286366cbfbaa546ab48687f06d835c3f12e Mon Sep 17 00:00:00 2001 From: sclMAX <6246024+sclMAX@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:06:04 -0300 Subject: [PATCH] feat(explode): support exploding ACIS and wire Region entities --- src/modules/draw/modify/explode.rs | 443 +++++++++++++++++++++++++++++ tests/explode_regression.rs | 402 ++++++++++++++++++++++++++ 2 files changed, 845 insertions(+) diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index e2de31c50..417371d52 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -11,6 +11,8 @@ // Insert → constituent entities (via opencadcodec explode_from_document) // MLine → Lines (spine + offset lines per miter direction) // Dimension → Lines (extension + dimension + arrows) + Text +// MText → Text lines +// Region → Lines, Arcs, Circles, Ellipses, Splines (from ACIS B-Rep loops or wires) // // Unsupported entity types are skipped silently. @@ -106,6 +108,7 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec explode_mline(ml), EntityType::Dimension(dim) => explode_dimension(dim, document), EntityType::MText(t) => crate::entities::mtext::explode_mtext(t, document), + EntityType::Region(region) => explode_region(region), _ => vec![], } } @@ -427,6 +430,446 @@ fn explode_mline(ml: &MLine) -> Vec { result } +// ── Region explode ───────────────────────────────────────────────────────── + +fn region_piece_common(region: &codec::entities::Region) -> EntityCommon { + let mut common = region.common.clone(); + common.handle = Handle::NULL; + common.owner_handle = Handle::NULL; + common.xdictionary_handle = None; + common.reactors.clear(); + common +} + +fn explode_region(region: &codec::entities::Region) -> Vec { + let common = region_piece_common(region); + let mut out = Vec::new(); + + if region.acis_data.has_data() { + let sat = region.parse_sat().or_else(|| { + (region.acis_data.is_binary && !region.acis_data.sab_data.is_empty()) + .then(|| codec::entities::acis::SabReader::read(®ion.acis_data.sab_data).ok()) + .flatten() + }); + if let Some(sat) = sat { + let (bodies, _loss) = kernel::acis::lift(&sat); + for body in &bodies { + for face in body.face_keys() { + if let Some(profile) = kernel::brep::planar_face_profile(body, face) { + append_region_loops(&profile.plane, &profile.loops, &common, &mut out); + } + } + } + } + if out.is_empty() { + if let Some((plane, loops, _)) = crate::scene::model::presspull_model::profile_geometry( + &EntityType::Region(region.clone()), + ) { + append_region_loops(&plane, &loops, &common, &mut out); + } + } + return out; + } + + // Fallback for synthetic/legacy wire-only regions without ACIS payload. + for wire in ®ion.wires { + for pair in wire.points.windows(2) { + let (a, b) = (pair[0], pair[1]); + if (b - a).length() <= 1e-12 { + continue; + } + out.push(EntityType::Line(LineEnt { + common: common.clone(), + start: a, + end: b, + ..LineEnt::new() + })); + } + } + out +} + +#[derive(Debug, Clone)] +struct CircularPiece { + centre: [f64; 2], + radius: f64, + start_pt: [f64; 2], + mid_pt: [f64; 2], + end_pt: [f64; 2], + sweep: f64, +} + +fn as_circular_piece(curve: &kernel::geom2d::Curve) -> Option { + use kernel::geom2d::Curve; + match curve { + Curve::Circle(circle) if circle.radius > 1e-12 => { + let centre = circle.centre; + let radius = circle.radius; + let start_pt = [centre[0] + radius, centre[1]]; + let mid_pt = [centre[0] - radius, centre[1]]; + let end_pt = start_pt; + Some(CircularPiece { + centre, + radius, + start_pt, + mid_pt, + end_pt, + sweep: std::f64::consts::TAU, + }) + } + Curve::Arc(arc) if arc.radius > 1e-12 => { + let centre = arc.centre; + let radius = arc.radius; + let pt_at = |ang: f64| [centre[0] + radius * ang.cos(), centre[1] + radius * ang.sin()]; + let start_pt = pt_at(arc.start_angle); + let end_pt = pt_at(arc.end_angle); + let diff = (arc.end_angle - arc.start_angle).abs(); + let sweep = if diff <= 1e-12 || (diff - std::f64::consts::TAU).abs() <= 1e-6 { + std::f64::consts::TAU + } else { + diff + }; + let mid_pt = if (sweep - std::f64::consts::TAU).abs() <= 1e-6 { + pt_at(arc.start_angle + std::f64::consts::PI) + } else { + pt_at((arc.start_angle + arc.end_angle) * 0.5) + }; + Some(CircularPiece { + centre, + radius, + start_pt, + mid_pt, + end_pt, + sweep, + }) + } + Curve::Ellipse(arc) => { + let maj = arc.ellipse.major_radius; + let min = arc.ellipse.minor_radius; + if maj > 1e-12 && (min - maj).abs() <= 1e-6 * (1.0 + maj) { + let centre = arc.ellipse.centre; + let radius = (maj + min) * 0.5; + let start_pt = arc.ellipse.point_at(arc.start_parameter); + let end_pt = arc.ellipse.point_at(arc.end_parameter); + let diff = (arc.end_parameter - arc.start_parameter).abs(); + let sweep = if (arc.sweep() - std::f64::consts::TAU).abs() <= 1e-6 + || diff <= 1e-12 + || (diff - std::f64::consts::TAU).abs() <= 1e-6 + { + std::f64::consts::TAU + } else { + diff + }; + let mid_pt = if (sweep - std::f64::consts::TAU).abs() <= 1e-6 { + arc.ellipse.point_at(arc.start_parameter + std::f64::consts::PI) + } else { + arc.ellipse.point_at((arc.start_parameter + arc.end_parameter) * 0.5) + }; + Some(CircularPiece { + centre, + radius, + start_pt, + mid_pt, + end_pt, + sweep, + }) + } else { + None + } + } + Curve::Nurbs(nurbs) => { + let p0 = nurbs.point_at(0.0); + let p1 = nurbs.point_at(0.5); + let p2 = nurbs.point_at(1.0); + let d = 2.0 * (p0[0] * (p1[1] - p2[1]) + p1[0] * (p2[1] - p0[1]) + p2[0] * (p0[1] - p1[1])); + if d.abs() > 1e-12 { + let p0_sq = p0[0] * p0[0] + p0[1] * p0[1]; + let p1_sq = p1[0] * p1[0] + p1[1] * p1[1]; + let p2_sq = p2[0] * p2[0] + p2[1] * p2[1]; + let cx = (p0_sq * (p1[1] - p2[1]) + p1_sq * (p2[1] - p0[1]) + p2_sq * (p0[1] - p1[1])) / d; + let cy = (p0_sq * (p2[0] - p1[0]) + p1_sq * (p0[0] - p2[0]) + p2_sq * (p1[0] - p0[0])) / d; + let r = (p0[0] - cx).hypot(p0[1] - cy); + if r > 1e-12 { + let tol = 1e-5 * (1.0 + r); + let mut is_circle = true; + for step in 1..16 { + let t = step as f64 / 16.0; + let pt = nurbs.point_at(t); + if ((pt[0] - cx).hypot(pt[1] - cy) - r).abs() > tol { + is_circle = false; + break; + } + } + if is_circle { + let th0 = (p0[1] - cy).atan2(p0[0] - cx); + let th1 = (p1[1] - cy).atan2(p1[0] - cx); + let th2 = (p2[1] - cy).atan2(p2[0] - cx); + let ccw_span = |from: f64, to: f64| { + let mut s = to - from; + while s < 0.0 { + s += std::f64::consts::TAU; + } + while s >= std::f64::consts::TAU { + s -= std::f64::consts::TAU; + } + s + }; + let sweep = if ccw_span(th0, th1) <= ccw_span(th0, th2) { + ccw_span(th0, th2) + } else { + ccw_span(th2, th0) + }; + let sweep = if sweep <= 1e-12 { + std::f64::consts::TAU + } else { + sweep + }; + return Some(CircularPiece { + centre: [cx, cy], + radius: r, + start_pt: p0, + mid_pt: p1, + end_pt: p2, + sweep, + }); + } + } + } + None + } + _ => None, + } +} + +fn append_region_loops( + plane: &kernel::space::Plane, + loops: &[Vec], + common: &EntityCommon, + out: &mut Vec, +) { + use kernel::geom2d::Curve; + let Some(normal_arr) = plane.normal() else { + return; + }; + let normal = Vector3::new(normal_arr[0], normal_arr[1], normal_arr[2]); + let normal_tuple = (normal.x, normal.y, normal.z); + let (ax, ay) = crate::scene::view::transform::ocs_axes(normal_tuple); + let ocs_x = DVec3::new(ax.0, ax.1, ax.2); + let ocs_y = DVec3::new(ay.0, ay.1, ay.2); + + let make_circle = |centre: [f64; 2], radius: f64| -> EntityType { + let wcs_c = plane.point_at(centre); + let (ox, oy, oz) = crate::scene::view::transform::wcs_point_to_ocs( + (wcs_c[0], wcs_c[1], wcs_c[2]), + normal_tuple, + ); + let mut circle = CircleEnt::new(); + circle.common = common.clone(); + circle.center = Vector3::new(ox, oy, oz); + circle.radius = radius; + circle.normal = normal; + EntityType::Circle(circle) + }; + + let make_ellipse = |ellipse: &kernel::geom2d::Ellipse, start_param: f64, end_param: f64| -> Option { + if ellipse.major_radius <= 1e-12 || ellipse.minor_radius <= 1e-12 { + return None; + } + let wcs_c = plane.point_at(ellipse.centre); + let tip = plane.point_at([ + ellipse.centre[0] + ellipse.major_axis[0] * ellipse.major_radius, + ellipse.centre[1] + ellipse.major_axis[1] * ellipse.major_radius, + ]); + let mut ent = codec::entities::Ellipse::new(); + ent.common = common.clone(); + ent.center = Vector3::new(wcs_c[0], wcs_c[1], wcs_c[2]); + ent.major_axis = Vector3::new(tip[0] - wcs_c[0], tip[1] - wcs_c[1], tip[2] - wcs_c[2]); + ent.minor_axis_ratio = ellipse.minor_radius / ellipse.major_radius; + ent.start_parameter = start_param; + ent.end_parameter = end_param; + ent.normal = normal; + Some(EntityType::Ellipse(ent)) + }; + + let make_arc_from_piece = |piece: &CircularPiece| -> EntityType { + let wcs_c = DVec3::from_array(plane.point_at(piece.centre)); + let (ox, oy, oz) = crate::scene::view::transform::wcs_point_to_ocs( + (wcs_c.x, wcs_c.y, wcs_c.z), + normal_tuple, + ); + let wcs_start = DVec3::from_array(plane.point_at(piece.start_pt)); + let wcs_mid = DVec3::from_array(plane.point_at(piece.mid_pt)); + let wcs_end = DVec3::from_array(plane.point_at(piece.end_pt)); + + let start_dir = wcs_start - wcs_c; + let mid_dir = wcs_mid - wcs_c; + let end_dir = wcs_end - wcs_c; + + let ang_start = norm_angle(start_dir.dot(ocs_y).atan2(start_dir.dot(ocs_x))); + let ang_mid = norm_angle(mid_dir.dot(ocs_y).atan2(mid_dir.dot(ocs_x))); + let ang_end = norm_angle(end_dir.dot(ocs_y).atan2(end_dir.dot(ocs_x))); + + let ccw_span = |from: f64, to: f64| { + let mut d = to - from; + while d < 0.0 { + d += std::f64::consts::TAU; + } + while d >= std::f64::consts::TAU { + d -= std::f64::consts::TAU; + } + d + }; + + let total_ccw = ccw_span(ang_start, ang_end); + let mid_ccw = ccw_span(ang_start, ang_mid); + + let (start_angle, end_angle) = if mid_ccw <= total_ccw { + (ang_start, ang_end) + } else { + (ang_end, ang_start) + }; + + let mut arc = ArcEnt::new(); + arc.common = common.clone(); + arc.center = Vector3::new(ox, oy, oz); + arc.radius = piece.radius; + arc.start_angle = start_angle; + arc.end_angle = end_angle; + arc.normal = normal; + EntityType::Arc(arc) + }; + + for ring in loops { + // Collapse a closed loop of circular arcs/curves into one Circle. + let circular_pieces: Vec = ring.iter().filter_map(as_circular_piece).collect(); + if circular_pieces.len() == ring.len() && !circular_pieces.is_empty() { + let first = &circular_pieces[0]; + let tol = 1e-5 * (1.0 + first.radius); + let same_circle = circular_pieces.iter().all(|p| { + (p.centre[0] - first.centre[0]).hypot(p.centre[1] - first.centre[1]) <= tol + && (p.radius - first.radius).abs() <= tol + }); + let total_sweep: f64 = circular_pieces.iter().map(|p| p.sweep).sum(); + if same_circle && (total_sweep - std::f64::consts::TAU).abs() <= 1e-4 { + out.push(make_circle(first.centre, first.radius)); + continue; + } + } + + // Collapse a closed loop of coaxial equal-axes elliptical arcs into one full Ellipse. + if let [Curve::Ellipse(first), rest @ ..] = ring.as_slice() { + let tol = 1e-6 * (1.0 + first.ellipse.major_radius); + let same_ellipse = rest.iter().all(|piece| { + matches!( + piece, + Curve::Ellipse(arc) + if (arc.ellipse.centre[0] - first.ellipse.centre[0]) + .hypot(arc.ellipse.centre[1] - first.ellipse.centre[1]) + <= tol + && (arc.ellipse.major_radius - first.ellipse.major_radius).abs() <= tol + && (arc.ellipse.minor_radius - first.ellipse.minor_radius).abs() <= tol + && (arc.ellipse.major_axis[0] * first.ellipse.major_axis[0] + + arc.ellipse.major_axis[1] * first.ellipse.major_axis[1] + - 1.0) + .abs() + <= 1e-6 + ) + }); + let total_sweep: f64 = ring + .iter() + .map(|piece| match piece { + Curve::Ellipse(arc) => { + let diff = (arc.end_parameter - arc.start_parameter).abs(); + if (arc.sweep() - std::f64::consts::TAU).abs() <= 1e-6 + || diff <= 1e-12 + || (diff - std::f64::consts::TAU).abs() <= 1e-6 + { + std::f64::consts::TAU + } else { + diff + } + } + _ => 0.0, + }) + .sum(); + if same_ellipse && (total_sweep - std::f64::consts::TAU).abs() <= 1e-5 { + if let Some(ent) = make_ellipse(&first.ellipse, 0.0, std::f64::consts::TAU) { + out.push(ent); + continue; + } + } + } + + for piece in ring { + if let Some(circ) = as_circular_piece(piece) { + if (circ.sweep - std::f64::consts::TAU).abs() <= 1e-6 { + out.push(make_circle(circ.centre, circ.radius)); + } else { + out.push(make_arc_from_piece(&circ)); + } + continue; + } + match piece { + Curve::Line(line) => { + let start = plane.point_at(line.start); + let end = plane.point_at(line.end); + let s = Vector3::new(start[0], start[1], start[2]); + let e = Vector3::new(end[0], end[1], end[2]); + if (e - s).length() <= 1e-12 { + continue; + } + out.push(EntityType::Line(LineEnt { + common: common.clone(), + start: s, + end: e, + normal, + ..LineEnt::new() + })); + } + Curve::Ellipse(arc) => { + let (start_param, end_param) = + if (arc.sweep() - std::f64::consts::TAU).abs() <= 1e-6 { + (0.0, std::f64::consts::TAU) + } else if arc.end_parameter > arc.start_parameter { + (arc.start_parameter, arc.end_parameter) + } else { + ( + arc.start_parameter, + arc.end_parameter + std::f64::consts::TAU, + ) + }; + if let Some(ent) = make_ellipse(&arc.ellipse, start_param, end_param) { + out.push(ent); + } + } + Curve::Nurbs(nurbs) => { + let mut spline = codec::entities::Spline::new(); + spline.common = common.clone(); + spline.degree = nurbs.degree() as i32; + spline.knots = nurbs.knots().to_vec(); + spline.control_points = nurbs + .control_points() + .iter() + .map(|&uv| { + let p = plane.point_at(uv); + Vector3::new(p[0], p[1], p[2]) + }) + .collect(); + if nurbs.is_rational() { + spline.weights = nurbs.weights().to_vec(); + spline.flags.rational = true; + } + spline.flags.planar = true; + spline.flags.closed = nurbs.is_closed(); + spline.normal = normal; + out.push(EntityType::Spline(spline)); + } + _ => {} + } + } + } +} + // ── Dimension explode ────────────────────────────────────────────────────── /// Convert a Dimension entity into Lines (geometry) + Text (label). diff --git a/tests/explode_regression.rs b/tests/explode_regression.rs index 9ec6bbc95..af1f9ba79 100644 --- a/tests/explode_regression.rs +++ b/tests/explode_regression.rs @@ -467,3 +467,405 @@ fn apply_explode_replacements_matches_legacy_loop() { )]; assert_eq!(explode_batch(&items, &scene_batched.document), vec![vec![]]); } + +#[test] +fn explode_region_acis_sat_and_binary_sab_with_hole_and_style() { + use codec::entities::acis::{SabWriter, SatDocument}; + use codec::entities::{AcisData, Region}; + use kernel::brep; + use kernel::geom2d::{Arc as KArc, Curve as KCurve, Line as KLine}; + use kernel::space::Plane; + + let plane = Plane::from_axes([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + let outer = vec![ + KCurve::Line(KLine { + start: [0.0, 0.0], + end: [20.0, 0.0], + }), + KCurve::Line(KLine { + start: [20.0, 0.0], + end: [20.0, 10.0], + }), + KCurve::Line(KLine { + start: [20.0, 10.0], + end: [0.0, 10.0], + }), + KCurve::Line(KLine { + start: [0.0, 10.0], + end: [0.0, 0.0], + }), + ]; + let hole: Vec = (0..4) + .map(|q| { + let start = std::f64::consts::FRAC_PI_2 * q as f64; + KCurve::Arc(KArc { + centre: [10.0, 5.0], + radius: 2.0, + start_angle: start, + end_angle: start + std::f64::consts::FRAC_PI_2, + }) + }) + .collect(); + + let body = brep::planar_region(plane, &[outer, hole]).expect("planar region with hole"); + let mut sat = SatDocument::new(); + kernel::acis::append(&body, &mut sat).expect("sat append"); + let sat_text = sat.to_sat_string(); + let parsed_sat = SatDocument::parse(&sat_text).expect("sat parse"); + let sab_bytes = SabWriter::write(&parsed_sat); + + let doc = CadDocument::new(); + + for (label, acis_data) in [ + ("SAT", AcisData::from_sat(&sat_text)), + ( + "SAB", + AcisData { + is_binary: true, + sab_data: sab_bytes, + ..AcisData::default() + }, + ), + ] { + let mut region = Region::new(); + region.acis_data = acis_data; + region.common.layer = "REGIONS".to_string(); + region.common.color = codec::types::Color::from_index(3); + region.common.linetype = "DASHED".to_string(); + region.common.xdictionary_handle = Some(Handle::new(999)); + + let pieces = explode_entity(&EntityType::Region(region), &doc); + let lines: Vec<_> = pieces + .iter() + .filter_map(|p| match p { + EntityType::Line(l) => Some(l), + _ => None, + }) + .collect(); + let circles: Vec<_> = pieces + .iter() + .filter_map(|p| match p { + EntityType::Circle(c) => Some(c), + _ => None, + }) + .collect(); + + assert_eq!(lines.len(), 4, "{label}: outer rectangle must yield 4 lines"); + assert_eq!(circles.len(), 1, "{label}: circular hole must yield 1 circle"); + assert!((circles[0].radius - 2.0).abs() < 1e-6); + assert!((circles[0].center.x - 10.0).abs() < 1e-6); + assert!((circles[0].center.y - 5.0).abs() < 1e-6); + + for piece in &pieces { + let c = piece.common(); + assert_eq!(c.layer, "REGIONS", "{label}: layer preserved"); + assert_eq!(c.color, codec::types::Color::from_index(3), "{label}: color preserved"); + assert_eq!(c.linetype, "DASHED", "{label}: linetype preserved"); + assert!(c.handle.is_null(), "{label}: handle cleared"); + assert!(c.owner_handle.is_null(), "{label}: owner_handle cleared"); + assert!(c.xdictionary_handle.is_none(), "{label}: xdict must not leak"); + } + } +} + +#[test] +fn explode_region_tilted_plane_arc_and_multi_island() { + use codec::entities::acis::SatDocument; + use codec::entities::{AcisData, Region}; + use kernel::brep; + use kernel::geom2d::{Arc as KArc, Curve as KCurve, Line as KLine}; + use kernel::space::Plane; + + // Semicircle + closing chord on tilted plane (XZ plane at y = 4.0). + let plane = Plane::from_axes([0.0, 4.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]); + let d_loop = vec![ + KCurve::Line(KLine { + start: [-5.0, 0.0], + end: [5.0, 0.0], + }), + KCurve::Arc(KArc { + centre: [0.0, 0.0], + radius: 5.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }), + ]; + let body = brep::planar_region(plane, &[d_loop]).expect("tilted D region"); + let mut sat = SatDocument::new(); + kernel::acis::append(&body, &mut sat).expect("sat append"); + let mut region = Region::new(); + region.acis_data = AcisData::from_sat(&sat.to_sat_string()); + + let doc = CadDocument::new(); + let pieces = explode_entity(&EntityType::Region(region), &doc); + assert_eq!(pieces.len(), 2, "D-region must explode to 1 Line + 1 Arc"); + + let line = pieces + .iter() + .find_map(|p| match p { + EntityType::Line(l) => Some(l), + _ => None, + }) + .expect("expected Line"); + assert!((line.start.y - 4.0).abs() < 1e-6 && (line.end.y - 4.0).abs() < 1e-6); + + let arc = pieces + .iter() + .find_map(|p| match p { + EntityType::Arc(a) => Some(a), + _ => None, + }) + .expect("expected Arc"); + let arc_planar = OpenCADStudio::entities::curve::arc_curve(arc); + let mid = arc_planar.point_at(0.5); + assert!( + (mid[0] - 0.0).abs() < 1e-6 && (mid[1] - 4.0).abs() < 1e-6 && (mid[2] - 5.0).abs() < 1e-6, + "exploded arc midpoint in WCS should sit at (0, 4, 5), got {mid:?}" + ); + + // Multi-island region (two disjoint rectangles unioned in one Region B-Rep). + let xy = Plane::from_axes([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + let rect_loop = |x0: f64, x1: f64| { + vec![ + KCurve::Line(KLine { + start: [x0, 0.0], + end: [x1, 0.0], + }), + KCurve::Line(KLine { + start: [x1, 0.0], + end: [x1, 5.0], + }), + KCurve::Line(KLine { + start: [x1, 5.0], + end: [x0, 5.0], + }), + KCurve::Line(KLine { + start: [x0, 5.0], + end: [x0, 0.0], + }), + ] + }; + let r1 = brep::planar_region(xy, &[rect_loop(0.0, 5.0)]).unwrap(); + let r2 = brep::planar_region(xy, &[rect_loop(10.0, 15.0)]).unwrap(); + let union_body = brep::union_planar_regions(&[r1, r2], 1e-6).unwrap(); + let mut union_sat = SatDocument::new(); + kernel::acis::append(&union_body, &mut union_sat).unwrap(); + let mut multi_region = Region::new(); + multi_region.acis_data = AcisData::from_sat(&union_sat.to_sat_string()); + let multi_pieces = explode_entity(&EntityType::Region(multi_region), &doc); + assert_eq!( + multi_pieces.len(), + 8, + "two-island region must explode to 8 lines" + ); + + // Wire-only fallback region (no ACIS data, display wires present). + let mut wire_region = Region::new(); + wire_region.wires = vec![codec::entities::Wire::from_points(vec![ + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + Vector3::new(4.0, 3.0, 0.0), + Vector3::new(0.0, 0.0, 0.0), + ])]; + let wire_pieces = explode_entity(&EntityType::Region(wire_region), &doc); + assert_eq!( + wire_pieces.len(), + 3, + "wire-only triangle region must explode to 3 lines" + ); +} + +#[test] +fn explode_region_acis_with_body_transform() { + use codec::entities::acis::SatDocument; + use codec::entities::{AcisData, Region}; + use kernel::brep; + use kernel::geom2d::{Curve as KCurve, Line as KLine}; + use kernel::space::Plane; + + let plane = Plane::from_axes([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + let rect = vec![ + KCurve::Line(KLine { + start: [0.0, 0.0], + end: [10.0, 0.0], + }), + KCurve::Line(KLine { + start: [10.0, 0.0], + end: [10.0, 5.0], + }), + KCurve::Line(KLine { + start: [10.0, 5.0], + end: [0.0, 5.0], + }), + KCurve::Line(KLine { + start: [0.0, 5.0], + end: [0.0, 0.0], + }), + ]; + let body = brep::planar_region(plane, &[rect]).expect("planar region"); + let mut sat = SatDocument::new(); + kernel::acis::append(&body, &mut sat).expect("sat append"); + + // AutoCAD writes a body transform record for placed regions. + // Translate by [50.0, 100.0, 0.0]. + sat.set_placement( + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + [50.0, 100.0, 0.0], + 1.0, + ); + + let mut region = Region::new(); + region.acis_data = AcisData::from_sat(&sat.to_sat_string()); + region.common.layer = "TRANSFORMED".to_string(); + + let doc = CadDocument::new(); + let pieces = explode_entity(&EntityType::Region(region), &doc); + assert_eq!(pieces.len(), 4, "placed region must explode to 4 lines"); + + let lines: Vec<_> = pieces + .iter() + .filter_map(|p| match p { + EntityType::Line(l) => Some(l), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 4); + + // Verify all line endpoints were shifted by [50, 100, 0] + for l in &lines { + assert!(l.start.x >= 50.0 - 1e-6 && l.start.x <= 60.0 + 1e-6); + assert!(l.start.y >= 100.0 - 1e-6 && l.start.y <= 105.0 + 1e-6); + assert!(l.end.x >= 50.0 - 1e-6 && l.end.x <= 60.0 + 1e-6); + assert!(l.end.y >= 100.0 - 1e-6 && l.end.y <= 105.0 + 1e-6); + assert_eq!(l.common.layer, "TRANSFORMED"); + assert!(l.common.handle.is_null()); + } +} + +#[test] +fn explode_region_elliptical_and_spline_boundaries() { + use codec::entities::acis::SatDocument; + use codec::entities::{AcisData, Region}; + use kernel::brep; + use kernel::geom2d::{ + Curve as KCurve, Ellipse as KEllipse, EllipseArc as KEllipseArc, Line as KLine, + NurbsCurve as KNurbs, + }; + use kernel::space::Plane; + + let plane = Plane::from_axes([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + let doc = CadDocument::new(); + + // 1. Full non-circular ellipse region (major 8.0, minor 4.0). + let ell_loop = vec![KCurve::Ellipse(KEllipseArc::full(KEllipse { + centre: [15.0, 20.0], + major_radius: 8.0, + minor_radius: 4.0, + major_axis: [1.0, 0.0], + }))]; + let body = brep::planar_region(plane, &[ell_loop]).expect("ellipse region"); + let mut sat = SatDocument::new(); + kernel::acis::append(&body, &mut sat).expect("sat append"); + let mut region = Region::new(); + region.acis_data = AcisData::from_sat(&sat.to_sat_string()); + region.common.layer = "ELLIPSES".to_string(); + + let pieces = explode_entity(&EntityType::Region(region), &doc); + assert_eq!( + pieces.len(), + 1, + "full ellipse region must explode to 1 Ellipse" + ); + let ell = match &pieces[0] { + EntityType::Ellipse(e) => e, + other => panic!("expected Ellipse entity, got {other:?}"), + }; + assert!((ell.center.x - 15.0).abs() < 1e-6); + assert!((ell.center.y - 20.0).abs() < 1e-6); + assert!((ell.major_axis_length() - 8.0).abs() < 1e-6); + assert!((ell.minor_axis_ratio - 0.5).abs() < 1e-6); + assert_eq!(ell.common.layer, "ELLIPSES"); + assert!(ell.common.handle.is_null()); + + // 2. Region with a cubic NURBS/Spline edge closed by a straight line. + let nurbs = KNurbs::new( + 3, + vec![ + [0.0, 0.0], + [3.0, 5.0], + [7.0, -2.0], + [10.0, 0.0], + ], + vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], + None, + ) + .expect("valid cubic nurbs"); + let spline_loop = vec![ + KCurve::Nurbs(nurbs), + KCurve::Line(KLine { + start: [10.0, 0.0], + end: [0.0, 0.0], + }), + ]; + let body_spline = brep::planar_region(plane, &[spline_loop]).expect("spline region"); + let mut sat_spline = SatDocument::new(); + kernel::acis::append(&body_spline, &mut sat_spline).expect("sat append"); + let mut region_spline = Region::new(); + region_spline.acis_data = AcisData::from_sat(&sat_spline.to_sat_string()); + region_spline.common.layer = "SPLINES".to_string(); + + let pieces_spline = explode_entity(&EntityType::Region(region_spline), &doc); + assert_eq!( + pieces_spline.len(), + 2, + "spline region must explode to 1 Spline and 1 Line" + ); + let has_line = pieces_spline.iter().any(|p| matches!(p, EntityType::Line(_))); + let has_spline = pieces_spline.iter().any(|p| matches!(p, EntityType::Spline(_))); + assert!(has_line, "must contain a Line"); + assert!(has_spline, "must contain a Spline"); + for p in &pieces_spline { + assert_eq!(p.common().layer, "SPLINES"); + assert!(p.common().handle.is_null()); + } + + // 3. Region with a circular arc represented as equal-radii Ellipse arc + lines. + let equal_ell_arc = KCurve::Ellipse(KEllipseArc { + ellipse: KEllipse { + centre: [0.0, 0.0], + major_radius: 5.0, + minor_radius: 5.0, + major_axis: [1.0, 0.0], + }, + start_parameter: 0.0, + end_parameter: std::f64::consts::FRAC_PI_2, + }); + let d_equal_loop = vec![ + KCurve::Line(KLine { + start: [0.0, 0.0], + end: [5.0, 0.0], + }), + equal_ell_arc, + KCurve::Line(KLine { + start: [0.0, 5.0], + end: [0.0, 0.0], + }), + ]; + let body_arc = brep::planar_region(plane, &[d_equal_loop]).expect("equal-radii ellipse region"); + let mut sat_arc = SatDocument::new(); + kernel::acis::append(&body_arc, &mut sat_arc).expect("sat append"); + let mut region_arc = Region::new(); + region_arc.acis_data = AcisData::from_sat(&sat_arc.to_sat_string()); + + let pieces_arc = explode_entity(&EntityType::Region(region_arc), &doc); + assert_eq!(pieces_arc.len(), 3, "must yield 2 lines and 1 arc"); + let arc_piece = pieces_arc + .iter() + .find_map(|p| match p { + EntityType::Arc(a) => Some(a), + _ => None, + }) + .expect("equal-radii ellipse arc must emit EntityType::Arc"); + assert!((arc_piece.radius - 5.0).abs() < 1e-6); +} +