Files
OneCAD-Draft/tests/explode_regression.rs
T

872 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! EXPLODE regression tests: golden per-arm outputs, geometry invariants,
//! and a differential test proving `apply_explode_replacements` matches the
//! legacy per-entity erase/apply loop (including leader-annotation coupling).
use codec::entities::{
CenterMarkAssociation, CenterMarkSource, CenterMarkSourceKind, Dimension, DimensionLinear,
Insert, Leader, Line, LwPolyline, LwVertex, MLine, Polyline, Polyline2D, Polyline3D, Vertex2D,
Vertex3D,
};
use codec::tables::BlockRecord;
use codec::types::{Vector2, Vector3};
use codec::{CadDocument, EntityType, Handle};
use OpenCADStudio::modules::draw::modify::explode::{
apply_explode_replacements, explode_batch, explode_entity, plan_explode,
};
use OpenCADStudio::scene::Scene;
const BLOCK_NAME: &str = "TEST_BLOCK";
/// Register a 6-line block the way baked blocks look: members in the
/// document, `entity_handles` filled, record in `block_records`.
fn add_test_block(doc: &mut CadDocument) {
let mut member_handles = Vec::new();
for k in 0..6 {
let mut line = Line::new();
line.start = Vector3::new(k as f64, 0.0, 0.0);
line.end = Vector3::new(k as f64, 5.0, 0.0);
member_handles.push(doc.add_entity(EntityType::Line(line)).unwrap());
}
let mut block = BlockRecord::new(BLOCK_NAME);
block.handle = doc.allocate_handle();
block.entity_handles = member_handles;
doc.block_records.add(block).unwrap();
}
// ── Golden per-arm outputs ────────────────────────────────────────────────
#[test]
fn explode_lwpolyline_golden_line_then_arc() {
let doc = CadDocument::new();
let mut pl = LwPolyline::new();
let mut mid = LwVertex::new(Vector2::new(10.0, 0.0));
mid.bulge = 1.0; // second segment becomes an arc
pl.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
mid,
LwVertex::new(Vector2::new(20.0, 0.0)),
];
let pieces = explode_entity(&EntityType::LwPolyline(pl), &doc);
assert_eq!(pieces.len(), 2);
assert!(matches!(pieces[0], EntityType::Line(_)));
assert!(matches!(pieces[1], EntityType::Arc(_)));
}
#[test]
fn explode_polyline_family_golden_all_lines() {
let doc = CadDocument::new();
let mut p2 = Polyline2D::new();
p2.vertices = vec![
Vertex2D::new(Vector3::new(0.0, 0.0, 0.0)),
Vertex2D::new(Vector3::new(5.0, 0.0, 0.0)),
Vertex2D::new(Vector3::new(5.0, 5.0, 0.0)),
];
let pieces = explode_entity(&EntityType::Polyline2D(p2), &doc);
assert_eq!(pieces.len(), 2);
assert!(pieces.iter().all(|p| matches!(p, EntityType::Line(_))));
let mut p3 = Polyline3D::new();
p3.vertices = vec![
codec::entities::Vertex3DPolyline {
position: Vector3::new(0.0, 0.0, 0.0),
..Default::default()
},
codec::entities::Vertex3DPolyline {
position: Vector3::new(5.0, 0.0, 0.0),
..Default::default()
},
codec::entities::Vertex3DPolyline {
position: Vector3::new(5.0, 5.0, 0.0),
..Default::default()
},
];
let pieces = explode_entity(&EntityType::Polyline3D(p3), &doc);
assert_eq!(pieces.len(), 2);
assert!(pieces.iter().all(|p| matches!(p, EntityType::Line(_))));
let mut p = Polyline::new();
p.vertices = vec![
Vertex3D::new(Vector3::new(0.0, 0.0, 0.0)),
Vertex3D::new(Vector3::new(5.0, 0.0, 0.0)),
Vertex3D::new(Vector3::new(5.0, 5.0, 0.0)),
];
let pieces = explode_entity(&EntityType::Polyline(p), &doc);
assert_eq!(pieces.len(), 2);
assert!(pieces.iter().all(|p| matches!(p, EntityType::Line(_))));
}
#[test]
fn explode_insert_golden_block_members() {
let mut doc = CadDocument::new();
add_test_block(&mut doc);
let insert = Insert::new(BLOCK_NAME, Vector3::new(0.0, 0.0, 0.0));
let pieces = explode_entity(&EntityType::Insert(insert), &doc);
assert_eq!(
pieces.len(),
6,
"insert must materialize all 6 block members"
);
assert!(pieces.iter().all(|p| matches!(p, EntityType::Line(_))));
}
#[test]
fn explode_mline_golden_three_lines_per_segment() {
let doc = CadDocument::new();
let ml = MLine::from_points(&[
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(4.0, 0.0, 0.0),
Vector3::new(4.0, 4.0, 0.0),
Vector3::new(8.0, 4.0, 0.0),
]);
let pieces = explode_entity(&EntityType::MLine(ml), &doc);
assert_eq!(pieces.len(), 9, "3 segments × (spine + 2 offsets)");
assert!(pieces.iter().all(|p| matches!(p, EntityType::Line(_))));
}
#[test]
fn explode_dimension_linear_golden_segments() {
let doc = CadDocument::new();
let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0));
d.definition_point = Vector3::new(5.0, 3.0, 0.0);
d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0);
let pieces = explode_entity(&EntityType::Dimension(Box::new(Dimension::Linear(d))), &doc);
// A baked linear dimension explodes to exactly 2 extension lines + 1
// dimension line. The arrowheads bake as SOLIDs (see the unit test
// `linear_dim_bakes_filled_arrowheads`), which is why the Lines count is
// 3 — the plan's pre-execution "≥4 Lines" research claim was wrong.
assert_eq!(
pieces
.iter()
.filter(|p| matches!(p, EntityType::Line(_)))
.count(),
3,
"linear dim must explode to extension + dimension lines, got {pieces:?}"
);
assert!(
pieces
.iter()
.filter(|p| matches!(p, EntityType::Solid(_)))
.count()
>= 2,
"linear dim must bake filled arrowheads (SOLIDs), got {pieces:?}"
);
// The measurement label is baked as a Text entity when the style path
// yields one. If this assertion fails on an empty document, the text
// entity legitimately was skipped — downgrade to the Lines assertion
// above rather than inventing a style fixture.
assert!(
pieces
.iter()
.any(|p| matches!(p, EntityType::Text(_) | EntityType::MText(_))),
"dimension label must be baked into a text entity, got {pieces:?}"
);
}
#[test]
fn centermark_associated_line_explodes_without_leaking_association() {
let mut doc = CadDocument::new();
let source = doc.allocate_handle();
let mut line = Line::new();
line.start = Vector3::new(0.0, 0.0, 0.0);
line.end = Vector3::new(10.0, 0.0, 0.0);
let assoc = CenterMarkAssociation {
source: CenterMarkSource {
handle: source,
kind: CenterMarkSourceKind::Circle,
segment_index: 0,
pick_point: Vector3::new(5.0, 0.0, 0.0),
},
plane_origin: Vector3::new(0.0, 0.0, 0.0),
plane_x: Vector3::new(1.0, 0.0, 0.0),
plane_y: Vector3::new(0.0, 1.0, 0.0),
center: Vector3::new(5.0, 0.0, 0.0),
radius: 2.0,
cross_size: 1.0,
cross_gap: 0.1,
cross_size_relative: false,
cross_gap_relative: false,
extension_length: 0.0,
length_adjustments: [0.0; 4],
overshoots: [0.0; 4],
show_extensions: false,
associated: true,
};
assoc.write(&mut line.common.extended_data);
assert!(CenterMarkAssociation::read(&line.common.extended_data).is_some());
let pieces = explode_entity(&EntityType::Line(line), &doc);
assert!(!pieces.is_empty(), "centermark-attached line must explode");
for piece in &pieces {
let common = piece.common();
assert!(
CenterMarkAssociation::read(&common.extended_data).is_none(),
"association must not leak onto exploded pieces"
);
}
}
// ── Geometry invariants ───────────────────────────────────────────────────
#[test]
fn lwpolyline_explode_preserves_length_and_closed_counts() {
let doc = CadDocument::new();
let mut pl = LwPolyline::new();
pl.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(3.0, 0.0)),
LwVertex::new(Vector2::new(3.0, 4.0)),
];
let pieces = explode_entity(&EntityType::LwPolyline(pl), &doc);
assert_eq!(pieces.len(), 2);
let total: f64 = pieces
.iter()
.map(|p| match p {
EntityType::Line(l) => (l.end - l.start).length(),
_ => 0.0,
})
.sum();
assert!(
(total - 7.0).abs() < 1e-9,
"total length must be preserved, got {total}"
);
let mut sq = LwPolyline::new();
sq.is_closed = true;
sq.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(6.0, 0.0)),
LwVertex::new(Vector2::new(6.0, 6.0)),
LwVertex::new(Vector2::new(0.0, 6.0)),
];
let pieces = explode_entity(&EntityType::LwPolyline(sq), &doc);
assert_eq!(pieces.len(), 4, "closed square explodes to 4 segments");
}
#[test]
fn plan_explode_drops_empty_and_keeps_order() {
let mut scene = Scene::new();
let mut line = Line::new(); // plain line → explodes to nothing
line.start = Vector3::new(0.0, 0.0, 0.0);
line.end = Vector3::new(1.0, 0.0, 0.0);
let survivor = scene.add_entity(EntityType::Line(line));
let mut pl = LwPolyline::new();
pl.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(5.0, 0.0)),
];
let poly = scene.add_entity(EntityType::LwPolyline(pl));
// Select in the order: survivor first, polyline second.
scene.select_entity(survivor, false);
scene.select_entity(poly, false);
let selected: Vec<(Handle, &EntityType)> = scene
.selected_entities()
.into_iter()
.filter(|(h, _)| !scene.is_layer_locked(*h))
.collect();
let replacements = plan_explode(&selected, &scene.document);
assert_eq!(replacements.len(), 1, "empty pieces must be dropped");
assert_eq!(replacements[0].0, poly, "order/identity preserved");
assert!(!replacements[0].1.is_empty());
}
// ── Differential: legacy loop vs helper pipeline ──────────────────────────
/// Frozen reference implementation of the pre-refactor EXPLODE command arm.
fn legacy_apply(scene: &mut Scene, replacements: &[(Handle, Vec<EntityType>)]) {
for (handle, pieces) in replacements {
scene.erase_entities(&[*handle]);
for piece in pieces {
scene.add_entity(piece.clone());
}
}
}
fn document_state(scene: &Scene) -> Vec<(Handle, EntityType)> {
let mut state: Vec<(Handle, EntityType)> = scene
.document
.entities()
.map(|e| (e.common().handle, e.clone()))
.collect();
state.sort_by_key(|(h, _)| *h);
state
}
fn selected_handles(scene: &Scene) -> Vec<Handle> {
scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect()
}
#[derive(Clone, Copy)]
struct FixtureHandles {
poly: Handle,
mline: Handle,
insert: Handle,
survivor: Handle,
leader1: Handle,
leader2: Handle,
}
/// Deterministic identical build used for both sides of the differential:
/// explodable polyline + mline + insert, a non-explodable survivor line that
/// must survive, and two leaders attached to the polyline that must be
/// erased via annotation coupling even though they are never planned.
/// The planned replacement order poly (2 pieces) → mline (9) → insert (6)
/// is deliberate: it is what makes a mis-ordered flattened add in
/// `apply_explode_replacements` fail `document_state` below instead of
/// coincidentally passing.
fn build_differential_scene() -> (Scene, FixtureHandles) {
let mut scene = Scene::new();
add_test_block(&mut scene.document);
let mut pl = LwPolyline::new();
pl.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(10.0, 0.0)),
LwVertex::new(Vector2::new(10.0, 5.0)),
];
let poly = scene.add_entity(EntityType::LwPolyline(pl));
let mline = scene.add_entity(EntityType::MLine(MLine::from_points(&[
Vector3::new(20.0, 0.0, 0.0),
Vector3::new(25.0, 0.0, 0.0),
Vector3::new(25.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
])));
let insert = scene.add_entity(EntityType::Insert(Insert::new(
BLOCK_NAME,
Vector3::new(0.0, 20.0, 0.0),
)));
let mut line = Line::new();
line.start = Vector3::new(0.0, 50.0, 0.0);
line.end = Vector3::new(10.0, 50.0, 0.0);
let survivor = scene.add_entity(EntityType::Line(line));
let mut l1 = Leader::new();
l1.vertices = vec![Vector3::new(1.0, 1.0, 0.0), Vector3::new(2.0, 2.0, 0.0)];
l1.annotation_handle = poly;
let leader1 = scene.add_entity(EntityType::Leader(l1));
let mut l2 = Leader::new();
l2.vertices = vec![Vector3::new(3.0, 3.0, 0.0), Vector3::new(4.0, 4.0, 0.0)];
l2.annotation_handle = poly;
let leader2 = scene.add_entity(EntityType::Leader(l2));
// User-style selection: survivor + all three explodable sources (the
// two leaders ride along through annotation expansion when poly is
// selected; they are never planned, only coupling-erased).
scene.select_entity(survivor, false);
scene.select_entity(poly, false);
scene.select_entity(mline, false);
scene.select_entity(insert, false);
(
scene,
FixtureHandles {
poly,
mline,
insert,
survivor,
leader1,
leader2,
},
)
}
fn plan_from_selection(scene: &Scene) -> Vec<(Handle, Vec<EntityType>)> {
let selected: Vec<(Handle, &EntityType)> = scene
.selected_entities()
.into_iter()
.filter(|(h, _)| !scene.is_layer_locked(*h))
.collect();
plan_explode(&selected, &scene.document)
}
#[test]
fn apply_explode_replacements_matches_legacy_loop() {
let (mut scene_legacy, handles_prior) = build_differential_scene();
let (mut scene_batched, handles) = build_differential_scene();
// Both builds must start from identical handle sequences: allocation is
// a per-document monotonic counter (CadDocument::allocate_handle), never
// global/atomic — these assertions make that assumption explicit so a
// future allocator change fails here, not as an opaque state mismatch.
assert_eq!(handles_prior.poly, handles.poly);
assert_eq!(handles_prior.survivor, handles.survivor);
let replacements = plan_from_selection(&scene_legacy);
assert!(
replacements.len() >= 3,
"poly, mline, insert must be planned"
);
assert_eq!(
replacements.iter().map(|(h, _)| *h).collect::<Vec<_>>(),
vec![handles.poly, handles.mline, handles.insert],
"planning must preserve selection order poly → mline → insert — this \
multi-source ordering is the guard that a mis-ordered flattened add \
fails the document_state comparison below"
);
let for_legacy = replacements.clone();
legacy_apply(&mut scene_legacy, &for_legacy);
apply_explode_replacements(&mut scene_batched, replacements);
assert_eq!(
document_state(&scene_legacy),
document_state(&scene_batched),
"document state (handles + entities) must be identical"
);
assert_eq!(
selected_handles(&scene_legacy),
selected_handles(&scene_batched),
"selection state must be identical"
);
for scene in [&scene_legacy, &scene_batched] {
assert!(
scene.document.get_entity(handles.poly).is_none(),
"poly erased"
);
assert!(
scene.document.get_entity(handles.mline).is_none(),
"mline erased"
);
assert!(
scene.document.get_entity(handles.insert).is_none(),
"insert erased"
);
assert!(
scene.document.get_entity(handles.leader1).is_none(),
"leader coupling L1"
);
assert!(
scene.document.get_entity(handles.leader2).is_none(),
"leader coupling L2"
);
assert!(
scene.document.get_entity(handles.survivor).is_some(),
"non-explodable survivor must survive"
);
assert!(
selected_handles(scene).contains(&handles.survivor),
"survivor must stay selected"
);
}
// explode_batch keeps empty entries so both sides see the same inputs.
let items: Vec<(Handle, &EntityType)> = vec![(
handles.survivor,
scene_batched.document.get_entity(handles.survivor).unwrap(),
)];
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<KCurve> = (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");
// A placed region carries a body transform record.
// 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);
}