Derive map units from IFC scale

Use IfcMapConversion.Scale as the source of truth for converting map coordinates to metres, instead of deriving that scale from IfcProjectedCRS.MapUnit. Bump the sidecar version because cached georef matrices and unit scales may differ under the new interpretation.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Dion Moult
2026-05-19 18:58:17 +10:00
parent e0a504417c
commit 6ca38f8bf6
7 changed files with 41 additions and 24 deletions
+2 -11
View File
@@ -105,18 +105,9 @@ ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file) {
out.units.project_length_to_meters =
calculateUnitScale(ifc_file, "LENGTHUNIT");
if (auto map_unit = getMapUnit(ifc_file)) {
if (auto s = siScaleFromNamedUnit(*map_unit)) {
out.units.map_unit_to_meters = *s;
} else {
out.units.map_unit_to_meters = out.units.project_length_to_meters;
}
} else {
// No MapUnit on the IfcProjectedCRS — fall back to project length unit.
out.units.map_unit_to_meters = out.units.project_length_to_meters;
}
auto params = getHelmertTransformationParameters(ifc_file);
const double scale = (params && params->scale != 0.0) ? params->scale : 1.0;
out.units.map_unit_to_meters = out.units.project_length_to_meters / scale;
if (!params) return out;
Eigen::Matrix4d helmert =
+5 -4
View File
@@ -100,10 +100,11 @@ struct ModelTransformation {
Eigen::Vector3d pivot = Eigen::Vector3d::Zero(); // federation unit
};
// Per-model unit scales captured at load time. project_length_to_meters
// comes from calculateUnitScale(file, "LENGTHUNIT"); map_unit_to_meters from
// siScaleFromNamedUnit(getMapUnit(file)) and falls back to the project length
// scale when the model has no MapUnit.
// Per-model unit scales captured at load time. project_length_to_meters comes
// from calculateUnitScale(file, "LENGTHUNIT"). map_unit_to_meters is derived
// from IfcMapConversion.Scale as project_length_to_meters / Scale; the
// IfcProjectedCRS.MapUnit named unit is metadata and does not affect the
// transform composition.
struct ModelUnits {
double project_length_to_meters = 1.0;
double map_unit_to_meters = 1.0;
+8 -5
View File
@@ -81,9 +81,11 @@ Eigen::Matrix4d autoLocal2Global(ifcopenshell::file* ifc_file,
// [ R_z(theta) · diag(fx, fy, fz) | (e, n, h) · u_m ]
// [ 0 | 1 ]
//
// `map_unit_to_meters` is the SI scale of IfcProjectedCRS.MapUnit (or the
// project's LENGTHUNIT scale if MapUnit is absent). The caller composes any
// IfcGeometricRepresentationContext WCS on the right:
// `map_unit_to_meters` is derived by the caller from the IFC project length
// unit and the authoritative IfcMapConversion.Scale. Since this matrix takes
// meter inputs from the geometry iterator, Scale is represented by that unit
// conversion and is not applied again in the linear block. The caller composes
// any IfcGeometricRepresentationContext WCS on the right:
// G = helmertMetersFromParameters(...) · inv(wcs_meters)
// (where wcs_meters has its translation column converted from project units
// to meters via calculateUnitScale).
@@ -96,8 +98,9 @@ Eigen::Matrix4d helmertMetersFromParameters(const HelmertTransformation& params,
// IfcCoordinateOperation.TargetCRS.MapUnit (the IfcNamedUnit), if present.
// Returns nullopt for IFC2X3, models without an IfcCoordinateOperation, or
// when MapUnit is absent on the IfcProjectedCRS. Callers fall back to
// calculateUnitScale(file, "LENGTHUNIT") in that case.
// when MapUnit is absent on the IfcProjectedCRS. This is retained for UI /
// metadata inspection; transform composition derives map unit scale from
// IfcMapConversion.Scale instead.
std::optional<express::Base> getMapUnit(ifcopenshell::file* ifc_file);
// "How do I rotate project east to get to grid east?" — i.e. -atan2(xao, xaa)
+2 -2
View File
@@ -17,7 +17,7 @@
* *
********************************************************************************/
// v12 layout (all multi-byte fields native-endian; endianness marker in header).
// v13 layout (all multi-byte fields native-endian; endianness marker in header).
//
// SidecarHeader (12 bytes)
//
@@ -30,7 +30,7 @@
// MeshInfo[num_meshes]
//
// uint32_t num_instances
// InstanceCpu[num_instances] (already sorted by mesh_id; v12 layout)
// InstanceCpu[num_instances] (already sorted by mesh_id; v13 layout)
//
// uint32_t has_coordinate_operation (v11+)
// double[16] coordinate_operation_meters (v11+; column-major)
+3 -1
View File
@@ -63,7 +63,9 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
// InstanceChunk carries the streamer placement as double[16]. This keeps
// large IFC placements exact until CoordinateOperation / FederatedFalseOrigin
// composition has reduced them to viewport-local float-sized values.
static constexpr uint32_t SIDECAR_VERSION = 12;
// v13 = Map unit scale in cached ModelGeoref is derived from
// IfcMapConversion.Scale, not IfcProjectedCRS.MapUnit.
static constexpr uint32_t SIDECAR_VERSION = 13;
static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304;
// Fixed-size element record. Strings are stored as (offset, length) pairs
+20
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "Federation.h"
#include "Geolocation.h"
#include <catch2/catch_test_macros.hpp>
@@ -457,6 +458,25 @@ TEST_CASE("composeFederatedFalseOrigin scales by federation unit",
REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9);
}
TEST_CASE("helmert metres transform consumes Scale through map unit scale",
"[federation][georef]") {
HelmertTransformation params;
params.e = 500000.0;
params.n = 7000000.0;
params.scale = 0.001; // project mm -> map numeric metres
const double project_length_to_meters = 0.001;
const double map_unit_to_meters = project_length_to_meters / params.scale;
Eigen::Matrix4d M = helmertMetersFromParameters(params, map_unit_to_meters);
Eigen::Vector4d local_m(10.0, 20.0, 0.0, 1.0);
Eigen::Vector4d global_m = M * local_m;
REQUIRE(std::abs(map_unit_to_meters - 1.0) < 1e-12);
REQUIRE(std::abs(global_m.x() - 500010.0) < 1e-9);
REQUIRE(std::abs(global_m.y() - 7000020.0) < 1e-9);
}
TEST_CASE("addGroup creates a top-level group; addGroup with parent nests it",
"[federation][groups]") {
ensureQApp();
+1 -1
View File
@@ -155,7 +155,7 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") {
REQUIRE(sizeof(MeshInfo) == 56);
REQUIRE(sizeof(InstanceGpu) == 80);
REQUIRE(SIDECAR_VERSION == 12);
REQUIRE(SIDECAR_VERSION == 13);
REQUIRE(SIDECAR_MAGIC == 0x49465657u);
}