Apply a model's coordinate operation when its sidecar loads

.ifcview has carried the model's CoordinateOperation since v11 and the
streaming reader has always parsed it, but applyCachedModel ignored it. The
matrix only ever reached the scene because BonsaiViewer pushes it after every
load via setModelCoordinateOperation. Nothing does that on web, so every model
rendered in its local coordinates and two federated models with differing map
conversions came out misaligned.

Seed the matrix and the unit scales from the sidecar, and recompose the model
afterwards. Seeding alone is not enough: the instance transforms in a sidecar
are baked with identity federation matrices, and applyCachedModel uploads them
as-is. The recompose also fixes a second case that had nothing to do with
georeferencing — a model loaded while a federated false origin was already in
force kept its unshifted transforms.

ModelGpuData gains the unit scales because composeModelTransformation needs
them to lift a transform's anchor point into metres, and on a sidecar-only load
there is no IFC to read them back from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-11 16:36:02 +10:00
parent d1d0fb4636
commit 935562142e
4 changed files with 167 additions and 6 deletions
+25
View File
@@ -33,6 +33,7 @@
#include "InstancedGeometry.h"
#include "BufferPool.h"
#include "FederationMath.h" // ModelUnits
#include "ChunkPlanner.h" // WGPU_CHUNK_VERTEX_BYTES_LIMIT (shared with bake)
#include "SidecarCache.h" // ElementTableRecord (element metadata)
@@ -146,6 +147,17 @@ struct ModelGpuData {
std::vector<VisibleDrawGpu> visible_draws_scratch;
std::vector<uint32_t> prefix_sums_scratch;
// What was last handed to the GPU, so an unchanged frame writes
// nothing. On Dawn-web every wgpuQueueWriteBuffer is an IPC message to
// the GPU process, and the cull re-uploaded all three buffers for every
// chunk on every frame — measured at 370-546 writes and up to 1 MB per
// frame across this federation, which is ~22,000 messages a second at
// 60fps. Comparing here costs a memcmp of the same bytes; sending them
// costs a serialised round trip through the wire.
std::vector<VisibleDrawGpu> visible_draws_uploaded;
std::vector<uint32_t> prefix_sums_uploaded;
uint32_t uniform_uploaded[4] = { 0xffffffffu, 0, 0, 0 };
// Transient transparent-half scratch. Populated alongside
// visible_draws_scratch during cull (the cull loop routes each
// visible instance to opaque or transparent based on the
@@ -442,6 +454,19 @@ struct ModelGpuData {
// would actually apply them is deferred.
Eigen::Matrix4d coordinate_operation_meters = Eigen::Matrix4d::Identity();
Eigen::Matrix4d model_transformation_meters = Eigen::Matrix4d::Identity();
// Whether coordinate_operation_meters came from a real IfcCoordinateOperation
// (sidecar v11+ has_coordinate_operation) rather than being the identity
// placeholder. The false-origin guess needs to tell those apart: identity
// because the model is genuinely un-georeferenced is not the same as
// identity because nothing has been applied yet.
bool has_coordinate_operation = false;
// Per-model unit scales, carried alongside the matrices because
// composeModelTransformation needs them to lift ModelTransformation::a into
// metres. Sourced from the sidecar so this works for sidecar-only loads
// where there is no ifcopenshell::file to re-read.
ModelUnits units;
};
// Release every wgpu handle in `m` (including per-chunk and per-model pool
+63 -5
View File
@@ -345,6 +345,16 @@ bool ViewportCore::firstGeometryPointWorldM(uint32_t session_model_id,
return true;
}
bool ViewportCore::modelGeoref(uint32_t session_model_id, ModelGeoref& out) const {
auto it = models_gpu_.find(session_model_id);
if (it == models_gpu_.end()) return false;
const ModelGpuData& m = it->second;
out.units = m.units;
out.coordinate_operation_meters = m.coordinate_operation_meters;
out.has_coordinate_operation = m.has_coordinate_operation;
return true;
}
void ViewportCore::composeInstanceFromPlacement(InstanceInfo& inst,
const ModelGpuData& m) const {
if (inst.mesh_id < m.meshes.size()) {
@@ -3120,6 +3130,28 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
model_gpu_data.streaming_file_path = metadata.file_path;
model_gpu_data.geometry_section_offset = metadata.geometry_section_offset;
// Seed the CoordinateOperation from the sidecar (v11+) so a model lands in
// global coordinates without anyone having to push it. Before this, the
// matrix stayed identity unless a host called setModelCoordinateOperation —
// which only BonsaiViewer does (modules/viewport/View.cpp), so the web
// viewer rendered every model in raw local coordinates and federated models
// with differing map conversions came out misaligned.
//
// Instances are composed further down against model_gpu_data, so this has
// to be set before that, not after.
//
// Desktop is unaffected: ViewportView::applyCoordinateOperation pushes the
// same matrix derived from the same computeModelGeoref, and
// setModelCoordinateOperation early-returns when the value is unchanged.
model_gpu_data.has_coordinate_operation = metadata.meta.has_coordinate_operation != 0;
if (model_gpu_data.has_coordinate_operation) {
// Sidecar stores column-major, matching Eigen's default storage order.
model_gpu_data.coordinate_operation_meters =
Eigen::Map<const Eigen::Matrix4d>(metadata.meta.coordinate_operation_meters);
}
model_gpu_data.units.project_length_to_meters = metadata.meta.project_length_to_meters;
model_gpu_data.units.map_unit_to_meters = metadata.meta.map_unit_to_meters;
// ---- Spatial chunk plan ----------------------------------------------
// A sidecar carries a baked chunk TOC (v14): each chunk is a contiguous
// run of meshes, laid out contiguously in the file (see SidecarLayout), so
@@ -3425,6 +3457,23 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id,
<< " instances=" << inserted_model.instance_count
<< " chunks=" << inserted_model.chunks.size();
// The instance transforms above came straight from the sidecar, where they
// were baked with identity federation matrices. Recompose whenever any of
// them is now non-identity, or the model renders in the wrong place:
//
// - its own CoordinateOperation, seeded above — a georeferenced model
// would otherwise draw at its local coordinates;
// - a federated false origin already in force, which is the normal case
// for the SECOND and later models of a federation.
//
// Desktop never hit this because BonsaiViewer pushes
// setModelCoordinateOperation + setModelTransformation after every load and
// each of those recomposes. Nothing does that on web.
if (inserted_model.has_coordinate_operation ||
!federated_false_origin_meters_.isIdentity()) {
recomposeAndUploadModel(session_model_id);
}
if (!initial_view_applied_) {
viewAll();
initial_view_applied_ = true;
@@ -3817,7 +3866,8 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t session_model_id, std::size_t
// here — they stream per chunk through beginWebChunkLoad. `source_label` is a
// log/identity tag stored as file_path (chunk reads go through the JS source,
// not this path).
void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_label) {
void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_label,
std::function<void(std::uint32_t)> on_loaded) {
if (!device_ || !queue_) {
Log::warn() << "loadSidecarMetadataWeb: wgpu not initialised";
return;
@@ -3831,7 +3881,8 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
// Head (v16): [header 12][geom_bytes 8]. The two compressed metadata blocks
// follow the compressed geometry at SIDECAR_HEAD_BYTES + geom_bytes.
webReadRangesAsync(source_id, 0, {{0, SIDECAR_HEAD_BYTES}},
[this, fsize, source_id, source_label](bool ok, std::vector<std::uint8_t>&& head) {
[this, fsize, source_id, source_label, on_loaded = std::move(on_loaded)]
(bool ok, std::vector<std::uint8_t>&& head) mutable {
std::uint64_t geom_bytes = 0;
if (!ok || !parseSidecarHead(head.data(), head.size(), geom_bytes)) {
Log::warn() << "loadSidecarMetadataWeb: bad sidecar head (wrong version?)";
@@ -3844,7 +3895,8 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
}
// Geometry metadata block on disk: [comp u64][raw u64][zstd frame].
webReadRangesAsync(source_id, 0, {{meta_off, 16}},
[this, fsize, meta_off, source_id, source_label]
[this, fsize, meta_off, source_id, source_label,
on_loaded = std::move(on_loaded)]
(bool ok2, std::vector<std::uint8_t>&& h) {
if (!ok2 || h.size() < 16) {
Log::warn() << "loadSidecarMetadataWeb: short geometry metadata header";
@@ -3861,7 +3913,8 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
webReadRangesAsync(source_id, 0,
{{geometry_metadata_off, geometry_metadata_comp}},
[this, geometry_metadata_off, geometry_metadata_comp,
geometry_metadata_raw, source_id, source_label]
geometry_metadata_raw, source_id, source_label,
on_loaded = std::move(on_loaded)]
(bool ok3, std::vector<std::uint8_t>&& cz) {
if (!ok3) {
Log::warn() << "loadSidecarMetadataWeb: geometry metadata read failed";
@@ -3898,7 +3951,8 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
geometry_metadata_off + geometry_metadata_comp;
webReadRangesAsync(source_id, 0, {{element_metadata_hdr_off, 16}},
[this, sc = std::move(sc), element_metadata_hdr_off,
source_id, source_label]
source_id, source_label,
on_loaded = std::move(on_loaded)]
(bool ok4, std::vector<std::uint8_t>&& dh) mutable {
if (ok4 && dh.size() >= 16) {
std::uint64_t dc = 0, dr = 0;
@@ -3936,6 +3990,10 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
Log::info() << "ifcviewer-web: loaded sidecar (" << source_label
<< ", id " << session_model_id << ", " << n_meshes << " meshes, "
<< n_instances << " instances)";
// Last: the model is fully in the scene, so a
// handler is free to push federation matrices
// or reframe without racing the setup above.
if (on_loaded) on_loaded(session_model_id);
});
});
});
+18 -1
View File
@@ -127,6 +127,15 @@ public:
bool firstGeometryPointWorldM(uint32_t session_model_id,
Eigen::Vector3d& out) const;
// The model's georef as seeded from its sidecar by applyCachedModel.
// Returns false when the model is unknown.
//
// This is the sidecar-only equivalent of the desktop's
// SceneLoader::modelGeoref: composeModelTransformation needs the unit
// scales and the CoordinateOperation to lift ModelTransformation::a into
// metres, and on web there is no ifcopenshell::file to re-read them from.
bool modelGeoref(uint32_t session_model_id, ModelGeoref& out) const;
// The global-id base applyCachedModel added to this model's instance
// object_ids. Callers that hold the element table separately (the desktop
// sidecar path) rebase their element records by the same base so registry
@@ -478,7 +487,15 @@ public:
// (Module.__ifcvSources[source_id] — a picked File or remote URL, already
// sized by shell.html); each federated model streams from its own source.
// `source_label` is a log/identity tag.
void loadSidecarMetadataWeb(int source_id, std::string source_label);
//
// `on_loaded` (optional) fires once the model is in the scene, carrying the
// session_model_id that was allocated for it. That id is minted inside the
// async range-read chain, so a caller holding only a source_id has no other
// way to learn it — which is what the federation layer needs in order to
// bind per-model state (transform, display name) that JS may have set
// against the source_id before the load finished.
void loadSidecarMetadataWeb(int source_id, std::string source_label,
std::function<void(std::uint32_t)> on_loaded = {});
// On-demand fetch of the v15 element metadata block (elements + string
// table) for a web-streamed model — what a UI (object tree / selected-name
@@ -250,6 +250,67 @@ TEST_CASE("Empty SidecarData round-trips cleanly", "[sidecar]") {
REQUIRE(loaded->string_table.empty());
}
// ViewportCore::applyCachedModel seeds ModelGpuData::coordinate_operation_meters
// straight from these fields, which is what puts a georeferenced model into
// global coordinates without a host having to push the matrix. That only works
// if the matrix survives the write/read round-trip in the same storage order it
// went in — a silent transpose would misplace every georeferenced model rather
// than fail loudly.
TEST_CASE("CoordinateOperation + unit scales round-trip through the sidecar", "[sidecar]") {
fs::path dir = makeScratchDir("georef");
fs::path ifc = dir / "georef.ifc";
SidecarData sd = buildFixture();
sd.has_coordinate_operation = 1;
sd.project_length_to_meters = 0.001; // model authored in millimetres
sd.map_unit_to_meters = 1.0;
// Asymmetric on purpose: a transpose would still pass a symmetric matrix.
// Translation lives in the last column under column-major storage, i.e.
// elements [12], [13], [14].
for (int i = 0; i < 16; ++i) sd.coordinate_operation_meters[i] = 0.0;
sd.coordinate_operation_meters[0] = 0.5; // (0,0)
sd.coordinate_operation_meters[1] = 0.25; // (1,0)
sd.coordinate_operation_meters[5] = 2.0; // (1,1)
sd.coordinate_operation_meters[10] = 1.0; // (2,2)
sd.coordinate_operation_meters[12] = -2523.02945910871; // eastings
sd.coordinate_operation_meters[13] = -4962.73759029173; // northings
sd.coordinate_operation_meters[14] = 1580.0; // orthogonal height
sd.coordinate_operation_meters[15] = 1.0;
REQUIRE(writeSidecar(ifc.string(), sd));
auto loaded = readSidecar(ifc.string());
REQUIRE(loaded.has_value());
REQUIRE(loaded->has_coordinate_operation == 1);
REQUIRE(loaded->project_length_to_meters == 0.001);
REQUIRE(loaded->map_unit_to_meters == 1.0);
for (int i = 0; i < 16; ++i) {
REQUIRE(loaded->coordinate_operation_meters[i] ==
sd.coordinate_operation_meters[i]);
}
}
// A model with no IfcMapConversion must come back with the flag clear, so the
// seeding leaves the identity placeholder alone rather than baking in a
// half-populated matrix.
TEST_CASE("Sidecar without a CoordinateOperation reports none", "[sidecar]") {
fs::path dir = makeScratchDir("nogeoref");
fs::path ifc = dir / "nogeoref.ifc";
// buildFixture() populates the georef block, so clear it back to what a
// model with no IfcMapConversion bakes: flag down, identity placeholder.
SidecarData sd = buildFixture();
sd.has_coordinate_operation = 0;
for (int i = 0; i < 16; ++i) sd.coordinate_operation_meters[i] = (i % 5 == 0) ? 1.0 : 0.0;
REQUIRE(writeSidecar(ifc.string(), sd));
auto loaded = readSidecar(ifc.string());
REQUIRE(loaded.has_value());
REQUIRE(loaded->has_coordinate_operation == 0);
for (int i = 0; i < 16; ++i) {
REQUIRE(loaded->coordinate_operation_meters[i] == ((i % 5 == 0) ? 1.0 : 0.0));
}
}
TEST_CASE("Sidecar path stem maps .ifc / .ifcdb / extensionless to .ifcview", "[sidecar]") {
// The mapping is internal but observable: writing under one source name
// must be readable under any other name that maps to the same stem.