#![allow(clippy::default_trait_access)]
use cadmpeg_test_support::edit;
use super::emit::{emit_attributes, emit_edges, CurveSenseRefs};
use super::geometry::{
analytic_procedural_surface, collapsed_edges, edge_pcurve_parameter_ranges, faced_body_kind,
is_asm_stream_delimiter, is_known_record_head, pcurve_ranges_on_domain, point_vector,
rational_four_arc_circle,
};
use super::records::{self, BodyNativeKey};
use super::topology::{shell_faces, shell_wire_roots, subshell_ancestor_shells};
use super::{
decode_with_purpose, id, inherited_attribute_target, AsmBrep, DecodePurpose, Reachable,
};
use crate::ids::IdFormat;
use crate::kernel_header::RefWidth;
use crate::nurbs;
use crate::sab::{Record, Token};
use cadmpeg_ir::attributes::SourceAttribute;
use cadmpeg_ir::geometry::{SolvedSurfaceGeometry, SurfaceGeometry};
use cadmpeg_ir::ids::{EdgeId, FaceId, LoopId, RegionId, ShellId};
use cadmpeg_ir::math::{Point3, Vector3};
use cadmpeg_ir::topology::{Loop, Shell};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
const FORMAT: IdFormat = crate::asm_format!("f3d");
fn exact_circle_directrix() -> cadmpeg_ir::geometry::nurbs::NurbsCurve {
let center = Point3::new(2.0, 3.0, 4.0);
let point = |x, y| Point3::new(center.x + x, center.y + y, center.z);
cadmpeg_ir::geometry::nurbs::NurbsCurve::from_lanes(
2,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0],
vec![
point(5.0, 0.0),
point(5.0, 5.0),
point(0.0, 5.0),
point(-5.0, 5.0),
point(-5.0, 0.0),
point(-5.0, -5.0),
point(0.0, -5.0),
point(5.0, -5.0),
point(5.0, 0.0),
],
Some(vec![
1.0,
std::f64::consts::FRAC_1_SQRT_2,
1.0,
std::f64::consts::FRAC_1_SQRT_2,
1.0,
std::f64::consts::FRAC_1_SQRT_2,
1.0,
std::f64::consts::FRAC_1_SQRT_2,
1.0,
]),
false,
)
.unwrap()
}
#[test]
fn exact_circle_extrusion_reduces_to_cylinder_only_along_normal() {
let definition =
|direction| nurbs::proc_surface::DecodedProceduralSurfaceDefinition::Extrusion {
directrix: exact_circle_directrix(),
parameter_interval: [0.0, 4.0],
direction,
native_position: Point3::new(0.0, 0.0, 0.0),
revision_form: None,
};
let Some(SurfaceGeometry::Solved(SolvedSurfaceGeometry::Cylinder(cylinder_surface))) =
analytic_procedural_surface(&definition(Vector3::new(0.0, 0.0, -8.0)))
else {
panic!("exact circle extrusion did not reduce")
};
let origin = cylinder_surface.origin();
let axis = cylinder_surface.frame().axis().as_raw();
let ref_direction = cylinder_surface.frame().reference().as_raw();
let radius = cylinder_surface.radius().get();
assert!(point_vector(Point3::new(2.0, 3.0, 4.0), origin.get()).norm() < 1.0e-12);
assert_eq!(*axis, Vector3::new(0.0, 0.0, -1.0));
assert!((ref_direction.x - 1.0).abs() < 1.0e-12);
assert!(ref_direction.y.abs() < 1.0e-12);
assert!(ref_direction.z.abs() < 1.0e-12);
assert!((radius - 5.0).abs() < 1.0e-12);
assert!(analytic_procedural_surface(&definition(Vector3::new(1.0, 0.0, 8.0))).is_none());
let mut approximate = exact_circle_directrix();
approximate
.try_map_control_points(|index, point| {
let mut point = point.get();
if index == 3 {
point.x += 1.0e-5;
}
cadmpeg_ir::features::FinitePoint3::new(point).ok_or_else(|| {
cadmpeg_ir::geometry::nurbs::NurbsError::Structure(
"control_points contains a non-finite point".into(),
)
})
})
.unwrap();
assert!(rational_four_arc_circle(&approximate).is_none());
}
fn degree_elevated_circle() -> cadmpeg_ir::geometry::nurbs::NurbsCurve {
let quadratic = exact_circle_directrix();
let weights = quadratic.weights().unwrap();
let homogeneous = |index: usize| {
let point = quadratic.control_points()[index];
let weight = weights[index].get() * 7.0;
[point.x * weight, point.y * weight, point.z * weight, weight]
};
let combine = |first: [f64; 4], first_scale: f64, second: [f64; 4], second_scale: f64| {
std::array::from_fn(|coordinate| {
first_scale * first[coordinate] + second_scale * second[coordinate]
})
};
let mut elevated = Vec::new();
for span in 0..4 {
let [first, middle, last] = [
homogeneous(span * 2),
homogeneous(span * 2 + 1),
homogeneous(span * 2 + 2),
];
let span = [
first,
combine(first, 1.0 / 3.0, middle, 2.0 / 3.0),
combine(middle, 2.0 / 3.0, last, 1.0 / 3.0),
last,
];
elevated.extend_from_slice(if elevated.is_empty() {
&span
} else {
&span[1..]
});
}
let (control_points, weights): (Vec<_>, Vec<_>) = elevated
.into_iter()
.map(|point| {
(
Point3::new(
point[0] / point[3],
point[1] / point[3],
point[2] / point[3],
),
point[3],
)
})
.unzip();
cadmpeg_ir::geometry::nurbs::NurbsCurve::from_lanes(
3,
vec![
0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0,
],
control_points,
Some(weights),
false,
)
.unwrap()
}
#[test]
fn exact_circle_recognition_is_projective_and_degree_invariant() {
let mut scaled = exact_circle_directrix();
let scaled_weights = scaled.weights().map(|weights| {
weights
.into_iter()
.map(|weight| weight.get() * 7.0)
.collect()
});
{
let replacement = cadmpeg_ir::geometry::nurbs::NurbsPoles3::from_lanes(
scaled.pole_rows().raw_points(),
scaled_weights,
)
.expect("scaled weights are finite and non-zero");
edit::replace(&mut scaled, |previous| {
cadmpeg_ir::geometry::nurbs::NurbsCurve::new(
previous.degree(),
previous.knots().to_vec(),
replacement,
previous.periodic(),
)
})
}
.unwrap();
assert!(rational_four_arc_circle(&scaled).is_some());
let mut elevated = degree_elevated_circle();
assert!(rational_four_arc_circle(&elevated).is_some());
assert!(matches!(
analytic_procedural_surface(
&nurbs::proc_surface::DecodedProceduralSurfaceDefinition::Extrusion {
directrix: elevated.clone(),
parameter_interval: [0.0, 4.0],
direction: Vector3::new(0.0, 0.0, 3.0),
native_position: Point3::new(0.0, 0.0, 0.0),
revision_form: None,
}
),
Some(SurfaceGeometry::Solved(SolvedSurfaceGeometry::Cylinder(_)))
));
elevated
.try_map_control_points(|index, point| {
let mut point = point.get();
if index == 5 {
point.x += 1.0e-5;
}
cadmpeg_ir::features::FinitePoint3::new(point).ok_or_else(|| {
cadmpeg_ir::geometry::nurbs::NurbsError::Structure(
"control_points contains a non-finite point".into(),
)
})
})
.unwrap();
assert!(rational_four_arc_circle(&elevated).is_none());
}
fn plane(origin: Point3, normal: Vector3, u_axis: Vector3) -> SurfaceGeometry {
SurfaceGeometry::Solved(SolvedSurfaceGeometry::Plane(
cadmpeg_ir::geometry::analytic::PlaneSurface::try_new(origin, normal, u_axis).unwrap(),
))
}
fn cylinder(origin: Point3, axis: Vector3, radius: f64) -> SurfaceGeometry {
SurfaceGeometry::Solved(SolvedSurfaceGeometry::Cylinder(
cadmpeg_ir::geometry::analytic::CylinderSurface::try_new(
origin,
axis,
Vector3::new(1.0, 0.0, 0.0),
radius,
)
.unwrap(),
))
}
fn linear_spine(points: Vec<Point3>) -> cadmpeg_ir::geometry::nurbs::NurbsCurve {
cadmpeg_ir::geometry::nurbs::NurbsCurve::from_lanes(
2,
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
points,
None,
false,
)
.unwrap()
}
#[test]
fn constant_circular_plane_plane_blend_reduces_to_tangent_cylinder() {
let mut definition = nurbs::proc_surface::DecodedProceduralSurfaceDefinition::Blend {
supports: Box::new([
Some(plane(
Point3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
)),
Some(plane(
Point3::new(0.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)),
]),
spine: Some(linear_spine(vec![
Point3::new(2.0, 2.0, -4.0),
Point3::new(2.0, 2.0, 0.0),
Point3::new(2.0, 2.0, 7.0),
])),
radius_offsets: [-2.0, -2.0],
cross_section: cadmpeg_ir::geometry::BlendCrossSection::Circular,
native: None,
};
assert!(
matches!(analytic_procedural_surface(&definition), Some(SurfaceGeometry::Solved(SolvedSurfaceGeometry::Cylinder(cylinder_surface)))
if {
let origin = cylinder_surface.origin();
let axis = cylinder_surface.frame().axis().as_raw();
let radius = cylinder_surface.radius().get();
origin.get() == Point3::new(2.0, 2.0, -4.0)
&& *axis == Vector3::new(0.0, 0.0, 1.0)
&& radius == 2.0
})
);
let nurbs::proc_surface::DecodedProceduralSurfaceDefinition::Blend {
spine: Some(spine), ..
} = &mut definition
else {
unreachable!()
};
spine
.try_map_control_points(|index, point| {
let mut point = point.get();
if index == 1 {
point.x = 2.1;
}
cadmpeg_ir::features::FinitePoint3::new(point).ok_or_else(|| {
cadmpeg_ir::geometry::nurbs::NurbsError::Structure(
"control_points contains a non-finite point".into(),
)
})
})
.unwrap();
assert!(analytic_procedural_surface(&definition).is_none());
}
#[test]
fn constant_circular_plane_cylinder_blend_reduces_to_tangent_torus() {
let mut circle = exact_circle_directrix();
circle
.try_map_control_points(|_, point| {
let mut point = point.get();
point.x -= 2.0;
point.y -= 3.0;
point.z -= 3.0;
cadmpeg_ir::features::FinitePoint3::new(point).ok_or_else(|| {
cadmpeg_ir::geometry::nurbs::NurbsError::Structure(
"control_points contains a non-finite point".into(),
)
})
})
.unwrap();
let mut definition = nurbs::proc_surface::DecodedProceduralSurfaceDefinition::Blend {
supports: Box::new([
Some(plane(
Point3::new(0.0, 0.0, -1.0),
Vector3::new(0.0, 0.0, 1.0),
Vector3::new(1.0, 0.0, 0.0),
)),
Some(cylinder(
Point3::new(0.0, 0.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
3.0,
)),
]),
spine: Some(circle),
radius_offsets: [-2.0, -2.0],
cross_section: cadmpeg_ir::geometry::BlendCrossSection::Circular,
native: None,
};
assert!(
matches!(analytic_procedural_surface(&definition), Some(SurfaceGeometry::Solved(SolvedSurfaceGeometry::Torus(torus_surface)))
if {
let center = torus_surface.center();
let axis = torus_surface.frame().axis().as_raw();
let ref_direction = torus_surface.frame().reference().as_raw();
let major_radius = torus_surface.major_radius().get();
let minor_radius = torus_surface.minor_radius().get();
*center == Point3::new(0.0, 0.0, 1.0)
&& *axis == Vector3::new(0.0, 0.0, 1.0)
&& *ref_direction == Vector3::new(1.0, 0.0, 0.0)
&& major_radius == 5.0
&& minor_radius == -2.0
})
);
let nurbs::proc_surface::DecodedProceduralSurfaceDefinition::Blend { supports, .. } =
&mut definition
else {
unreachable!()
};
supports[0] = Some(plane(
Point3::new(0.0, 0.0, -1.0),
Vector3::new(
0.0,
std::f64::consts::FRAC_1_SQRT_2,
std::f64::consts::FRAC_1_SQRT_2,
),
Vector3::new(1.0, 0.0, 0.0),
));
assert!(analytic_procedural_surface(&definition).is_none());
}
#[test]
fn normalized_topology_heads_are_not_other_records() {
for head in ["subshell", "wire", "tcoedge", "tedge", "tvertex"] {
assert!(is_known_record_head(head), "{head}");
}
}
#[test]
fn unreferenced_carrier_heads_are_not_application_records() {
for head in ["spline", "intcurve", "pcurve"] {
assert!(is_known_record_head(head), "{head}");
}
assert!(!is_known_record_head("ATTRIB_CUSTOM"));
}
#[test]
fn asm_stream_delimiters_are_not_application_records() {
for name in ["Begin-of-ASM-History-Data", "End-of-ASM-data"] {
assert!(is_asm_stream_delimiter(name));
}
assert!(!is_asm_stream_delimiter("ATTRIB_CUSTOM-attrib"));
}
#[test]
fn saved_top_level_edge_projects_as_a_wire_body() {
let record = |index, name: &str, tokens: Vec<Token>| Record {
index,
name: name.into(),
tokens: tokens.into(),
offset: 0,
len: 0,
};
let records = vec![
record(0, "asmheader", Vec::new()),
record(
1,
"edge",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(2),
Token::Double(0.0),
Token::Ref(3),
Token::Double(1.0),
Token::Ref(-1),
Token::Ref(6),
Token::False,
],
),
record(
2,
"vertex",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(1),
Token::Long(0),
Token::Ref(4),
],
),
record(
3,
"vertex",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(1),
Token::Long(1),
Token::Ref(5),
],
),
record(
4,
"point",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Position([0.0, 0.0, 0.0]),
],
),
record(
5,
"point",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Position([1.0, 0.0, 0.0]),
],
),
record(
6,
"straight-curve",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Position([0.0, 0.0, 0.0]),
Token::Vector3([1.0, 0.0, 0.0]),
],
),
];
let mut bytes = Vec::from(&b"ASM BinaryFile4"[..]);
bytes.extend_from_slice(&22500u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&2u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
let arena = cadmpeg_core::decode::DecodeArena::new();
let (ctx, _) = cadmpeg_core::decode::DecodeContext::from_root_bytes(
&bytes,
&arena,
&cadmpeg_core::decode::DecodePolicy::service(),
)
.expect("fixture is within the service input limit");
let brep = decode_with_purpose(
&ctx,
&records,
&bytes,
"BREP.saved-edge.smbh",
FORMAT,
DecodePurpose::Model,
)
.expect("valid BREP tolerances");
assert_eq!(brep.bodies.len(), 1);
assert_eq!(brep.bodies[0].kind, cadmpeg_ir::topology::BodyKind::Wire);
assert_eq!(brep.regions.len(), 1);
assert_eq!(brep.shells.len(), 1);
assert_eq!(
brep.shells[0].wire_edges(),
vec![EdgeId::mint(id(FORMAT, 1).into_string()).expect("identity grammar")]
);
assert_eq!(brep.edges.len(), 1);
assert_eq!(brep.vertices.len(), 2);
assert_eq!(brep.points.len(), 2);
assert_eq!(brep.curves.len(), 1);
}
const CONE_WITH_POINT_APEX_SAT: &[u8] = b"23200 0 1 0 \n\
14 cadmpeg-tests 4 ACIS 0 \n\
1 1e-06 1e-10 \n\
body $-1 -1 $-1 $1 $-1 $-1 #\n\
lump $-1 -1 $-1 $-1 $2 $0 #\n\
shell $-1 -1 $-1 $-1 $-1 $3 $-1 $1 #\n\
face $-1 -1 $-1 $4 $5 $2 $-1 $7 forward single #\n\
face $-1 -1 $-1 $-1 $6 $2 $-1 $8 forward single #\n\
loop $-1 -1 $-1 $9 $10 $3 #\n\
loop $-1 -1 $-1 $-1 $11 $4 #\n\
cone-surface $-1 -1 $-1 0 0 0 0 0 1 5 0 0 1 I I -0.4472135954999579 0.8944271909999159 5 forward I I I I #\n\
plane-surface $-1 -1 $-1 0 0 0 0 0 -1 1 0 0 forward_v I I I I #\n\
loop $-1 -1 $-1 $-1 $12 $3 #\n\
coedge $-1 -1 $-1 $10 $10 $11 $13 forward $5 0 $-1 #\n\
coedge $-1 -1 $-1 $11 $11 $10 $13 reversed $6 0 $-1 #\n\
coedge $-1 -1 $-1 $12 $12 $-1 $14 forward $9 0 $-1 #\n\
edge $-1 -1 $-1 $15 0 $15 6.283185307179586 $10 $17 forward @7 unknown #\n\
edge $-1 -1 $-1 $16 1 $16 0 $12 $-1 forward @7 unknown #\n\
vertex $-1 -1 $-1 $13 0 $18 #\n\
vertex $-1 -1 $-1 $14 0 $19 #\n\
ellipse-curve $-1 -1 $-1 0 0 0 0 0 1 5 0 0 1 I I #\n\
point $-1 -1 $-1 5 0 0 #\n\
point $-1 -1 $-1 0 0 10 #\n\
End-of-ASM-data \n";
fn edge_by_record(brep: &AsmBrep, index: i64) -> &cadmpeg_ir::topology::Edge {
let edge = EdgeId::mint(id(FORMAT, index).into_string()).expect("identity grammar");
brep.edges
.iter()
.find(|candidate| candidate.id == edge)
.expect("edge record decodes")
}
#[test]
fn null_curve_apex_edge_is_a_point_edge_of_a_solid() {
let source = CONE_WITH_POINT_APEX_SAT;
crate::test_support::with_service_context(source, |ctx| {
let parsed = crate::sat::parse(ctx, source).expect("synthetic SAT stream");
let header = parsed.header.as_kernel_header();
let brep = super::decode_with_header(
ctx,
&parsed.records,
source,
Some(&header),
"stream",
FORMAT,
DecodePurpose::Model,
)
.expect("synthetic cone solid decodes");
assert_eq!(brep.bodies.len(), 1);
assert_eq!(brep.bodies[0].kind, cadmpeg_ir::topology::BodyKind::Solid);
let apex = edge_by_record(&brep, 14);
assert_eq!(apex.start, apex.end);
assert_eq!(apex.param_range(), None);
let carrier = apex.curve().expect("apex edge carrier");
let curve = brep
.curves
.iter()
.find(|curve| &curve.id == carrier)
.expect("apex carrier curve");
let point = match curve.geometry {
cadmpeg_ir::geometry::CurveGeometry::Solved(
cadmpeg_ir::geometry::SolvedCurveGeometry::Degenerate(point),
) => Some(point.point().get()),
_ => None,
}
.expect("apex carrier is a point");
assert!(
point.distance(Point3::new(0.0, 0.0, 10.0)) < 1.0e-12,
"{point:?}"
);
assert!(edge_by_record(&brep, 13).curve().is_some());
assert_eq!(brep.curves.len(), 2);
})
.expect("fixture is within the service input limit");
}
#[test]
fn null_curve_edge_between_distinct_vertices_stays_curveless() {
let source = String::from_utf8(CONE_WITH_POINT_APEX_SAT.to_vec())
.expect("ASCII fixture")
.replace("edge $-1 -1 $-1 $16 1 $16 0", "edge $-1 -1 $-1 $16 1 $15 0");
let source = source.as_bytes();
crate::test_support::with_service_context(source, |ctx| {
let parsed = crate::sat::parse(ctx, source).expect("synthetic SAT stream");
let header = parsed.header.as_kernel_header();
let brep = super::decode_with_header(
ctx,
&parsed.records,
source,
Some(&header),
"stream",
FORMAT,
DecodePurpose::Model,
)
.expect("synthetic cone solid decodes");
assert_eq!(edge_by_record(&brep, 14).curve(), None);
assert_eq!(brep.curves.len(), 1);
assert_eq!(brep.bodies[0].kind, cadmpeg_ir::topology::BodyKind::Sheet);
})
.expect("fixture is within the service input limit");
}
#[test]
fn nested_attributes_inherit_their_topology_owner() {
use cadmpeg_ir::attributes::AttributeTarget;
use cadmpeg_ir::ids::EdgeId;
let current_attribute = |index, owner| Record {
index,
name: "ATTRIB_CUSTOM-attrib".into(),
tokens: vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(owner),
]
.into(),
offset: 0,
len: 0,
};
let legacy_attribute = |index, owner| Record {
index,
name: "ATTRIB_CUSTOM-attrib".into(),
tokens: vec![
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(owner),
]
.into(),
offset: 0,
len: 0,
};
let parent = current_attribute(7, 3);
let child = legacy_attribute(8, 7);
let records = HashMap::from([(7, &parent), (8, &child)]);
let expected =
AttributeTarget::Edge(EdgeId::mint("test:model:edge#0").expect("identity grammar"));
let targets = HashMap::from([(3, expected.clone())]);
assert_eq!(
inherited_attribute_target(7, &records, &targets),
Some(expected.clone())
);
assert_eq!(
inherited_attribute_target(8, &records, &targets),
Some(expected)
);
let cycle_left = current_attribute(9, 10);
let cycle_right = legacy_attribute(10, 9);
let cycle = HashMap::from([(9, &cycle_left), (10, &cycle_right)]);
assert_eq!(inherited_attribute_target(9, &cycle, &targets), None);
}
#[test]
fn standard_attribute_chain_uses_forward_links_and_first_exact_color() {
use super::attributes::{
attribute_chain_color_carrier, collect_attributes, DirectColorCarrier,
};
use cadmpeg_ir::attributes::AttributeTarget;
let record = |index, name: &str, next, payload: Vec<Token>| {
let mut tokens = vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(next),
Token::Ref(-1),
Token::Ref(0),
];
tokens.extend(payload);
Record {
index,
name: name.into(),
tokens: tokens.into(),
offset: 0,
len: 0,
}
};
let entity = Record {
index: 0,
name: "face".into(),
tokens: vec![Token::Ref(1)].into(),
offset: 0,
len: 0,
};
let attributes = [
record(1, "color-adesk-attrib", 2, vec![Token::Long(5)]),
record(
2,
"material-adesk-attrib",
3,
vec![Token::Long(7), Token::Long(11)],
),
record(
3,
"truecolor-adesk-attrib",
4,
vec![Token::Int64(i64::from(0xc3_40_80_c0_u32))],
),
record(
4,
"rgb_color-st-attrib",
5,
vec![
Token::Double(0.25),
Token::Double(0.5),
Token::Double(0.75),
Token::Double(0.5),
],
),
record(
5,
"truecolor-adesk-attrib",
6,
vec![Token::Int64(i64::from(0xc2_40_80_c0_u32))],
),
record(
6,
"entatt_color-bt-attrib",
-1,
vec![Token::Str("16711680".into())],
),
];
let by_index = attributes
.iter()
.map(|attribute| {
let index = i64::try_from(attribute.index).expect("index fits");
(index, attribute)
})
.collect::<HashMap<_, _>>();
let (carrier, decoded) = attribute_chain_color_carrier(&entity, by_index.len(), |index| {
by_index.get(&index).copied()
})
.unwrap();
assert_eq!(carrier.index, 5);
assert_eq!(
decoded.carrier,
DirectColorCarrier::AutodeskTrueColor { field: 5 }
);
assert_eq!(
(
decoded.color.r(),
decoded.color.g(),
decoded.color.b(),
decoded.color.a(),
),
(64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0)
);
let mut emitted = HashSet::new();
let mut source = Vec::new();
collect_attributes(
&entity,
&AttributeTarget::Face(FaceId::mint("test:model:face#0").expect("identity grammar")),
&by_index,
&mut emitted,
&mut source,
FORMAT,
)
.expect("finite attribute values");
assert_eq!(
source
.iter()
.map(|attribute| attribute.name.as_str())
.collect::<Vec<_>>(),
[
"color-adesk-attrib",
"material-adesk-attrib",
"truecolor-adesk-attrib",
"rgb_color-st-attrib",
"truecolor-adesk-attrib",
"entatt_color-bt-attrib",
]
);
}
#[test]
fn legacy_attribute_chain_uses_second_field_forward_link() {
use super::attributes::{
attribute_chain_color_carrier, attribute_chain_name, collect_attributes,
};
use cadmpeg_ir::attributes::AttributeTarget;
let entity = Record {
index: 0,
name: "face".into(),
tokens: vec![Token::Ref(1)].into(),
offset: 0,
len: 0,
};
let color = Record {
index: 1,
name: "rgb_color-st-attrib".into(),
tokens: vec![
Token::Ref(-1),
Token::Ref(2),
Token::Ref(-1),
Token::Ref(0),
Token::Double(0.25),
Token::Double(0.5),
Token::Double(0.75),
]
.into(),
offset: 0,
len: 0,
};
let name = Record {
index: 2,
name: "string_attrib-name_attrib-gen-attrib".into(),
tokens: vec![
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(1),
Token::Ref(0),
Token::Str("name".into()),
Token::Str("legacy face".into()),
]
.into(),
offset: 0,
len: 0,
};
let by_index = HashMap::from([(1, &color), (2, &name)]);
let (carrier, decoded) = attribute_chain_color_carrier(&entity, by_index.len(), |index| {
by_index.get(&index).copied()
})
.unwrap();
assert_eq!(carrier.index, 1);
assert_eq!(
decoded.carrier,
super::attributes::DirectColorCarrier::NormalizedRgb { fields: [4, 5, 6] }
);
assert_eq!(
attribute_chain_name(&entity, &by_index).as_deref(),
Some("legacy face")
);
let mut emitted = HashSet::new();
let mut source = Vec::new();
collect_attributes(
&entity,
&AttributeTarget::Face(FaceId::mint("test:model:face#0").expect("identity grammar")),
&by_index,
&mut emitted,
&mut source,
FORMAT,
)
.expect("finite attribute values");
assert_eq!(
source
.iter()
.map(|attribute| attribute.name.as_str())
.collect::<Vec<_>>(),
[
"rgb_color-st-attrib",
"string_attrib-name_attrib-gen-attrib"
]
);
}
#[test]
fn shell_and_loop_attribute_chains_retain_their_native_owners() {
use cadmpeg_ir::attributes::AttributeTarget;
let record = |index, name: &str, tokens: Vec<Token>| Record {
index,
name: name.into(),
tokens: tokens.into(),
offset: 0,
len: 0,
};
let records = vec![
record(0, "asmheader", vec![]),
record(1, "ATTRIB_CUSTOM-attrib", vec![Token::Ref(-1)]),
record(2, "ATTRIB_CUSTOM-attrib", vec![Token::Ref(-1)]),
record(3, "shell", vec![Token::Ref(1)]),
record(4, "loop", vec![Token::Ref(2)]),
];
let face_id = FaceId::mint("test:model:face#0").expect("identity grammar");
let mut brep = AsmBrep {
shells: vec![Shell::new(
ShellId::mint(id(FORMAT, 3).into_string()).expect("identity grammar"),
RegionId::mint("test:model:region#0").expect("identity grammar"),
vec![face_id.clone()],
Vec::new(),
Vec::new(),
)
.unwrap()],
loops: vec![Loop {
id: LoopId::mint(id(FORMAT, 4).into_string()).expect("identity grammar"),
face: face_id,
boundary: cadmpeg_ir::topology::LoopBoundary::Ring(
cadmpeg_ir::topology::LoopRing::new(
vec![cadmpeg_ir::ids::CoedgeId::mint("test:model:coedge#0")
.expect("identity grammar")],
Vec::new(),
)
.expect("valid loop ring"),
),
}],
..AsmBrep::default()
};
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect();
let reach = Reachable {
loops: HashSet::from([4]),
..Reachable::default()
};
assert_eq!(
emit_attributes(&mut brep, &records, &by_index, &reach, FORMAT)
.expect("finite attribute values"),
HashSet::from([1, 2])
);
assert!(brep.attributes.iter().any(|attribute| attribute.target
== AttributeTarget::Shell(
ShellId::mint(id(FORMAT, 3).into_string()).expect("identity grammar")
)));
assert!(brep.attributes.iter().any(|attribute| attribute.target
== AttributeTarget::Loop(
LoopId::mint(id(FORMAT, 4).into_string()).expect("identity grammar")
)));
}
#[test]
fn lump_named_attributes_bind_to_their_owning_body() {
use cadmpeg_ir::attributes::{AttributeTarget, AttributeValue};
use cadmpeg_ir::ids::BodyId;
use cadmpeg_ir::topology::{Body, BodyKind, Region};
let record = |index, name: &str, tokens: Vec<Token>| Record {
index,
name: name.into(),
tokens: tokens.into(),
offset: 0,
len: 0,
};
let records = vec![
record(1, "body", vec![Token::Ref(-1)]),
record(
2,
"lump",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(1),
],
),
record(
3,
"name_attrib-gen-attrib",
vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(2),
Token::Enum(1),
Token::Enum(3),
Token::Enum(1),
Token::Enum(1),
Token::Str("MBRD_ST_SHEETMETAL_LUMP".into()),
],
),
];
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect();
let body_id = BodyId::mint(id(FORMAT, 1).into_string()).expect("identity grammar");
let mut brep = AsmBrep {
bodies: vec![Body {
id: body_id.clone(),
kind: BodyKind::Sheet,
regions: vec![RegionId::mint(id(FORMAT, 2).into_string()).expect("identity grammar")],
transform: None,
name: None,
color: None,
visible: None,
}],
regions: vec![Region {
id: RegionId::mint(id(FORMAT, 2).into_string()).expect("identity grammar"),
body: body_id.clone(),
shells: Vec::new(),
}],
..AsmBrep::default()
};
let emitted = emit_attributes(
&mut brep,
&records,
&by_index,
&Reachable::default(),
FORMAT,
)
.expect("finite attribute values");
assert_eq!(emitted, HashSet::from([3]));
assert_eq!(
brep.attributes,
vec![SourceAttribute {
id: cadmpeg_ir::ids::AttributeId::mint("f3d:brep:attribute#3")
.expect("identity grammar"),
target: AttributeTarget::Body(body_id),
name: "name_attrib-gen-attrib".into(),
values: vec![
AttributeValue::Reference("f3d:brep:entity#-1".into()),
AttributeValue::Integer(-1),
AttributeValue::Reference("f3d:brep:entity#-1".into()),
AttributeValue::Reference("f3d:brep:entity#-1".into()),
AttributeValue::Reference("f3d:brep:entity#2".into()),
AttributeValue::Integer(1),
AttributeValue::Integer(3),
AttributeValue::Integer(1),
AttributeValue::Integer(1),
AttributeValue::String("MBRD_ST_SHEETMETAL_LUMP".into()),
],
}]
);
}
fn ident(bytes: &mut Vec<u8>, name: &str) {
bytes.push(0x0d);
bytes.push(u8::try_from(name.len()).expect("test value fits"));
bytes.extend_from_slice(name.as_bytes());
}
fn reference(bytes: &mut Vec<u8>, value: i64) {
bytes.push(0x0c);
bytes.extend_from_slice(&value.to_le_bytes());
}
fn record(bytes: &mut Vec<u8>, name: &str, fields: &[i64]) {
ident(bytes, name);
for field in fields {
reference(bytes, *field);
}
bytes.push(0x11);
}
#[test]
fn generated_subshell_hierarchy_flattens_faces_onto_shell() {
let mut bytes = Vec::new();
record(&mut bytes, "asmheader", &[]); record(&mut bytes, "shell", &[-1, -1, -1, -1, 2, -1, -1, -1]); record(&mut bytes, "subshell", &[-1, -1, -1, 1, -1, 3, 4, -1]); record(&mut bytes, "subshell", &[-1, -1, -1, 2, -1, -1, 5, -1]); record(&mut bytes, "face", &[-1, -1, -1, -1, -1, 1, 2]); record(&mut bytes, "face", &[-1, -1, -1, -1, -1, 3, 3]);
let records = crate::test_support::sab::frame(&bytes, 0, bytes.len(), RefWidth::Eight)
.expect("generated subshell bytes must frame");
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect::<HashMap<_, _>>();
let kept = [4, 5].into_iter().collect::<HashSet<_>>();
let subshells = subshell_ancestor_shells(&records, &by_index).unwrap();
assert_eq!(
shell_faces(&records[1], &by_index, &subshells, &kept, FORMAT),
vec![
FaceId::mint("f3d:brep:entity#4").expect("identity grammar"),
FaceId::mint("f3d:brep:entity#5").expect("identity grammar")
]
);
assert_eq!(
subshell_ancestor_shells(&records, &by_index)
.unwrap()
.get(&3),
Some(&1)
);
}
#[test]
fn subshell_wires_project_onto_the_nearest_shell() {
let mut bytes = Vec::new();
record(&mut bytes, "asmheader", &[]); record(&mut bytes, "shell", &[-1, -1, -1, -1, 2, -1, 4, -1]); record(&mut bytes, "subshell", &[-1, -1, -1, 1, -1, 3, -1, 5]); record(&mut bytes, "subshell", &[-1, -1, -1, 2, -1, -1, -1, 6]); record(&mut bytes, "wire", &[]); record(&mut bytes, "wire", &[]); record(&mut bytes, "wire", &[]);
let records = crate::test_support::sab::frame(&bytes, 0, bytes.len(), RefWidth::Eight)
.expect("generated subshell-wire bytes must frame");
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect::<HashMap<_, _>>();
let subshells = subshell_ancestor_shells(&records, &by_index).unwrap();
assert_eq!(
shell_wire_roots(&records[1], &by_index, &subshells),
[4, 5, 6]
);
}
#[test]
fn reversed_edge_negates_its_pcurve_validation_interval() {
let edge = Record {
index: 1,
name: "edge".into(),
tokens: vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(2),
Token::Double(0.55),
Token::Ref(3),
Token::Double(0.60),
Token::Ref(-1),
Token::Ref(4),
Token::True,
]
.into(),
offset: 0,
len: 0,
};
assert_eq!(
edge_pcurve_parameter_ranges(&edge),
Some([[-0.55, -0.60], [0.55, 0.60]])
);
let candidate = cadmpeg_ir::geometry::pcurve::PcurveNurbs::from_lanes(
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![
cadmpeg_ir::math::Point2::new(0.0, 0.0),
cadmpeg_ir::math::Point2::new(1.0, 0.0),
],
None,
false,
)
.unwrap();
assert_eq!(
pcurve_ranges_on_domain(&candidate, Some(&edge)),
Some(vec![[0.55, 0.60], [0.0, 1.0]])
);
}
#[test]
fn carrierless_edge_retains_raw_parameter_range_without_a_domain() {
let edge = Record {
index: 1,
name: "edge".into(),
tokens: vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(2),
Token::Double(1.0),
Token::Ref(3),
Token::Double(0.0),
Token::Ref(-1),
Token::Ref(-1),
Token::False,
]
.into(),
offset: 0,
len: 0,
};
let records = [edge];
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect::<HashMap<_, _>>();
let mut brep = AsmBrep::default();
let reach = Reachable {
edges: HashSet::from([1]),
vertices: HashSet::from([2, 3]),
..Reachable::default()
};
emit_edges(
&mut brep,
&records,
&by_index,
&reach,
CurveSenseRefs {
reversed_curve_refs: &HashSet::new(),
forward_curve_refs: &HashSet::new(),
},
FORMAT,
)
.expect("valid edge tolerance");
assert_eq!(brep.edges.len(), 1);
assert_eq!(brep.edges[0].curve(), None);
assert_eq!(
brep.edges[0]
.param_range()
.map(cadmpeg_ir::units::FiniteVector::get),
Some([1.0, 0.0])
);
}
#[test]
fn tolerant_edge_tail_admits_only_nonnegative_finite_source_tolerance() {
for (source_tolerance, expected_tolerance, expected_tails) in [
(0.5, Some(0.5 * crate::nurbs::reader::LEN_TO_MM), 1),
(-1.0, None, 0),
(f64::NAN, None, 0),
] {
let edge = Record {
index: 1,
name: "tedge".into(),
tokens: vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(2),
Token::Double(1.0),
Token::Ref(3),
Token::Double(0.0),
Token::Ref(-1),
Token::Ref(-1),
Token::False,
Token::False,
Token::Double(source_tolerance),
Token::Long(2_250_003),
Token::Long(7),
]
.into(),
offset: 0,
len: 0,
};
let records = [edge];
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect::<HashMap<_, _>>();
let reach = Reachable {
edges: HashSet::from([1]),
vertices: HashSet::from([2, 3]),
..Reachable::default()
};
let mut brep = AsmBrep::default();
emit_edges(
&mut brep,
&records,
&by_index,
&reach,
CurveSenseRefs {
reversed_curve_refs: &HashSet::new(),
forward_curve_refs: &HashSet::new(),
},
FORMAT,
)
.expect("edge emission");
assert_eq!(
brep.edges[0]
.tolerance
.map(cadmpeg_ir::scalar::PositiveReal::get),
expected_tolerance
);
assert_eq!(brep.tolerant_edge_tails.len(), expected_tails);
}
}
#[test]
fn append_preserves_body_ordinals_within_each_source_brep() {
let key = |source: &str| BodyNativeKey {
source_namespace: records::identity::NativeRecordNamespace::new(FORMAT),
record_index: 17,
body: if source == "first" {
"f3d:brep:entity#17"
} else {
"f3d:brep:entity#18"
}
.try_into()
.expect("valid identity"),
body_ordinal: 0,
source_brep: Some(source.into()),
asm_body_key: Some(23),
};
let mut first = AsmBrep::default();
first.body_native_keys.push(key("first"));
let mut second = AsmBrep::default();
second.body_native_keys.push(key("second"));
first.append(second);
assert_eq!(first.body_native_keys.len(), 2);
assert_eq!(first.body_native_keys[0].body_ordinal, 0);
assert_eq!(first.body_native_keys[1].body_ordinal, 0);
assert_eq!(
first.body_native_keys[0].source_brep.as_deref(),
Some("first")
);
assert_eq!(
first.body_native_keys[1].source_brep.as_deref(),
Some("second")
);
}
#[test]
fn a_restated_join_map_names_no_wire_key() {
let brep = AsmBrep::default();
let value = serde_value::to_value(&brep).expect("AsmBrep serializes");
AsmBrep::deserialize(value.clone()).expect("the key records alone read");
for restated in ["face_keys", "body_keys"] {
let serde_value::Value::Map(mut fields) = value.clone() else {
panic!("AsmBrep serializes as a map");
};
fields.insert(
serde_value::Value::String(restated.to_string()),
serde_value::Value::Map(std::collections::BTreeMap::new()),
);
let Err(error) = AsmBrep::deserialize(serde_value::Value::Map(fields)) else {
panic!("a restated join map is an unknown field");
};
let error = error.to_string();
assert!(error.contains(restated), "{error}");
}
}
#[test]
fn the_join_projections_read_the_key_records() {
let face = FaceId::mint("asm:test:face#1".to_string()).expect("identity grammar");
let mut brep = AsmBrep::default();
let namespace = records::identity::NativeRecordNamespace::new(crate::asm_format!("f3d"));
brep.face_native_keys.push(records::FaceNativeKey {
source_namespace: namespace,
record_index: 1,
face: face.clone(),
asm_face_key: Some(7),
});
assert_eq!(
super::key_maps::face_keys(&brep.face_native_keys),
std::collections::HashMap::from([(face, 7)])
);
}
#[test]
fn circle_recognition_is_invariant_under_common_weight_scale() {
for curve in [exact_circle_directrix(), degree_elevated_circle()] {
for scale in [1e-200, 1.0, 1e200] {
let rescaled = cadmpeg_ir::geometry::nurbs::NurbsCurve::from_lanes(
curve.degree(),
curve.knots().to_vec(),
curve.pole_rows().raw_points(),
Some(
curve
.weights()
.unwrap()
.iter()
.map(|w| w.get() * scale)
.collect(),
),
false,
)
.unwrap();
assert!(rational_four_arc_circle(&rescaled).is_some());
}
}
let curve = exact_circle_directrix();
for scale in [1e-200, 1.0, 1e200] {
let polynomial = cadmpeg_ir::geometry::nurbs::NurbsCurve::from_lanes(
curve.degree(),
curve.knots().to_vec(),
curve.pole_rows().raw_points(),
Some(vec![scale; 9]),
false,
)
.unwrap();
assert!(rational_four_arc_circle(&polynomial).is_none());
}
}
#[test]
fn a_non_finite_attribute_double_is_refused() {
use super::attributes::source_attribute;
use cadmpeg_ir::attributes::AttributeTarget;
let record = Record {
index: 1,
name: "real-st-attrib".into(),
tokens: vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(-1),
Token::Ref(0),
Token::Double(f64::NAN),
]
.into(),
offset: 0,
len: 0,
};
let error = source_attribute(&record, AttributeTarget::Document, FORMAT)
.expect_err("a NaN attribute double is refused");
assert_eq!(
error.to_string(),
"malformed container: attribute record 1 (real-st-attrib) holds a non-finite number"
);
}
#[test]
fn faced_body_kind_closes_on_balanced_uses_of_extended_edges() {
use cadmpeg_ir::topology::BodyKind;
use cadmpeg_ir::topology::Sense::{Forward, Reversed};
let shared = EdgeId::from(id(FORMAT, 1));
let touching = EdgeId::from(id(FORMAT, 2));
let apex = EdgeId::from(id(FORMAT, 3));
let no_collapsed = |_: &EdgeId| false;
let only_apex = |edge: &EdgeId| *edge == apex;
let non_manifold = [
(&shared, Forward),
(&shared, Reversed),
(&touching, Forward),
(&touching, Reversed),
(&touching, Forward),
(&touching, Reversed),
];
assert_eq!(faced_body_kind(non_manifold, no_collapsed), BodyKind::Solid);
assert_eq!(
faced_body_kind(non_manifold[..5].iter().copied(), no_collapsed),
BodyKind::Sheet
);
assert_eq!(
faced_body_kind([(&shared, Forward), (&shared, Forward)], no_collapsed),
BodyKind::Sheet
);
let apex_loop = [(&shared, Forward), (&shared, Reversed), (&apex, Forward)];
assert_eq!(faced_body_kind(apex_loop, no_collapsed), BodyKind::Sheet);
assert_eq!(faced_body_kind(apex_loop, only_apex), BodyKind::Solid);
assert_eq!(
faced_body_kind([(&apex, Forward)], only_apex),
BodyKind::Sheet
);
}
#[test]
fn collapsed_edges_need_one_vertex_and_a_point_carrier() {
let edge = |index, start, end, curve| Record {
index,
name: "edge".into(),
tokens: vec![
Token::Ref(-1),
Token::Long(-1),
Token::Ref(-1),
Token::Ref(start),
Token::Double(0.0),
Token::Ref(end),
Token::Double(0.0),
Token::Ref(-1),
Token::Ref(curve),
Token::False,
]
.into(),
offset: 0,
len: 0,
};
let curve = |index, name: &str| Record {
index,
name: name.into(),
tokens: Vec::new().into(),
offset: 0,
len: 0,
};
let records = vec![
edge(1, 10, 10, -1),
edge(2, 10, 10, 20),
edge(3, 10, 10, 21),
edge(4, 10, 11, -1),
edge(5, 10, 10, -1),
curve(20, "degenerate_curve"),
curve(21, "intcurve-curve"),
];
let by_index = records
.iter()
.map(|record| (i64::try_from(record.index).expect("index fits"), record))
.collect();
let collapsed = collapsed_edges(&records, &by_index, &HashSet::from([1, 2, 3, 4]), FORMAT)
.expect("edge indices fit");
assert_eq!(
collapsed,
HashSet::from([EdgeId::from(id(FORMAT, 1)), EdgeId::from(id(FORMAT, 2))])
);
}
mod attribute_limits;