cadmpeg-ir 0.6.5

CAD data model, validation, diffing, and codec API for cadmpeg.
// SPDX-License-Identifier: Apache-2.0
#![allow(clippy::unwrap_used)]

use crate::products::{
    AssemblyJoint, JointConnector, JointId, JointOperand, OperandContainer, PairedJointKind,
    PlaneSense,
};
use crate::transform::Transform;

fn connector() -> JointConnector {
    JointConnector {
        operand: JointOperand::root("root:object", Vec::new()),
        frame: Transform::identity(),
        detached: false,
    }
}

fn joint_id() -> JointId {
    JointId::mint("test:model:joint#0").expect("identity grammar")
}

/// Each kinematic family names exactly the scalars it carries, so a scalar
/// outside the family is refused by name rather than admitted and dropped.
#[test]
fn a_joint_family_refuses_a_scalar_outside_it_by_name() {
    let families: [(PairedJointKind, &[&str]); 19] = [
        (
            PairedJointKind::Fixed {
                angle: None,
                translation_offset: None,
                angular_limits: None,
                linear_limits: None,
            },
            &[
                "angle",
                "translation_offset",
                "angular_limits",
                "linear_limits",
            ],
        ),
        (
            PairedJointKind::Revolute {
                angle: None,
                angular_limits: None,
            },
            &["angle", "angular_limits"],
        ),
        (
            PairedJointKind::Slider {
                distance: None,
                translation_offset: None,
                linear_limits: None,
            },
            &["distance", "translation_offset", "linear_limits"],
        ),
        (
            PairedJointKind::Cylindrical {
                angle: None,
                distance: None,
                angular_limits: None,
                linear_limits: None,
            },
            &["angle", "distance", "angular_limits", "linear_limits"],
        ),
        (PairedJointKind::Ball {}, &[]),
        (PairedJointKind::Distance { distance: None }, &["distance"]),
        (PairedJointKind::Parallel {}, &[]),
        (PairedJointKind::Perpendicular {}, &[]),
        (PairedJointKind::Angle { angle: None }, &["angle"]),
        (
            PairedJointKind::RackPinion {
                distance: None,
                distance2: None,
            },
            &["distance", "distance2"],
        ),
        (PairedJointKind::Screw { distance: None }, &["distance"]),
        (
            PairedJointKind::Gears {
                distance: None,
                distance2: None,
            },
            &["distance", "distance2"],
        ),
        (
            PairedJointKind::Belt {
                distance: None,
                distance2: None,
            },
            &["distance", "distance2"],
        ),
        (
            PairedJointKind::PlaneCoincidence {
                sense: PlaneSense::Opposed,
                offset: None,
            },
            &["offset"],
        ),
        (PairedJointKind::AxisCoincidence {}, &[]),
        (
            PairedJointKind::PointOnAxis { distance: None },
            &["distance"],
        ),
        (
            PairedJointKind::Insert {
                sense: PlaneSense::Opposed,
                offset: None,
            },
            &["offset"],
        ),
        (
            PairedJointKind::Tangent {
                contact: crate::products::TangentContact::Outside,
            },
            &[],
        ),
        (
            PairedJointKind::Native {
                name: "application_joint".into(),
                angle: None,
                translation_offset: None,
                distance: None,
                distance2: None,
                angular_limits: None,
                linear_limits: None,
            },
            &[
                "angle",
                "translation_offset",
                "distance",
                "distance2",
                "angular_limits",
                "linear_limits",
            ],
        ),
    ];
    let fields = [
        ("angle", serde_json::json!(0.5)),
        ("translation_offset", serde_json::json!([1.0, 2.0, 3.0])),
        ("distance", serde_json::json!(2.0)),
        ("distance2", serde_json::json!(3.0)),
        ("offset", serde_json::json!(1.5)),
        (
            "angular_limits",
            serde_json::json!({"bounds": "range", "minimum": 0.0, "maximum": 1.0}),
        ),
        (
            "linear_limits",
            serde_json::json!({"bounds": "range", "minimum": 0.0, "maximum": 2.0}),
        ),
    ];
    for (kind, allowed) in families {
        let joint =
            AssemblyJoint::paired(joint_id(), kind.clone(), [connector(), connector()], None);
        let wire = serde_json::to_value(&joint).unwrap();
        assert_eq!(
            serde_json::from_value::<AssemblyJoint>(wire.clone()).unwrap(),
            joint
        );
        for (field, value) in &fields {
            let mut candidate = wire.clone();
            candidate["operands"]["kind"][*field] = value.clone();
            let result = serde_json::from_value::<AssemblyJoint>(candidate.clone());
            assert_eq!(result.is_ok(), allowed.contains(field), "{kind:?}: {field}");
            match result {
                Ok(joint) => assert_eq!(
                    serde_json::to_value(joint).unwrap(),
                    candidate,
                    "{kind:?}: {field}"
                ),
                Err(error) => assert!(
                    error
                        .to_string()
                        .contains(&format!("unknown field `{field}`")),
                    "{kind:?}: {field}: {error}"
                ),
            }
        }
    }
}

/// The deleted parallel arrays and the read-and-dropped `properties` key are
/// refused at the level they were deleted from.
#[test]
fn the_joint_wire_refuses_the_deleted_keys() {
    let joint = AssemblyJoint::grounded(joint_id(), connector(), None);
    let wire = serde_json::to_value(&joint).unwrap();
    assert_eq!(wire["operands"]["arity"], "grounded");
    assert!(wire.get("kind").is_none());
    assert!(wire.get("frames").is_none());
    assert!(wire.get("detached").is_none());
    assert_eq!(
        serde_json::from_value::<AssemblyJoint>(wire.clone()).unwrap(),
        joint
    );

    for (level, key, value) in [
        ("properties", serde_json::json!({})),
        ("frames", serde_json::json!([])),
        ("detached", serde_json::json!([false, false])),
    ]
    .map(|(key, value)| ("joint", key, value))
    {
        let mut candidate = wire.clone();
        candidate.as_object_mut().unwrap().insert(key.into(), value);
        let error = serde_json::from_value::<AssemblyJoint>(candidate)
            .unwrap_err()
            .to_string();
        assert!(
            error.contains(&format!("unknown field `{key}`")),
            "{level}: {key}: {error}"
        );
    }

    let mut candidate = wire;
    candidate["operands"]["distance"] = serde_json::json!(1);
    let error = serde_json::from_value::<AssemblyJoint>(candidate)
        .unwrap_err()
        .to_string();
    assert!(error.contains("unknown field `distance`"), "{error}");
}

/// A non-finite joint scalar is refused where the scalar is read.
#[test]
fn a_nonfinite_joint_scalar_is_refused() {
    let joint = AssemblyJoint::paired(
        joint_id(),
        PairedJointKind::Native {
            name: "custom".into(),
            angle: None,
            translation_offset: None,
            distance: None,
            distance2: None,
            angular_limits: None,
            linear_limits: None,
        },
        [connector(), connector()],
        None,
    );
    let wire = serde_json::to_string(&joint).unwrap();
    for field in ["angle", "distance", "distance2"] {
        let candidate = wire.replace(
            "\"name\":\"custom\"",
            &format!("\"name\":\"custom\",\"{field}\":1e400"),
        );
        assert!(
            serde_json::from_str::<AssemblyJoint>(&candidate).is_err(),
            "{field}"
        );
    }
}

/// A plane coincidence names its sense, an operand below a local occurrence
/// names its nested occurrence path, and a joint names its driving parameter.
#[test]
fn a_plane_coincidence_keeps_its_sense_path_and_driving_parameter() {
    let occurrence = crate::ids::OccurrenceId::mint("test:model:occurrence#4").unwrap();
    let nested = JointConnector {
        operand: JointOperand::nested_occurrence(
            occurrence.clone(),
            vec!["2".into(), "7".into()],
            "part:object",
            vec!["face:3".into()],
        ),
        frame: Transform::identity(),
        detached: false,
    };
    let mut joint = AssemblyJoint::paired(
        joint_id(),
        PairedJointKind::PlaneCoincidence {
            sense: PlaneSense::Aligned,
            offset: Some(crate::scalar::FiniteReal::new(-2.5).unwrap()),
        },
        [connector(), nested],
        None,
    );
    joint.driving_parameter =
        Some(crate::features::ParameterId::mint("test:model:parameter#0").unwrap());
    let wire = serde_json::to_value(&joint).unwrap();
    assert_eq!(wire["operands"]["kind"]["sense"], "aligned");
    assert_eq!(wire["operands"]["kind"]["offset"], -2.5);
    assert_eq!(
        wire["operands"]["connectors"][1]["operand"]["container"]["occurrence_path"],
        serde_json::json!(["2", "7"])
    );
    assert_eq!(wire["driving_parameter"], "test:model:parameter#0");
    assert_eq!(
        serde_json::from_value::<AssemblyJoint>(wire.clone()).unwrap(),
        joint
    );

    let mut missing_sense = wire.clone();
    missing_sense["operands"]["kind"]
        .as_object_mut()
        .unwrap()
        .remove("sense");
    assert!(serde_json::from_value::<AssemblyJoint>(missing_sense).is_err());

    let flat = JointOperand::occurrence(occurrence, "part:object", Vec::new());
    let wire = serde_json::to_value(&flat).unwrap();
    assert!(wire["container"].get("occurrence_path").is_none());
    assert!(matches!(
        serde_json::from_value::<JointOperand>(wire).unwrap().container,
        OperandContainer::Occurrence { occurrence_path, .. } if occurrence_path.is_empty()
    ));
}

/// Insert and tangent joints name their sense and contact side on the wire.
#[test]
fn insert_and_tangent_joints_keep_their_sense_and_contact() {
    for (kind, key, value) in [
        (
            PairedJointKind::Insert {
                sense: PlaneSense::Opposed,
                offset: Some(crate::scalar::FiniteReal::new(2.0).unwrap()),
            },
            "sense",
            "opposed",
        ),
        (
            PairedJointKind::Tangent {
                contact: crate::products::TangentContact::Inside,
            },
            "contact",
            "inside",
        ),
    ] {
        let joint = AssemblyJoint::paired(joint_id(), kind, [connector(), connector()], None);
        let wire = serde_json::to_value(&joint).unwrap();
        assert_eq!(wire["operands"]["kind"][key], value);
        assert_eq!(
            serde_json::from_value::<AssemblyJoint>(wire.clone()).unwrap(),
            joint
        );
        let mut missing = wire;
        missing["operands"]["kind"]
            .as_object_mut()
            .unwrap()
            .remove(key);
        assert!(serde_json::from_value::<AssemblyJoint>(missing).is_err());
    }
}