diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index aa5b312013..781afac955 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -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 visible_draws_scratch; std::vector 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 visible_draws_uploaded; + std::vector 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 diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 1b3b2f72e6..2a17c590a6 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -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(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 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&& head) { + [this, fsize, source_id, source_label, on_loaded = std::move(on_loaded)] + (bool ok, std::vector&& 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&& 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&& 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&& 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); }); }); }); diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 416e555584..a1fac57a6b 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -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 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 diff --git a/src/ifcviewer/tests/test_sidecar_cache.cpp b/src/ifcviewer/tests/test_sidecar_cache.cpp index 41395ca948..47e1bbfd67 100644 --- a/src/ifcviewer/tests/test_sidecar_cache.cpp +++ b/src/ifcviewer/tests/test_sidecar_cache.cpp @@ -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.