#![allow(clippy::unwrap_used)]
use cadmpeg_ir::examples::unit_cube;
use cadmpeg_ir::geometry::{
Curve, CurveGeometry, NurbsCurve, NurbsSurface, Surface, SurfaceGeometry,
};
use cadmpeg_ir::ids::{CurveId, ProceduralCurveId, SurfaceId};
use cadmpeg_ir::math::{Point3, Vector3};
use cadmpeg_ir::units::{LengthUnit, Units};
use cadmpeg_ir::CadIr;
use crate::{write_step, StepWriteOptions};
fn export(ir: &CadIr) -> String {
let mut buf = Vec::new();
write_step(ir, &mut buf, &StepWriteOptions::default()).expect("write");
String::from_utf8(buf).expect("utf8")
}
fn emit_surface_only(g: &SurfaceGeometry) -> String {
let mut e = crate::writer::Emitter::new();
crate::geometry::surface(&mut e, g);
e.into_lines().join("\n")
}
fn emit_curve_only(g: &CurveGeometry) -> String {
let mut e = crate::writer::Emitter::new();
crate::geometry::curve(&mut e, g);
e.into_lines().join("\n")
}
fn edgeless_doc() -> CadIr {
use cadmpeg_ir::ids::{
BodyId, CoedgeId, EdgeId, FaceId, LoopId, PointId, RegionId, ShellId, SurfaceId, VertexId,
};
use cadmpeg_ir::topology::{
Body, Coedge, Edge, Face, Loop, Point, Region, Sense, Shell, Vertex,
};
let mut ir = CadIr::empty(Units::default());
ir.model.points.push(Point {
id: PointId("p0".into()),
position: Point3::new(0.0, 0.0, 0.0),
});
ir.model.points.push(Point {
id: PointId("p1".into()),
position: Point3::new(1.0, 0.0, 0.0),
});
ir.model.vertices.push(Vertex {
id: VertexId("v0".into()),
point: PointId("p0".into()),
tolerance: None,
});
ir.model.vertices.push(Vertex {
id: VertexId("v1".into()),
point: PointId("p1".into()),
tolerance: None,
});
ir.model.edges.push(Edge {
id: EdgeId("e0".into()),
curve: None,
start: VertexId("v0".into()),
end: VertexId("v1".into()),
param_range: None,
tolerance: None,
});
ir.model.surfaces.push(Surface {
id: SurfaceId("s0".into()),
geometry: SurfaceGeometry::Plane {
origin: Point3::new(0.0, 0.0, 0.0),
normal: Vector3::new(0.0, 0.0, 1.0),
u_axis: Vector3::new(1.0, 0.0, 0.0),
},
source_object: None,
});
ir.model.coedges.push(Coedge {
id: CoedgeId("ce0".into()),
owner_loop: LoopId("lp0".into()),
edge: EdgeId("e0".into()),
next: CoedgeId("ce0".into()),
previous: CoedgeId("ce0".into()),
radial_next: CoedgeId("ce0".into()),
sense: Sense::Forward,
pcurve: None,
});
ir.model.loops.push(Loop {
id: LoopId("lp0".into()),
face: FaceId("f0".into()),
coedges: vec![CoedgeId("ce0".into())],
});
ir.model.faces.push(Face {
id: FaceId("f0".into()),
shell: ShellId("sh0".into()),
surface: SurfaceId("s0".into()),
sense: Sense::Forward,
loops: vec![LoopId("lp0".into())],
name: None,
color: None,
tolerance: None,
});
ir.model.shells.push(Shell {
id: ShellId("sh0".into()),
region: RegionId("l0".into()),
faces: vec![FaceId("f0".into())],
wire_edges: Vec::new(),
free_vertices: Vec::new(),
});
ir.model.regions.push(Region {
id: RegionId("l0".into()),
body: BodyId("b0".into()),
shells: vec![ShellId("sh0".into())],
});
ir.model.bodies.push(Body {
id: BodyId("b0".into()),
kind: cadmpeg_ir::topology::BodyKind::Solid,
regions: vec![RegionId("l0".into())],
transform: None,
name: None,
color: None,
visible: None,
});
ir
}
#[test]
fn cube_has_valid_part21_envelope() {
let s = export(&unit_cube());
assert!(s.starts_with("ISO-10303-21;\n"));
assert!(s.contains("HEADER;"));
assert!(s.contains("FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));"));
assert!(s.contains("\nDATA;\n"));
assert!(s.trim_end().ends_with("END-ISO-10303-21;"));
assert_eq!(s.matches("ENDSEC;").count(), 2);
}
#[test]
fn cube_emits_full_brep_hierarchy() {
let s = export(&unit_cube());
assert!(s.contains("MANIFOLD_SOLID_BREP"));
assert!(s.contains("CLOSED_SHELL"));
assert_eq!(s.matches("ADVANCED_FACE").count(), 6);
assert_eq!(s.matches("= PLANE(").count(), 6);
assert_eq!(s.matches("EDGE_CURVE").count(), 12);
assert_eq!(s.matches("VERTEX_POINT").count(), 8);
assert_eq!(s.matches("ORIENTED_EDGE").count(), 24);
assert_eq!(s.matches("= EDGE_LOOP(").count(), 6);
assert_eq!(s.matches("FACE_OUTER_BOUND").count(), 6);
assert_eq!(s.matches("= LINE(").count(), 12);
}
#[test]
fn cube_product_and_context_boilerplate_present() {
let s = export(&unit_cube());
for kw in [
"APPLICATION_CONTEXT",
"APPLICATION_PROTOCOL_DEFINITION",
"PRODUCT(",
"PRODUCT_DEFINITION(",
"PRODUCT_DEFINITION_SHAPE",
"SHAPE_DEFINITION_REPRESENTATION",
"ADVANCED_BREP_SHAPE_REPRESENTATION",
"GEOMETRIC_REPRESENTATION_CONTEXT",
"UNCERTAINTY_MEASURE_WITH_UNIT",
] {
assert!(s.contains(kw), "missing {kw}");
}
assert!(s.contains("SI_UNIT(.MILLI.,.METRE.)"));
}
#[test]
fn every_reference_resolves() {
let s = export(&unit_cube());
let mut declared = std::collections::HashSet::new();
for line in s.lines() {
if let Some(rest) = line.strip_prefix('#') {
if let Some(eq) = rest.find(" =") {
if let Ok(id) = rest[..eq].parse::<u64>() {
declared.insert(id);
}
}
}
}
assert!(!declared.is_empty());
for line in s.lines() {
let Some(eq) = line.find('=') else { continue };
let body = &line[eq + 1..];
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'#' {
let start = i + 1;
let mut j = start;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > start {
let id: u64 = body[start..j].parse().unwrap();
assert!(
declared.contains(&id),
"dangling reference #{id} in: {line}"
);
}
i = j;
} else {
i += 1;
}
}
}
}
#[test]
fn reports_entity_counts_and_no_geometry_loss_for_cube() {
let mut buf = Vec::new();
let report = write_step(&unit_cube(), &mut buf, &StepWriteOptions::default()).unwrap();
assert_eq!(report.total_entities, buf_line_count(&buf));
assert_eq!(report.entity_counts.get("ADVANCED_FACE"), Some(&6));
assert_eq!(report.entity_counts.get("VERTEX_POINT"), Some(&8));
assert_eq!(report.error_count(), 0);
}
fn buf_line_count(buf: &[u8]) -> usize {
String::from_utf8_lossy(buf)
.lines()
.filter(|l| l.starts_with('#'))
.count()
}
fn cylinder_surface_doc() -> CadIr {
let mut ir = CadIr::empty(Units::default());
ir.model.surfaces.push(Surface {
id: SurfaceId("cyl".into()),
geometry: SurfaceGeometry::Cylinder {
origin: Point3::new(0.0, 0.0, 0.0),
axis: Vector3::new(0.0, 0.0, 1.0),
ref_direction: Vector3::new(1.0, 0.0, 0.0),
radius: 5.0,
},
source_object: None,
});
ir
}
#[test]
fn analytic_surfaces_map_to_their_step_entities() {
let cases: Vec<(SurfaceGeometry, &str)> = vec![
(
SurfaceGeometry::Cylinder {
origin: Point3::new(0.0, 0.0, 0.0),
axis: Vector3::new(0.0, 0.0, 1.0),
ref_direction: Vector3::new(1.0, 0.0, 0.0),
radius: 5.0,
},
"CYLINDRICAL_SURFACE",
),
(
SurfaceGeometry::Cone {
origin: Point3::new(0.0, 0.0, 0.0),
axis: Vector3::new(0.0, 0.0, 1.0),
ref_direction: Vector3::new(1.0, 0.0, 0.0),
radius: 2.0,
half_angle: 0.5,
},
"CONICAL_SURFACE",
),
(
SurfaceGeometry::Sphere {
center: Point3::new(1.0, 2.0, 3.0),
axis: Vector3::new(0.0, 0.0, 1.0),
ref_direction: Vector3::new(1.0, 0.0, 0.0),
radius: 4.0,
},
"SPHERICAL_SURFACE",
),
(
SurfaceGeometry::Torus {
center: Point3::new(0.0, 0.0, 0.0),
axis: Vector3::new(0.0, 0.0, 1.0),
ref_direction: Vector3::new(1.0, 0.0, 0.0),
major_radius: 3.0,
minor_radius: 1.0,
},
"TOROIDAL_SURFACE",
),
];
for (geom, kw) in cases {
let mut ir = CadIr::empty(Units::default());
ir.model.surfaces.push(Surface {
id: SurfaceId("s".into()),
geometry: geom,
source_object: None,
});
let s = emit_surface_only(&ir.model.surfaces[0].geometry);
assert!(s.contains(kw), "missing {kw} in {s}");
}
}
#[test]
fn analytic_surface_placements_preserve_orientation() {
let geometry = SurfaceGeometry::Sphere {
center: Point3::new(1.0, 2.0, 3.0),
axis: Vector3::new(0.0, 1.0, 0.0),
ref_direction: Vector3::new(0.0, 0.0, 1.0),
radius: 4.0,
};
let s = emit_surface_only(&geometry);
assert!(s.contains("DIRECTION('',(0.,1.,0.))"));
assert!(s.contains("DIRECTION('',(0.,0.,1.))"));
}
#[test]
fn parabola_and_hyperbola_map_to_step_conics() {
let parabola = emit_curve_only(&CurveGeometry::Parabola {
vertex: Point3::new(1.0, 2.0, 3.0),
axis: Vector3::new(0.0, 0.0, 1.0),
major_direction: Vector3::new(0.0, 1.0, 0.0),
focal_distance: 2.5,
});
assert!(parabola.contains("= PARABOLA("));
assert!(parabola.contains(",2.5)"));
let hyperbola = emit_curve_only(&CurveGeometry::Hyperbola {
center: Point3::new(1.0, 2.0, 3.0),
axis: Vector3::new(0.0, 0.0, 1.0),
major_direction: Vector3::new(0.0, 1.0, 0.0),
major_radius: 4.0,
minor_radius: 1.5,
});
assert!(hyperbola.contains("= HYPERBOLA("));
assert!(hyperbola.contains(",4.,1.5)"));
}
#[test]
fn nurbs_curve_non_rational_uses_with_knots() {
let n = NurbsCurve {
degree: 2,
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
control_points: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(2.0, 0.0, 0.0),
],
weights: None,
periodic: false,
};
let s = emit_curve_only(&CurveGeometry::Nurbs(n));
assert!(s.contains("B_SPLINE_CURVE_WITH_KNOTS"));
assert!(s.contains("(3,3)"), "knot multiplicities: {s}");
assert!(!s.contains("RATIONAL"));
}
#[test]
fn nurbs_curve_rational_uses_complex_form() {
let n = NurbsCurve {
degree: 2,
knots: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
control_points: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
Point3::new(2.0, 0.0, 0.0),
],
weights: Some(vec![1.0, 0.5, 1.0]),
periodic: false,
};
let s = emit_curve_only(&CurveGeometry::Nurbs(n));
assert!(s.contains("RATIONAL_B_SPLINE_CURVE"));
assert!(s.contains("BOUNDED_CURVE()"));
}
#[test]
fn nurbs_surface_grid_orientation_is_u_major() {
let n = NurbsSurface {
u_degree: 1,
v_degree: 1,
u_knots: vec![0.0, 0.0, 1.0, 1.0],
v_knots: vec![0.0, 0.0, 1.0, 1.0],
u_count: 2,
v_count: 2,
control_points: vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(0.0, 1.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(1.0, 1.0, 0.0),
],
weights: None,
u_periodic: false,
v_periodic: false,
};
let s = emit_surface_only(&SurfaceGeometry::Nurbs(n));
assert!(s.contains("B_SPLINE_SURFACE_WITH_KNOTS"));
}
#[test]
fn v1_document_uses_canonical_millimeter_unit() {
let ir = unit_cube();
assert_eq!(ir.units.length, LengthUnit::Millimeter);
let s = export(&ir);
assert!(s.contains("SI_UNIT(.MILLI.,.METRE.)"));
assert!(!s.contains("CONVERSION_BASED_UNIT"));
}
#[test]
fn real_formatting_always_has_decimal_point() {
let s = export(&unit_cube());
assert!(s.contains("10.")); assert!(!s.contains("(10,")); }
#[test]
fn edge_without_curve_is_reported_and_omitted() {
let _ = cylinder_surface_doc(); let ir = edgeless_doc();
let mut buf = Vec::new();
let report = write_step(&ir, &mut buf, &StepWriteOptions::default()).unwrap();
let curve = Curve {
id: CurveId("unused".into()),
geometry: CurveGeometry::Line {
origin: Point3::new(0.0, 0.0, 0.0),
direction: Vector3::new(1.0, 0.0, 0.0),
},
source_object: None,
};
let _ = curve; assert!(report
.losses
.iter()
.any(|l| l.message.contains("edge(s) have no typed 3D curve")));
}
#[test]
fn subds_tessellations_and_source_associations_are_reported_as_losses() {
let source_object = cadmpeg_ir::SourceObjectAssociation {
format: "test".into(),
object_id: "object-0".into(),
name: None,
color: None,
visible: None,
layer: None,
instance_path: Vec::new(),
};
let mut ir = unit_cube();
ir.model.subds.push(cadmpeg_ir::SubdSurface {
id: cadmpeg_ir::ids::SubdId("test:step:subd#0".into()),
scheme: cadmpeg_ir::SubdScheme::CatmullClark,
vertices: Vec::new(),
edges: Vec::new(),
faces: Vec::new(),
source_object: Some(source_object.clone()),
});
ir.model
.tessellations
.push(cadmpeg_ir::tessellation::Tessellation {
id: "test:step:tessellation#0".into(),
source_object: Some(source_object),
vertices: Vec::new(),
triangles: Vec::new(),
strip_lengths: Vec::new(),
normals: Vec::new(),
channels: Vec::new(),
});
let report = write_step(&ir, &mut Vec::new(), &StepWriteOptions::default()).unwrap();
assert!(report.losses.iter().any(|loss| {
loss.category == cadmpeg_ir::LossCategory::Geometry
&& loss.severity == cadmpeg_ir::Severity::Warning
&& loss
.message
.contains("1 subdivision surface(s) were omitted")
}));
assert!(report.losses.iter().any(|loss| {
loss.category == cadmpeg_ir::LossCategory::Geometry
&& loss.severity == cadmpeg_ir::Severity::Warning
&& loss.message.contains("1 tessellation(s) were omitted")
}));
assert!(report.losses.iter().any(|loss| {
loss.category == cadmpeg_ir::LossCategory::Metadata
&& loss
.message
.contains("2 source-object association(s) were not represented")
}));
}
#[test]
fn face_on_unknown_surface_is_skipped_and_reported() {
let mut ir = unit_cube();
let target = ir.model.faces[0].surface.0.clone();
for s in &mut ir.model.surfaces {
if s.id.0 == target {
s.geometry = SurfaceGeometry::Unknown { record: None };
}
}
let mut buf = Vec::new();
let report = write_step(&ir, &mut buf, &StepWriteOptions::default()).unwrap();
let s = String::from_utf8(buf).unwrap();
assert_eq!(
s.matches("ADVANCED_FACE").count(),
5,
"the unknown-surface face should be omitted"
);
let unknown_notes: Vec<_> = report
.losses
.iter()
.filter(|l| l.message.contains("rest on an unknown"))
.collect();
assert_eq!(
unknown_notes.len(),
1,
"loss must be aggregated into a single counted note, got: {:?}",
report.losses
);
assert!(unknown_notes[0].message.contains("1 face(s)"));
}
#[test]
fn signed_analytic_radius_normalization_is_reported() {
let mut ir = unit_cube();
ir.model.surfaces[0].geometry = SurfaceGeometry::Sphere {
center: Point3::new(0.0, 0.0, 0.0),
axis: Vector3::new(0.0, 0.0, 1.0),
ref_direction: Vector3::new(1.0, 0.0, 0.0),
radius: -2.0,
};
let mut buf = Vec::new();
let report = write_step(&ir, &mut buf, &StepWriteOptions::default()).unwrap();
assert!(report.losses.iter().any(|loss| {
loss.category == cadmpeg_ir::LossCategory::Geometry
&& loss.message.contains("normalized to positive STEP radii")
}));
}
#[test]
fn procedural_construction_reduction_is_reported() {
let mut ir = unit_cube();
ir.model
.procedural_curves
.push(cadmpeg_ir::geometry::ProceduralCurve {
id: ProceduralCurveId("generated_int_cur".into()),
curve: ir.model.curves[0].id.clone(),
definition: cadmpeg_ir::geometry::ProceduralCurveDefinition::Intersection {
context: cadmpeg_ir::geometry::IntcurveSupportContext {
sides: std::array::from_fn(|_| cadmpeg_ir::geometry::IntcurveSupportSide {
surface: None,
pcurve: None,
}),
parameter_range: [0.0, 1.0],
discontinuities: std::array::from_fn(|_| Vec::new()),
},
},
cache_fit_tolerance: Some(0.01),
});
let mut buf = Vec::new();
let report = write_step(&ir, &mut buf, &StepWriteOptions::default()).unwrap();
assert!(report.losses.iter().any(|loss| loss
.message
.contains("reduced to their solved STEP carriers")));
}
#[test]
fn parametric_history_reduction_is_reported() {
let mut ir = unit_cube();
ir.native.namespace_mut("f3d").arenas.insert(
"asm_histories".into(),
vec![cadmpeg_ir::NativeRecord {
id: "asm-history-0".into(),
fields: Default::default(),
}],
);
ir.finalize();
let mut buf = Vec::new();
let report = write_step(&ir, &mut buf, &StepWriteOptions::default()).unwrap();
assert!(report.losses.iter().any(|loss| loss
.message
.contains("parametric design/history record(s) were not represented in STEP")));
}
#[test]
fn hidden_body_is_omitted_and_reported() {
let mut ir = unit_cube();
ir.model.bodies[0].visible = Some(false);
let mut buf = Vec::new();
let report = write_step(&ir, &mut buf, &StepWriteOptions::default()).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("MANIFOLD_SOLID_BREP"));
assert!(!s.contains("ADVANCED_FACE"));
assert!(report
.losses
.iter()
.any(|l| l.message.contains("hidden body(ies) were omitted")));
let mut ir = unit_cube();
ir.model.bodies[0].visible = Some(true);
let s = export(&ir);
assert!(s.contains("MANIFOLD_SOLID_BREP"));
}
#[test]
fn body_color_becomes_styled_item_presentation() {
let mut ir = unit_cube();
ir.model.bodies[0].color = Some(cadmpeg_ir::topology::Color {
r: 0.25,
g: 0.5,
b: 0.75,
a: 1.0,
});
let s = export(&ir);
assert!(s.contains("COLOUR_RGB('',0.25,0.5,0.75)"));
assert!(s.contains("STYLED_ITEM"));
assert!(s.contains("MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION"));
let solid = s
.lines()
.find(|line| line.contains("MANIFOLD_SOLID_BREP"))
.and_then(|line| line.split(" =").next())
.unwrap()
.to_string();
let styled = s.lines().find(|line| line.contains("STYLED_ITEM")).unwrap();
assert!(styled.ends_with(&format!(",{solid});")));
}
#[test]
fn face_appearance_binding_styles_the_advanced_face() {
use cadmpeg_ir::appearance::{Appearance, AppearanceBinding, AppearanceTarget};
use cadmpeg_ir::ids::AppearanceId;
let mut ir = unit_cube();
let face = ir.model.faces[0].id.clone();
ir.model.appearances.push(Appearance {
id: AppearanceId("test:appearance#black".to_string()),
name: None,
asset_guid: None,
visual_guid: None,
physical_token: None,
schema: None,
category: None,
base_color: Some(cadmpeg_ir::topology::Color {
r: 0.125,
g: 0.125,
b: 0.125,
a: 1.0,
}),
properties: Default::default(),
});
ir.model.appearance_bindings.push(AppearanceBinding {
id: "test:appearance-binding#face".to_string(),
target: AppearanceTarget::Face(face),
appearance: AppearanceId("test:appearance#black".to_string()),
source_entity_id: None,
object_type: None,
channels: Default::default(),
});
let s = export(&ir);
assert!(s.contains("COLOUR_RGB('',0.125,0.125,0.125)"));
let styled: Vec<&str> = s.lines().filter(|l| l.contains("STYLED_ITEM")).collect();
assert_eq!(styled.len(), 1);
let target = styled[0]
.rsplit_once(',')
.map(|(_, tail)| tail.trim_end_matches(");").to_string())
.unwrap();
let face_line = s
.lines()
.find(|line| line.starts_with(&format!("{target} = ADVANCED_FACE")));
assert!(face_line.is_some(), "styled item must reference a face");
}