cadmpeg 0.6.8

Convert and inspect native CAD files.
// SPDX-License-Identifier: Apache-2.0
//! `diff --geometry`: ID-free comparison of measured bodies, with
//! expectations from the comparison rules in `docs/measurement.md`.

#[cfg(any(feature = "step", feature = "fcstd"))]
use std::path::Path;
#[cfg(feature = "step")]
use std::path::PathBuf;

use assert_cmd::Command;
use cadmpeg_ir::examples::unit_cube;
use serde_json::Value;
use tempfile::tempdir;

use crate::support::fixture;

fn cadmpeg() -> Command {
    Command::cargo_bin("cadmpeg").unwrap()
}

/// Run `diff A B --geometry --json` and return the exit code and report.
#[cfg(any(feature = "step", feature = "fcstd"))]
fn geometry_json(a: &str, b: &str) -> (Option<i32>, Value) {
    let output = cadmpeg()
        .args(["diff", a, b, "--geometry", "--json"])
        .output()
        .unwrap();
    let report = serde_json::from_slice(&output.stdout)
        .unwrap_or_else(|_| panic!("{}", String::from_utf8_lossy(&output.stderr)));
    (output.status.code(), report)
}

#[cfg(feature = "step")]
fn measure_fixture(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/measure")
        .join(name)
}

#[cfg(feature = "step")]
#[test]
fn geometry_diff_round_trip_agrees() {
    let dir = tempdir().unwrap();
    let source = measure_fixture("measure_cylinder_solid.p21");
    let written = dir.path().join("rt.step");
    cadmpeg()
        .args(["convert", source.to_str().unwrap(), "-o"])
        .arg(&written)
        .args(["--to", "step:ap242-e3"])
        .assert()
        .success();
    let (code, report) = geometry_json(source.to_str().unwrap(), written.to_str().unwrap());
    assert_eq!(code, Some(0), "{report:#}");
    assert_eq!(report["different"], false);
    let comparison = &report["geometry"]["comparison"];
    assert_eq!(comparison["verdict"], "agree");
    assert_eq!(comparison["placement"]["a"], "trivial");
    assert_eq!(comparison["placement"]["b"], "trivial");
    assert_eq!(comparison["pairs"].as_array().unwrap().len(), 1);
    assert!(report["diff"].is_object());
    assert!(report["source_fidelity"].is_object());
}

/// Write `source` as STEP and return the export loss codes of the report.
#[cfg(all(feature = "step", feature = "iges"))]
fn step_export_loss_codes(source: &Path, written: &Path, report: &Path) -> Vec<String> {
    cadmpeg()
        .args(["convert", source.to_str().unwrap(), "-o"])
        .arg(written)
        .arg("--report")
        .arg(report)
        .assert()
        .success();
    let report: Value = serde_json::from_slice(&std::fs::read(report).unwrap()).unwrap();
    report["export"]["losses"]
        .as_array()
        .unwrap()
        .iter()
        .map(|loss| loss["code"]["code"].as_str().unwrap().to_owned())
        .collect()
}

/// A face whose outer boundary is the natural boundary of an IGES Type 144
/// support keeps its domain in STEP, or STEP reports that it cannot. The
/// unbounded plane has no finite domain: the STEP writer omits the face and
/// reports `topology.face-implicit-outer-bound-unwritable`. The bilinear
/// patch states its parameter rectangle: the written face measures the patch
/// less its hole, as the source does.
#[cfg(all(feature = "step", feature = "iges"))]
#[test]
fn iges_implicit_outer_face_keeps_its_domain_in_step() {
    const UNWRITABLE: &str = "topology.face-implicit-outer-bound-unwritable";
    let dir = tempdir().unwrap();

    let plane = measure_fixture("measure_implicit_outer_plane.igs");
    let written = dir.path().join("plane.step");
    let codes = step_export_loss_codes(&plane, &written, &dir.path().join("plane.json"));
    assert!(codes.iter().any(|code| code == UNWRITABLE));
    let text = std::fs::read_to_string(&written).unwrap();
    assert!(!text.contains("ADVANCED_FACE"));

    let patch = measure_fixture("measure_implicit_outer_bspline.igs");
    let written = dir.path().join("patch.step");
    let codes = step_export_loss_codes(&patch, &written, &dir.path().join("patch.json"));
    assert!(!codes.iter().any(|code| code == UNWRITABLE));
    let (code, report) = geometry_json(patch.to_str().unwrap(), written.to_str().unwrap());
    assert_eq!(code, Some(0));
    let comparison = &report["geometry"]["comparison"];
    assert_eq!(comparison["verdict"], "agree");
    assert_eq!(comparison["pairs"].as_array().unwrap().len(), 1);
}

/// The cylinder fixture moved by +1 mm in `x`: the four points of its axis
/// placements and vertices shift.
#[cfg(feature = "step")]
#[test]
fn geometry_diff_detects_moved_body() {
    let dir = tempdir().unwrap();
    let source = measure_fixture("measure_cylinder_solid.p21");
    let text = std::fs::read_to_string(&source).unwrap();
    let moved_text = text
        .replace(
            "#10=CARTESIAN_POINT('',(0.,0.,0.));",
            "#10=CARTESIAN_POINT('',(1.,0.,0.));",
        )
        .replace(
            "#11=CARTESIAN_POINT('',(0.,0.,30.));",
            "#11=CARTESIAN_POINT('',(1.,0.,30.));",
        )
        .replace(
            "#20=CARTESIAN_POINT('',(10.,0.,0.));",
            "#20=CARTESIAN_POINT('',(11.,0.,0.));",
        )
        .replace(
            "#21=CARTESIAN_POINT('',(10.,0.,30.));",
            "#21=CARTESIAN_POINT('',(11.,0.,30.));",
        );
    assert_ne!(moved_text, text);
    let moved = dir.path().join("moved.p21");
    std::fs::write(&moved, moved_text).unwrap();
    let (code, report) = geometry_json(source.to_str().unwrap(), moved.to_str().unwrap());
    assert_eq!(code, Some(1));
    let comparison = &report["geometry"]["comparison"];
    assert_eq!(comparison["verdict"], "disagree");
    assert_eq!(
        comparison["unmatched_a"],
        serde_json::json!(["step:data:body#71"])
    );
    assert_eq!(comparison["unmatched_b"].as_array().unwrap().len(), 1);
    let row = |quantity: &str| {
        comparison["document"]
            .as_array()
            .unwrap()
            .iter()
            .find(|row| row["quantity"] == quantity)
            .unwrap_or_else(|| panic!("document row {quantity}"))
            .clone()
    };
    assert_eq!(row("area")["agrees"], true);
    assert_eq!(row("closed_volume")["agrees"], true);
    assert_eq!(row("bbox.min.x")["agrees"], false);
}

/// `text` with the `x` coordinate of every `CARTESIAN_POINT` moved by `dx`.
#[cfg(feature = "step")]
fn shifted_x(text: &str, dx: f64) -> String {
    const POINT: &str = "CARTESIAN_POINT('',(";
    text.lines()
        .map(|line| {
            let Some(start) = line.find(POINT).map(|at| at + POINT.len()) else {
                return line.to_owned();
            };
            let end = start + line[start..].find(',').expect("a coordinate list");
            let x: f64 = line[start..end].parse().expect("a coordinate");
            format!("{}{:?}{}", &line[..start], x + dx, &line[end..])
        })
        .collect::<Vec<_>>()
        .join("\n")
}

/// A face whose loops bound no region compares by its signature. Its
/// vertex box is the row that depends on position, so a copy moved by
/// +100 mm in `x` disagrees, and the unmoved file agrees with itself.
#[cfg(feature = "step")]
#[test]
fn geometry_diff_detects_a_moved_signature_body() {
    let dir = tempdir().unwrap();
    let source = measure_fixture("measure_plane_islands.p21");
    let text = std::fs::read_to_string(&source).unwrap();
    let moved = dir.path().join("moved.p21");
    std::fs::write(&moved, shifted_x(&text, 100.0)).unwrap();
    let (code, report) = geometry_json(source.to_str().unwrap(), source.to_str().unwrap());
    assert_eq!(code, Some(0), "{report:#}");
    let comparison = &report["geometry"]["comparison"];
    assert_eq!(comparison["verdict"], "agree");
    let rows = comparison["pairs"][0]["quantities"].as_array().unwrap();
    assert!(rows
        .iter()
        .all(|row| row["quantity"].as_str().unwrap().starts_with("signature.")));
    assert!(rows
        .iter()
        .any(|row| row["quantity"] == "signature.vertex_box.min.x" && row["agrees"] == true));
    let (code, report) = geometry_json(source.to_str().unwrap(), moved.to_str().unwrap());
    assert_eq!(code, Some(1), "{report:#}");
    let comparison = &report["geometry"]["comparison"];
    assert_eq!(comparison["verdict"], "disagree");
    assert_eq!(comparison["unmatched_a"].as_array().unwrap().len(), 1);
    assert_eq!(comparison["unmatched_b"].as_array().unwrap().len(), 1);
}

/// Each `FreeCAD` corpus file against itself. Identical documents give
/// identical rows, so nothing disagrees and no body is unmatched. A body
/// whose faces lie on procedural carriers without an admitted cache
/// (`Sweep`) has no comparable quantity, datum bodies with no vertex
/// (`DatumPlane`, `DatumCoordinateSystem`) compare by a signature that does
/// not depend on position, and occurrences that place bodies
/// (`core_design_product`) keep the verdict inconclusive. A document with
/// none of these (`geometry_topology`) agrees with itself.
#[cfg(feature = "fcstd")]
#[test]
fn geometry_diff_self_comparison() {
    let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/freecad_fcstd/fixtures");
    for (file, verdict, unmeasured, causes) in [
        (
            "core_operations.FCStd",
            "inconclusive",
            &["#Sweep:"][..],
            &["compare.unmeasured-body", "compare.signature-unplaced"][..],
        ),
        ("geometry_topology.FCStd", "agree", &[][..], &[][..]),
        (
            "core_design_product.FCStd",
            "inconclusive",
            &[][..],
            &["compare.occurrence-placement"][..],
        ),
    ] {
        let path = corpus.join(file);
        let (code, report) = geometry_json(path.to_str().unwrap(), path.to_str().unwrap());
        let comparison = &report["geometry"]["comparison"];
        assert_eq!(comparison["verdict"], verdict, "{file}");
        assert_eq!(code, Some(i32::from(verdict != "agree")), "{file}");
        assert_eq!(
            comparison["inconclusive_causes"],
            serde_json::json!(causes),
            "{file}"
        );
        assert!(comparison["unmatched_a"].as_array().unwrap().is_empty());
        assert!(comparison["unmatched_b"].as_array().unwrap().is_empty());
        let names: Vec<&str> = comparison["unmeasured_a"]
            .as_array()
            .unwrap()
            .iter()
            .map(|id| id.as_str().unwrap())
            .collect();
        assert_eq!(names.len(), unmeasured.len(), "{file}: {names:?}");
        for needle in unmeasured {
            assert!(
                names.iter().any(|id| id.contains(needle)),
                "{file}: {needle}"
            );
        }
        let rows = comparison["pairs"]
            .as_array()
            .unwrap()
            .iter()
            .flat_map(|pair| pair["quantities"].as_array().unwrap().iter())
            .chain(comparison["document"].as_array().unwrap().iter());
        for row in rows {
            assert_ne!(row["agrees"], false, "{file}: {row:#}");
        }
    }
}

#[test]
fn geometry_diff_tsv() {
    let dir = tempdir().unwrap();
    let cube = fixture(
        dir.path(),
        "cube.cadir.json",
        &unit_cube().expect("unit cube fixture is admitted"),
    );
    let output = cadmpeg()
        .args(["diff"])
        .arg(&cube)
        .arg(&cube)
        .args(["--geometry", "--tsv"])
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(0));
    let text = String::from_utf8(output.stdout).unwrap();
    let header = text.lines().next().unwrap();
    assert_eq!(
        header,
        "a\tb\tquantity\tunit\tvalue_a\tvalue_b\tdifference\ttolerance\trule\tagrees"
    );
    assert!(text
        .lines()
        .any(|line| line.starts_with("document\tdocument\tbody_count\t")));
    cadmpeg()
        .args(["diff"])
        .arg(&cube)
        .arg(&cube)
        .arg("--tsv")
        .assert()
        .code(2);
    cadmpeg()
        .args(["diff"])
        .arg(&cube)
        .arg(&cube)
        .args(["--geometry", "--tsv", "--json"])
        .assert()
        .code(2);
}

#[test]
fn geometry_diff_rejects_container_only() {
    let dir = tempdir().unwrap();
    let cube = fixture(
        dir.path(),
        "cube.cadir.json",
        &unit_cube().expect("unit cube fixture is admitted"),
    );
    cadmpeg()
        .args(["diff"])
        .arg(&cube)
        .arg(&cube)
        .args(["--geometry", "--container-only"])
        .assert()
        .code(2);
    cadmpeg()
        .args(["diff"])
        .arg(&cube)
        .arg(&cube)
        .args(["--geometry", "--geometry-tolerance", "1.5"])
        .assert()
        .code(2);
}

#[test]
fn id_diff_report_has_no_geometry_member() {
    let dir = tempdir().unwrap();
    let cube = fixture(
        dir.path(),
        "cube.cadir.json",
        &unit_cube().expect("unit cube fixture is admitted"),
    );
    let output = cadmpeg()
        .args(["diff"])
        .arg(&cube)
        .arg(&cube)
        .arg("--json")
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(0));
    let report: Value = serde_json::from_slice(&output.stdout).unwrap();
    let mut keys: Vec<&str> = report
        .as_object()
        .unwrap()
        .keys()
        .map(String::as_str)
        .collect();
    keys.sort_unstable();
    assert_eq!(
        keys,
        [
            "command",
            "diff",
            "different",
            "generator",
            "ir_version",
            "refusal",
            "source_fidelity",
            "status"
        ]
    );
}