use std::collections::BTreeMap;
use std::io::Cursor;
use std::path::Path;
use cadmpeg_ir::codec::{Codec, DecodeOptions, DecodeResult};
use cadmpeg_ir::pmi::{
DimensionKind, DimensionTolerance, GeometricToleranceKind, PmiAnnotation, PmiDefinition,
};
use cadmpeg_ir::CadIr;
use crate::StepCodec;
const NIST_VALUE_TOLERANCE: f64 = 1.0e-9;
const MM_PER_INCH: f64 = 25.4;
fn decode_specimen(name: &str) -> DecodeResult {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../corpus/nist-pmi/step")
.join(name);
let bytes =
std::fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
StepCodec::default()
.decode(&mut Cursor::new(bytes), &DecodeOptions::default())
.unwrap_or_else(|error| panic!("decode {name}: {error}"))
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Range {
Deviations(f64, f64),
Limits(f64, f64),
}
impl Range {
fn agrees(self, other: Self) -> bool {
match (self, other) {
(Self::Deviations(a, b), Self::Deviations(c, d))
| (Self::Limits(a, b), Self::Limits(c, d)) => values_agree(a, c) && values_agree(b, d),
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
struct Dimension {
kind: DimensionKind,
nominal: Option<f64>,
range: Option<Range>,
}
impl Dimension {
fn new(kind: DimensionKind, nominal: Option<f64>, deviations: Option<(f64, f64)>) -> Self {
Self {
kind,
nominal,
range: deviations.map(|(lower, upper)| Range::Deviations(lower, upper)),
}
}
fn limits(kind: DimensionKind, nominal: Option<f64>, lower: f64, upper: f64) -> Self {
Self {
kind,
nominal,
range: Some(Range::Limits(lower, upper)),
}
}
fn agrees(&self, other: &Self) -> bool {
self.kind == other.kind
&& option_agrees(self.nominal, other.nominal, values_agree)
&& option_agrees(self.range, other.range, Range::agrees)
}
}
#[derive(Debug, Clone, PartialEq)]
struct Tolerance {
kind: GeometricToleranceKind,
magnitude: f64,
datums: Vec<String>,
modifiers: Vec<String>,
}
impl Tolerance {
fn new(kind: GeometricToleranceKind, magnitude: f64, datums: &[&str]) -> Self {
Self {
kind,
magnitude,
datums: datums.iter().map(|datum| (*datum).to_string()).collect(),
modifiers: Vec::new(),
}
}
fn agrees(&self, other: &Self) -> bool {
self.kind == other.kind
&& values_agree(self.magnitude, other.magnitude)
&& self.datums == other.datums
&& self.modifiers == other.modifiers
}
}
fn values_agree(left: f64, right: f64) -> bool {
(left - right).abs() <= NIST_VALUE_TOLERANCE
}
fn option_agrees<T: Copy>(left: Option<T>, right: Option<T>, agree: impl Fn(T, T) -> bool) -> bool {
match (left, right) {
(Some(left), Some(right)) => agree(left, right),
(None, None) => true,
_ => false,
}
}
fn annotation<'a>(ir: &'a CadIr, id: &cadmpeg_ir::ids::PmiId) -> &'a PmiAnnotation {
ir.model
.pmi
.iter()
.find(|annotation| &annotation.id == id)
.unwrap_or_else(|| panic!("annotation {}", id.as_str()))
}
fn dimensions(ir: &CadIr) -> Vec<Dimension> {
ir.model
.pmi
.iter()
.filter_map(|annotation| match &annotation.definition {
PmiDefinition::Dimension(dimension) => Some(Dimension {
kind: dimension.kind().clone(),
nominal: dimension.nominal().map(|value| value.value.get()),
range: dimension.tolerance().map(|tolerance| match tolerance {
DimensionTolerance::PlusMinus { lower, upper } => {
Range::Deviations(lower.value.get(), upper.value.get())
}
DimensionTolerance::Limits { lower, upper } => {
Range::Limits(lower.value.get(), upper.value.get())
}
other => panic!("NIST CTC tolerance form {other:?}"),
}),
}),
_ => None,
})
.collect()
}
fn datum_compartments(ir: &CadIr, system: &cadmpeg_ir::ids::PmiId) -> Vec<String> {
let PmiDefinition::DatumSystem { references } = &annotation(ir, system).definition else {
panic!("{} is a datum system", system.as_str());
};
let mut compartments = BTreeMap::<u32, Vec<String>>::new();
for reference in references.as_slice() {
let PmiDefinition::Datum { identification } = &annotation(ir, &reference.datum).definition
else {
panic!("{} is a datum", reference.datum.as_str());
};
let mut label = identification.clone();
for modifier in &reference.modifiers {
label.push('(');
label.push_str(modifier);
label.push(')');
}
compartments
.entry(reference.precedence.get())
.or_default()
.push(label);
}
compartments
.into_values()
.map(|compartment| compartment.join("-"))
.collect()
}
fn tolerances(ir: &CadIr) -> Vec<Tolerance> {
ir.model
.pmi
.iter()
.filter_map(|annotation| match &annotation.definition {
PmiDefinition::GeometricTolerance {
tolerance,
magnitude,
datum_system,
modifiers,
..
} => Some(Tolerance {
kind: tolerance.clone(),
magnitude: magnitude.get().value.get(),
datums: datum_system
.as_ref()
.map(|system| datum_compartments(ir, system))
.unwrap_or_default(),
modifiers: modifiers.clone(),
}),
_ => None,
})
.collect()
}
fn datums(ir: &CadIr) -> Vec<String> {
let mut datums = ir
.model
.pmi
.iter()
.filter_map(|annotation| match &annotation.definition {
PmiDefinition::Datum { identification } => Some(identification.clone()),
_ => None,
})
.collect::<Vec<_>>();
datums.sort();
datums
}
fn assert_each_present<T: std::fmt::Debug + Clone>(
expected: &[T],
decoded: &[T],
agrees: impl Fn(&T, &T) -> bool,
) -> Vec<T> {
let mut unused = decoded.to_vec();
for item in expected {
let index = unused
.iter()
.position(|candidate| agrees(item, candidate))
.unwrap_or_else(|| {
panic!("NIST expects {item:?}; unmatched decoded items: {unused:#?}")
});
unused.remove(index);
}
unused
}
#[test]
fn nist_ctc_01_semantic_pmi_matches_the_nist_definition() {
use DimensionKind::{Angular, Diameter};
use GeometricToleranceKind::{Flatness, Perpendicularity, Position, SurfaceProfile};
let result = decode_specimen("nist_ctc_01_asme1_ap242-e1.stp");
let ir = result.ir();
assert_eq!(datums(ir), ["A", "B", "C"]);
let expected_dimensions = [
Dimension::new(Diameter, Some(20.0), Some((-0.10, 0.05))),
Dimension::new(Diameter, Some(20.0), Some((-0.05, 0.10))),
Dimension::new(Diameter, Some(25.0), Some((-0.15, 0.15))),
Dimension::limits(Diameter, Some(35.0), 34.8, 35.2),
Dimension::limits(Diameter, Some(35.0), 34.8, 35.2),
Dimension::new(Diameter, Some(35.0), Some((-0.2, 0.0))),
Dimension::new(Diameter, Some(35.0), Some((0.0, 0.2))),
Dimension::new(
Angular,
Some(60.0_f64.to_radians()),
Some(((-0.5_f64).to_radians(), 0.5_f64.to_radians())),
),
Dimension::new(Diameter, None, None),
Dimension::new(Diameter, None, None),
];
let unmatched = assert_each_present(&expected_dimensions, &dimensions(ir), Dimension::agrees);
assert!(
unmatched
.iter()
.all(|dimension| dimension.kind == DimensionKind::Location),
"dimensions outside the NIST definition: {unmatched:#?}"
);
let expected_tolerances = [
Tolerance::new(SurfaceProfile, 1.25, &["A", "B", "C"]),
Tolerance::new(SurfaceProfile, 0.5, &["A"]),
Tolerance::new(Position, 0.75, &["A", "B", "C"]),
Tolerance::new(Position, 0.75, &["A", "B", "C"]),
Tolerance::new(Flatness, 0.2, &[]),
Tolerance::new(Perpendicularity, 1.5, &["A"]),
];
let unmatched = assert_each_present(&expected_tolerances, &tolerances(ir), Tolerance::agrees);
assert!(
unmatched.is_empty(),
"tolerances outside the NIST definition: {unmatched:#?}"
);
}
#[test]
fn nist_ctc_04_semantic_pmi_matches_the_nist_definition() {
use DimensionKind::{Angular, Diameter};
use GeometricToleranceKind::{Position, SurfaceProfile};
let result = decode_specimen("nist_ctc_04_asme1_ap242-e1.stp");
let ir = result.ir();
assert_eq!(datums(ir), ["A", "B", "C", "D", "E", "F", "G", "H"]);
let expected_dimensions = [
Dimension::new(Diameter, Some(10.0), None),
Dimension::new(Diameter, Some(20.0), Some((-0.3, 0.3))),
Dimension::new(
Angular,
Some(90.0_f64.to_radians()),
Some(((-1.0_f64).to_radians(), 1.0_f64.to_radians())),
),
Dimension::new(Diameter, Some(14.0), Some((-0.1, 0.1))),
Dimension::new(Diameter, Some(20.0), Some((-0.2, 0.2))),
Dimension::new(Diameter, Some(6.65), Some((-0.12, 0.12))),
];
assert_each_present(&expected_dimensions, &dimensions(ir), Dimension::agrees);
let expected_tolerances = [
Tolerance::new(SurfaceProfile, 0.5, &["A", "B", "C"]),
Tolerance::new(SurfaceProfile, 2.0, &["D", "G", "H"]),
Tolerance::new(SurfaceProfile, 0.2, &["D"]),
Tolerance::new(Position, 0.35, &["A", "B", "C"]),
Tolerance::new(Position, 0.75, &["A", "B", "C"]),
Tolerance::new(Position, 1.5, &["D", "E", "F"]),
Tolerance::new(Position, 0.3, &["D", "E"]),
];
let unmatched = assert_each_present(&expected_tolerances, &tolerances(ir), Tolerance::agrees);
assert!(
unmatched.is_empty(),
"tolerances outside the NIST definition: {unmatched:#?}"
);
}
#[test]
fn nist_ctc_05_semantic_pmi_matches_the_nist_definition() {
use GeometricToleranceKind::{CircularRunout, Roundness, Straightness, TotalRunout};
let result = decode_specimen("nist_ctc_05_asme1_ap242-e1.stp");
let ir = result.ir();
assert_eq!(datums(ir), ["A", "B", "C", "D"]);
let expected_tolerances = [
Tolerance::new(Straightness, 0.005 * MM_PER_INCH, &[]),
Tolerance::new(TotalRunout, 0.002 * MM_PER_INCH, &["A"]),
Tolerance::new(TotalRunout, 0.015 * MM_PER_INCH, &["B"]),
Tolerance::new(Roundness, 0.002 * MM_PER_INCH, &[]),
Tolerance::new(CircularRunout, 0.035 * MM_PER_INCH, &["A-B"]),
Tolerance::new(CircularRunout, 0.025 * MM_PER_INCH, &["A-B"]),
];
assert_each_present(&expected_tolerances, &tolerances(ir), Tolerance::agrees);
}