ifcviewer: add stage 3+4 data model and compose helpers to Federation

Adds the structs that were briefly in src/ifcviewer/Federation.{h,cpp}
two commits ago, now folded into the merged Federation alongside the
file persistence layer:

  - FederationConfig: federation-wide unit ({prefix, name}).  Default
    METRE; one-of an IfcSIUnit name with optional prefix or an
    IfcConversionBasedUnit name.
  - FederationOrigin: stage 3 — XYZ in federation unit + Z-rot.
    Composes to R_z · T(-xyz_meters), nominating a point as origin.
  - AFrame + ModelTransform: stage 4 intent — A (model project or
    map unit, per a_frame), B and pivot (federation unit), full
    intrinsic-XYZ Euler rotation in degrees.
  - ModelUnits: per-model project_length_to_meters / map_unit_to_meters
    cached at load time.

Free functions composeFederationOrigin and composeModelTransform
return Eigen::Matrix4d in metres.  composeModelTransform takes the
model's stage-2 georef matrix so it can lift `a` into metres when
authored in ModelLocal.

Federation gains config_, origin_ members + setters that emit
dirtyChanged.  Each Model carries a transform_intent.  JSON I/O
emits config / origin always; transform_intent only when non-default.
Schema stays "ifcfed/1" — additive, optional, sane defaults.

Five new tests: round-trip of the new fields, default-omission
behaviour, two compose smoke tests for FederationOrigin, and one
verifying the "pivot at B preserves A→B" invariant of
composeModelTransform.  All 36 ctest cases pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-01 15:15:37 +10:00
parent ecf0a5a4e1
commit 5386c9ec69
4 changed files with 417 additions and 10 deletions
+127
View File
@@ -304,3 +304,130 @@ TEST_CASE("load on malformed JSON fails with an error", "[federation]") {
REQUIRE_FALSE(fed.load(bad, &warnings, &err));
REQUIRE_FALSE(err.isEmpty());
}
TEST_CASE("config / origin / transform_intent round-trip through save+load",
"[federation]") {
ensureQApp();
QTemporaryDir tmp;
REQUIRE(tmp.isValid());
QString src1 = writeStubFile(tmp.filePath("models/wall.ifc"));
QString fed_path = tmp.filePath("project.ifcfed");
Federation src;
QString id1 = src.addModel(src1, "Wall");
FederationConfig cfg;
cfg.unit_name = "FOOT";
cfg.unit_prefix = "";
src.setConfig(cfg);
FederationOrigin org;
org.xyz = Eigen::Vector3d(100.0, 200.0, 30.0);
org.rz_deg = 45.0;
src.setOrigin(org);
ModelTransform xf;
xf.a_frame = AFrame::ModelLocal;
xf.a = Eigen::Vector3d(1.0, 2.0, 3.0);
xf.b = Eigen::Vector3d(4.0, 5.0, 6.0);
xf.rxyz_deg = Eigen::Vector3d(90.0, 0.0, 0.0);
xf.pivot = Eigen::Vector3d(7.0, 8.0, 9.0);
src.setModelTransform(id1, xf);
QString err;
REQUIRE(src.save(fed_path, &err));
REQUIRE(err.isEmpty());
Federation dst;
QStringList warnings;
REQUIRE(dst.load(fed_path, &warnings, &err));
REQUIRE(err.isEmpty());
REQUIRE(warnings.isEmpty());
REQUIRE(dst.config().unit_name == "FOOT");
REQUIRE(dst.config().unit_prefix == "");
REQUIRE(dst.origin().xyz == org.xyz);
REQUIRE(dst.origin().rz_deg == 45.0);
REQUIRE(dst.models().size() == 1);
const auto& m = dst.models()[0];
REQUIRE(m.id == id1);
REQUIRE(m.transform_intent.a_frame == AFrame::ModelLocal);
REQUIRE(m.transform_intent.a == xf.a);
REQUIRE(m.transform_intent.b == xf.b);
REQUIRE(m.transform_intent.rxyz_deg == xf.rxyz_deg);
REQUIRE(m.transform_intent.pivot == xf.pivot);
}
TEST_CASE("default ModelTransform is omitted from saved JSON", "[federation]") {
ensureQApp();
QTemporaryDir tmp;
REQUIRE(tmp.isValid());
QString src1 = writeStubFile(tmp.filePath("models/wall.ifc"));
QString fed_path = tmp.filePath("project.ifcfed");
Federation src;
src.addModel(src1, "Wall");
QString err;
REQUIRE(src.save(fed_path, &err));
QJsonObject root = readJsonFile(fed_path);
QJsonArray models = root.value("models").toArray();
REQUIRE(models.size() == 1);
REQUIRE_FALSE(models[0].toObject().contains("transform_intent"));
}
TEST_CASE("composeFederationOrigin moves the nominated point to the origin",
"[federation][compose]") {
FederationConfig cfg; // METRE, no prefix
FederationOrigin org;
org.xyz = Eigen::Vector3d(10.0, 20.0, 5.0);
org.rz_deg = 0.0;
Eigen::Matrix4d M = composeFederationOrigin(org, cfg);
// The nominated point (10, 20, 5) should map to (0, 0, 0).
Eigen::Vector4d p(10.0, 20.0, 5.0, 1.0);
Eigen::Vector4d r = M * p;
REQUIRE(std::abs(r.x()) < 1e-9);
REQUIRE(std::abs(r.y()) < 1e-9);
REQUIRE(std::abs(r.z()) < 1e-9);
}
TEST_CASE("composeFederationOrigin scales by federation unit",
"[federation][compose]") {
FederationConfig cfg;
cfg.unit_name = "FOOT"; // 1 ft = 0.3048 m
FederationOrigin org;
org.xyz = Eigen::Vector3d(1.0, 0.0, 0.0); // 1 foot in fed coords
Eigen::Matrix4d M = composeFederationOrigin(org, cfg);
// Translation column should be -1 ft = -0.3048 m.
REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9);
}
TEST_CASE("composeModelTransform with pivot=B keeps A landing on B",
"[federation][compose]") {
// A in ModelGlobal frame, federation in metres, model has identity stage 2.
FederationConfig fed_cfg; // METRE
ModelUnits mu; // 1.0 / 1.0 (already in metres)
Eigen::Matrix4d stage2 = Eigen::Matrix4d::Identity();
ModelTransform xf;
xf.a_frame = AFrame::ModelGlobal;
xf.a = Eigen::Vector3d(5.0, 0.0, 0.0);
xf.b = Eigen::Vector3d(100.0, 50.0, 10.0);
xf.rxyz_deg = Eigen::Vector3d(0.0, 0.0, 30.0);
xf.pivot = xf.b; // pivot at B preserves A->B regardless of rotation
Eigen::Matrix4d M = composeModelTransform(xf, fed_cfg, mu, stage2);
Eigen::Vector4d a(xf.a.x(), xf.a.y(), xf.a.z(), 1.0);
Eigen::Vector4d r = M * a;
REQUIRE(std::abs(r.x() - xf.b.x()) < 1e-9);
REQUIRE(std::abs(r.y() - xf.b.y()) < 1e-9);
REQUIRE(std::abs(r.z() - xf.b.z()) < 1e-9);
}